Skip to content

Instantly share code, notes, and snippets.

@lwillek
Created January 14, 2026 06:39
Show Gist options
  • Select an option

  • Save lwillek/cac6564392cb4722d7581dbb265bae63 to your computer and use it in GitHub Desktop.

Select an option

Save lwillek/cac6564392cb4722d7581dbb265bae63 to your computer and use it in GitHub Desktop.
Cleaning Up Local Git Branches: A Practical Guide

Cleaning Up Local Git Branches: A Practical Guide

Imagine you’ve been hopping between tasks for a few months, creating a new branch for every ticket. Many were merged, some were abandoned, and others were just experiments. Now your local repository is overflowing with dozens of branches you no longer need.

This guide helps you clean up, by creating a script which:

  • identifies local branches that don’t exist on any remote or whose upstream has been deleted,
  • shows you what it plans to delete,
  • and asks for your confirmation before proceeding.

Audience

This guide is written for junior engineers with basic Git knowledge. You should be comfortable with branching and switching branches, and willing to learn a bit about remotes and upstreams.

What This Script Does for You

  • Verifies required tools are installed
  • Ensures you’re in a Git repository
  • Fetches and prunes remote references to get the latest state
  • Detects your repository’s default branch using the remote origin/HEAD
  • Checks out the default branch (creating a local copy from origin if needed)
  • Collects local branches that:
    • no longer have a corresponding branch on any remote, or
    • are marked as “gone” (their upstream remote branch was deleted)
  • Displays the list of candidates and asks for confirmation
  • Deletes the selected local branches

It’s designed to be safe by default and clear in what it’s about to do.

Prerequisites

  • Bash 4+ (associative arrays are used)
  • Git, awk, sort, grep installed and available in your PATH
  • A remote named origin, with its HEAD configured to the default branch

If origin/HEAD isn’t set, the script will stop.

Installation

  1. Save the script to a file, for example:
    • git-clean-local-branches.sh
  2. Make it executable:
    • chmod +x git-clean-local-branches.sh
  3. Optionally, move it into a directory in your PATH (e.g., ~/bin).

Usage

Run the script from within your Git repository:

./git-clean-local-branches.sh

What you’ll see:

  • It will verify prerequisites and repository state
  • It will fetch and prune remote refs
  • It will switch to the default branch if necessary
  • It will list branches that are safe to delete locally (local-only and gone-upstream)
  • It will prompt:
    • “Delete these branches? [y/N]:”
  • Answer “y” or “yes” to proceed; anything else aborts.

What Counts as a Deletion Candidate?

  • Local-only: A local branch whose name doesn’t appear under any remote (e.g., no refs/remotes/*/<branch>).
  • Gone-upstream: A local branch whose upstream remote branch has been deleted (Git shows “[gone]” in git branch -vv).

Safety Considerations

  • The script confirms before deleting anything
  • It reads the confirmation from /dev/tty to avoid issues if stdin is redirected (e.g., in pipelines)
  • It refuses to guess default branch names—if origin/HEAD isn’t configured, it fails with instructions
  • It uses git fetch --all --prune to ensure remote tracking information is up-to-date

Tip: If a branch was deleted accidentally, you can often recover via the reflog:

  • git reflog to find the commit.
  • git branch recovered <commit> to recreate the branch.

Why the Default Branch Matters

Deleting branches while you’re on one of them isn’t possible. The script switches you to your repository’s default branch before cleanup. It detects that branch strictly via origin/HEAD.

If the default branch doesn’t exist locally, it’s created from origin/<default>.

Troubleshooting

  • Error: “Not inside a git repository.”
    • Ensure you’re running the script in a repository directory.
  • Error: “Cannot determine default branch from origin/HEAD.”
    • Set origin/HEAD (see below) or ensure origin exists and is reachable.
  • Error: Missing required command(s)
    • Install the listed tools (e.g., via your package manager).
  • Script doesn’t prompt for input
    • Ensure it’s run in an interactive terminal; it reads from /dev/tty.

How to Set origin/HEAD (If Missing)

If your repo complains about detecting the default branch, set origin/HEAD to point to the correct default branch:

git remote set-head origin --auto
# or explicitly:
git remote set-head origin main
# or:
git remote set-head origin master

Verify:

git symbolic-ref --short refs/remotes/origin/HEAD
# Expected: origin/main (or origin/master, etc.)

Limitations and Design Choices

  • The script checks all remotes for branch-name matches when deciding if a local branch is “local-only.”
    • If you want to restrict to origin only, adjust the filter from refs/remotes/*/<branch> to refs/remotes/origin/<branch>.
  • It assumes a working network to fetch; if you’re offline, the fetch step will fail early.

Code

#!/usr/bin/env bash
set -euo pipefail

die() {
  printf 'Error: %s\n' "$*" >&2
  exit 1
}

require_cmds() {
  local -a missing=()
  local cmd
  for cmd in git awk sort grep; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
      missing+=("$cmd")
    fi
  done
  if ((${#missing[@]} > 0)); then
    die "Missing required command(s): ${missing[*]}"
  fi
}

ensure_git_repo() {
  git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "Not inside a git repository."
}

fetch_upstream() {
  printf 'Fetching latest upstream changes...\n'
  git fetch --all --prune || die "Fetch failed."
}

detect_default_branch() {
  # Determine default branch from origin/HEAD; fail if not resolvable.
  local remote_head_ref
  remote_head_ref="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)" \
    || die "Cannot determine default branch from origin/HEAD. Ensure remote 'origin' exists and its HEAD is set."
  printf '%s\n' "${remote_head_ref#origin/}"
}

ensure_on_default_branch() {
  # Switch to default branch if not currently on it; create it from origin if absent locally.
  local default_branch current_branch
  default_branch="$1"
  current_branch="$(git rev-parse --abbrev-ref HEAD || true)"
  if [[ "$current_branch" != "$default_branch" ]]; then
    printf 'Switching to default branch %s...\n' "$default_branch"
    if git show-ref --verify --quiet "refs/heads/$default_branch"; then
      git checkout --force --progress "$default_branch" || die "Failed to check out '$default_branch'."
    else
      git checkout -B "$default_branch" "origin/$default_branch" \
        || die "Failed to create and check out '$default_branch' from origin."
    fi
  fi
}

collect_branches_to_delete() {
  # Print candidates: local branches with no matching remote branch on any remote,
  # plus branches whose upstream is gone.
  local current_branch
  current_branch="$(git rev-parse --abbrev-ref HEAD)"

  local -a local_branches gone_branches
  mapfile -t local_branches < <(git for-each-ref refs/heads --format='%(refname:short)')
  mapfile -t gone_branches  < <(git branch -vv | awk '/\[.*: gone\]/{print $1}')

  declare -A set=()
  local b

  for b in "${gone_branches[@]}"; do
    [[ "$b" == "$current_branch" ]] && continue
    set["$b"]=1
  done

  for b in "${local_branches[@]}"; do
    [[ "$b" == "$current_branch" ]] && continue
    if ! git for-each-ref --format='%(refname:short)' "refs/remotes/*/$b" | grep -q .; then
      set["$b"]=1
    fi
  done

  local -a out=()
  for b in "${!set[@]}"; do
    out+=("$b")
  done
  if ((${#out[@]} > 0)); then
    LC_ALL=C mapfile -t out < <(printf '%s\n' "${out[@]}" | sort)
  fi

  printf '%s\n' "${out[@]}"
}

confirm_and_delete() {
  local -a branches=("$@")

  if ((${#branches[@]} == 0)); then
    printf 'No local-only or gone-upstream branches to delete.\n'
    return 0
  fi

  printf 'The following %d branch(es) will be deleted:\n' "${#branches[@]}"
  local b
  for b in "${branches[@]}"; do
    printf '  %s\n' "$b"
  done

  local ans
  if [[ -t 1 && -r /dev/tty ]]; then
    read -r -p "Delete these branches? [y/N]: " ans </dev/tty
  else
    printf 'Delete these branches? [y/N]: '
    read -r ans || { printf 'No input available for confirmation. Aborting.\n' >&2; return 1; }
  fi
  case "$ans" in
    y|Y|yes|YES) ;;
    *) printf 'Aborted.\n'; return 0 ;;
  esac

  for b in "${branches[@]}"; do
    git branch -D "$b"
  done
}

main() {
  require_cmds
  ensure_git_repo
  fetch_upstream

  local default_branch
  default_branch="$(detect_default_branch)"
  ensure_on_default_branch "$default_branch"

  local -a candidates=()
  mapfile -t candidates < <(collect_branches_to_delete)
  confirm_and_delete "${candidates[@]}"
}

main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment