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.
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.
- 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
originif 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.
- 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.
- Save the script to a file, for example:
git-clean-local-branches.sh
- Make it executable:
chmod +x git-clean-local-branches.sh
- Optionally, move it into a directory in your PATH (e.g.,
~/bin).
Run the script from within your Git repository:
./git-clean-local-branches.shWhat 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.
- 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).
- The script confirms before deleting anything
- It reads the confirmation from
/dev/ttyto avoid issues if stdin is redirected (e.g., in pipelines) - It refuses to guess default branch names—if
origin/HEADisn’t configured, it fails with instructions - It uses
git fetch --all --pruneto ensure remote tracking information is up-to-date
Tip: If a branch was deleted accidentally, you can often recover via the reflog:
git reflogto find the commit.git branch recovered <commit>to recreate the branch.
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>.
- 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 ensureoriginexists and is reachable.
- Set
- 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.
- Ensure it’s run in an interactive terminal; it reads from
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 masterVerify:
git symbolic-ref --short refs/remotes/origin/HEAD
# Expected: origin/main (or origin/master, etc.)- The script checks all remotes for branch-name matches when deciding if a local branch is “local-only.”
- If you want to restrict to
originonly, adjust the filter fromrefs/remotes/*/<branch>torefs/remotes/origin/<branch>.
- If you want to restrict to
- It assumes a working network to fetch; if you’re offline, the fetch step will fail early.
#!/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 "$@"