Deleting merged branches automatically Jump to heading

A repository that never deletes branches ends up with thousands of them. The branch list becomes useless for finding active work, cleanup scripts slow down, every clone fetches refs nobody will look at again, and stale branches occasionally get revived by accident. Deleting a branch after its pull request merges is safe — the commits are on main, or in the case of squash merges, their content is — and forges can do it automatically. The remaining work is local: remote-tracking refs that outlive their branches, and local branches that developers forget. This page sets up deletion at each level, with protection for branches that must never be removed, within feature branch isolation.

When to use this approach Jump to heading

  • The remote has hundreds or thousands of branches, most of them merged long ago.
  • Developers’ git branch output is dominated by finished work.
  • You want branch lists to reflect active work only, without losing anything.
  • Your naming convention reserves long-lived prefixes, as in naming conventions for feature branches.

Step 1 — Delete head branches when pull requests merge Jump to heading

Forges can delete a pull request’s branch as soon as it merges. Enable it for every repository; it is the single biggest reduction in branch count.

gh api -X PATCH "repos/$OWNER/$REPO" -F delete_branch_on_merge=true
# GitLab: per merge request ("Delete source branch") or as the project default
glab api -X PUT "projects/$PROJECT_ID" -F remove_source_branch_after_merge=true

Branches deleted this way can be restored from the pull request page for a while, which makes the setting low-risk. Applying it across an organisation is a job for bulk updating repository settings with the API.

Branch cleanup at each levelThe forge deletes the remote branch when its pull request merges. Fetch pruning removes the matching remote-tracking ref on each developer's machine. A local cleanup command removes local branches whose upstream is gone or that are merged. Protected patterns are excluded at every level.each level removes what the previous one left behindForge: delete on mergeremote branch removedfetch.pruneorigin/* ref removedLocal cleanuplocal branch removedProtected patternsmain, release/*, env/*skip any level and the lists keep growing at that level

Step 2 — Prune remote-tracking refs on every fetch Jump to heading

When a branch is deleted on the server, each clone keeps its origin/… ref until told otherwise. fetch.prune removes them on every fetch.

git config --global fetch.prune true
git fetch                        # prunes deleted remote branches automatically
git branch -r | wc -l            # now reflects the server

Add it to the team’s shared Git configuration so everyone gets it; see shipping a team gitconfig with includeIf.

Step 3 — Clean up local branches safely Jump to heading

Local branches are the developer’s own, so cleanup should be a command they run, not something done to them. Delete local branches that are either merged into main or whose upstream was deleted on the server — the usual sign of a merged pull request.

#!/bin/sh
# git-tidy — delete local branches that are merged or whose upstream is gone
set -eu
git fetch --quiet --prune
protected='^(main|master|release/.*|env/.*)$'
current=$(git symbolic-ref --quiet --short HEAD || true)
git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ |
while read -r branch track; do
  printf '%s\n' "$branch" | grep -Eq "$protected" && continue
  [ "$branch" = "$current" ] && continue
  if git merge-base --is-ancestor "$branch" origin/main; then
    git branch -d "$branch"
  elif [ "$track" = "[gone]" ]; then
    echo "upstream gone: $branch (squash-merged or deleted) — deleting"
    git branch -D "$branch"
  fi
done
git config --global alias.tidy '!sh ~/bin/git-tidy'
git tidy

⚠️ SAFETY WARNING: A branch whose upstream is gone has usually been merged, but not always — someone may have deleted the remote branch while you still had unpushed work on it. The script prints the name before deleting with -D. If a branch is deleted by mistake, Git prints its last commit hash; recreate it with git branch <name> <hash>, as described in recovering a deleted branch.

Step 4 — Clean up old remote branches that were never merged Jump to heading

Delete-on-merge does nothing for branches that were abandoned without merging. Report them first, notify their authors, and delete only after a grace period — never automatically on the first pass.

cutoff=$(date -d '90 days ago' +%s)
git for-each-ref --no-merged=origin/main \
  --format='%(committerdate:unix) %(authoremail) %(refname:lstrip=3)' refs/remotes/origin/ |
awk -v c="$cutoff" '$1 < c && $3 !~ /^(main|release\/|env\/|HEAD)/ {print $2, $3}'
An abandoned branch's path to deletionA branch receives its last commit and goes quiet. After sixty days it appears in a report and its author is notified. If nothing happens by ninety days, it is archived as a tag and deleted. The tag keeps the commits reachable in case anyone needs them later.Last commitwork stopsday 0Reportauthor notifiedday 60Reminderstill idleday 75Archive tagarchive/<name>day 90Deletedbranch list cleanday 90archiving to a tag first makes deleting unmerged work reversible

The notification side is covered in closing stale branches and pull requests.

Step 5 — Protect branches that must stay Jump to heading

Release, environment and main branches must never be deleted by any of these mechanisms. Protect them on the server, and exclude their patterns in every script.

# Server: deletion forbidden for protected patterns (ruleset rule "deletion")
gh api "repos/$OWNER/$REPO/rulesets" --jq '.[] | {name, target, enforcement}'

With deletion forbidden on the server, a cleanup script with a bug cannot remove a release branch even if its exclusion pattern is wrong.

Remote branch count after enabling cleanupAn illustrative repository that had never deleted branches. Enabling delete-on-merge stopped growth immediately. A one-off archive-and-delete of abandoned branches removed most of the backlog, leaving a branch list that reflects active work.branches on the remote (illustrative)before1840+ delete on merge (3 months)1870after backlog cleanup64delete-on-merge stops the growth; only a backlog pass shrinks what already exists

Step 6 — Report the effect Jump to heading

Track the remote branch count weekly. It should drop sharply after the backlog pass and then stay roughly level, tracking the number of open pull requests. A rising count means a source of branches is bypassing cleanup — often a bot that pushes branches without opening pull requests, or a repository where delete-on-merge was never enabled.

git ls-remote --heads origin | wc -l
gh pr list --state open --json number --jq length

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Does deleting a merged branch lose history? Jump to heading

No. With merge commits, the branch’s commits are reachable from main. With squash merges, the content is on main and the original commits remain visible in the pull request. Only the name goes.

Why keep [gone] detection when --merged exists? Jump to heading

--merged checks ancestry, which squash and rebase merges break. A gone upstream is the most reliable local signal that a squash-merged branch is finished; the content-based alternative is in detecting squash-merged branches.

Should CI delete local branches on runners? Jump to heading

Runners should be ephemeral, so there is nothing to clean. Long-lived runners should use fresh checkouts rather than accumulating branches.