Detecting squash-merged branches Jump to heading

git branch --merged main is the standard way to find branches safe to delete: it lists branches whose tip is an ancestor of main. Squash merging breaks it. A squash creates a brand-new commit on main containing the branch’s changes, but none of the branch’s own commits ever become ancestors of main. So every squash-merged branch looks unmerged forever, local branch lists grow without limit, and cleanup scripts either do nothing or — when someone switches to -D out of frustration — delete branches that really were unmerged. This page shows two reliable ways to detect squash merges, by comparing content and by asking the forge, and how to use them in cleanup without risking real work. It belongs to squash and fixup strategies.

When to use this approach Jump to heading

  • Your team squash-merges pull requests, and local branch lists keep growing.
  • git branch --merged shows few branches even though most have been merged.
  • You want a cleanup script that deletes squash-merged branches and nothing else.
  • Remote branch cleanup on the forge is handled separately, as in deleting merged branches automatically.

Step 1 — See why --merged misses squash merges Jump to heading

--merged checks ancestry. After a squash merge, main has a new commit S whose content equals the branch’s combined changes, but the branch’s commits are not in main’s history.

git branch --merged main          # does not list feature/search, though it was squash-merged
git merge-base --is-ancestor feature/search main; echo $?    # 1: not an ancestor
Why a squash-merged branch looks unmergedThe feature branch has commits F1, F2 and F3. Squash merging created S on main with the same combined change, but F3 is not an ancestor of main. Ancestry-based checks like --merged therefore report the branch as unmerged.same content, different commitsmainBM1SfeatureBF1F2F3S contains F1+F2+F3, but Git's ancestry checks only follow commit links Why a squash-merged branch looks unmergedThe feature branch has commits F1, F2 and F3. Squash merging created S on main with the same combined change, but F3 is not an ancestor of main. Ancestry-based checks like --merged therefore report the branch as unmerged.same content, different commitsmainBM1SfeatureBF1F2F3S contains F1+F2+F3, but Git's ancestry checks only follow commit links

Step 2 — Detect by content: does main already contain the branch’s change? Jump to heading

A squash-merged branch’s combined change is already in main. One reliable test creates a temporary commit with the branch’s tree on top of the merge base, then asks git cherry whether main has an equivalent patch.

is_squash_merged() {
  b=$1 target=${2:-main}
  base=$(git merge-base "$target" "$b")
  # one commit containing the branch's whole change, parented on the base
  tmp=$(git commit-tree "$(git rev-parse "$b^{tree}")" -p "$base" -m tmp)
  # "-" means target already has an equivalent change
  [ "$(git cherry "$target" "$tmp" | cut -c1)" = "-" ]
}

for b in $(git for-each-ref --format='%(refname:short)' refs/heads/ | grep -v '^main$'); do
  is_squash_merged "$b" && echo "squash-merged: $b"
done

git commit-tree writes a commit object without touching any branch or the working tree; it is safe to run and the temporary commit is garbage-collected later. The technique works when main received the branch’s change unaltered. If the squash was edited during merge — a conflict resolved differently, a last-minute fix — the patch IDs differ and the branch is reported as unmerged, which errs on the safe side.

Step 3 — Detect by asking the forge Jump to heading

The forge knows which branch each pull request came from and whether it merged. For branches that were pushed and merged through pull requests, that is the most direct answer.

gh pr list --state merged --limit 500 --json headRefName,mergedAt \
  --jq '.[] | "\(.headRefName)\t\(.mergedAt)"' > merged-prs.tsv
for b in $(git for-each-ref --format='%(refname:short)' refs/heads/); do
  grep -q "^$b	" merged-prs.tsv && echo "merged via PR: $b"
done
Two ways to detect a squash mergeContent comparison works offline and on any forge but misses squashes that were edited during merge. Asking the forge sees every merged pull request regardless of edits but only knows about branches that went through pull requests, and a branch name can be reused.Content (git cherry)Forge PR stateneeds networknoyesedited squashmissed (safe)detectedbranch never PR'dhandledunknownreused branch namehandledcan misleaduse both, and require both to agree before deleting anything Two ways to detect a squash mergeContent comparison works offline and on any forge but misses squashes that were edited during merge. Asking the forge sees every merged pull request regardless of edits but only knows about branches that went through pull requests, and a branch name can be reused.Content (git cherry)Forge PR stateneeds networknoyesedited squashmissed (safe)detectedbranch never PR'dhandledunknownreused branch namehandledcan misleaduse both, and require both to agree before deleting anything

A branch name reused after merging — someone created a new feature/search later — appears as merged by name. Compare the pull request’s head commit with the local branch tip before trusting a name match.

gh pr list --state merged --head feature/search --json headRefOid --jq '.[0].headRefOid'
git rev-parse feature/search      # must match, or the branch has newer commits

Step 4 — Clean up with both checks agreeing Jump to heading

Delete a branch only when it is squash-merged by content and its tip matches a merged pull request’s head, or when it is an ancestor of main. Anything else is kept and listed for a human.

#!/bin/sh
# prune-squashed.sh — delete local branches that are definitely merged
set -eu
. ./squash-lib.sh          # defines is_squash_merged from step 2
git fetch -q origin
for b in $(git for-each-ref --format='%(refname:short)' refs/heads/ | grep -vE '^(main|release/.*)$'); do
  if git merge-base --is-ancestor "$b" origin/main; then
    git branch -d "$b"; continue
  fi
  pr_head=$(gh pr list --state merged --head "$b" --json headRefOid --jq '.[0].headRefOid // empty')
  if [ "$pr_head" = "$(git rev-parse "$b")" ] && is_squash_merged "$b" origin/main; then
    git branch -D "$b" && echo "deleted squash-merged $b"
  else
    echo "kept $b"
  fi
done

⚠️ SAFETY WARNING: This script uses git branch -D, which deletes without Git’s own merged check. The two independent checks above are what make it safe; do not remove either one. If a branch is deleted by mistake, git branch -D prints its tip hash, and the branch can be recreated with git branch <name> <hash>, as described in recovering a deleted branch.

Step 5 — Keep the local list short from now on Jump to heading

Two settings stop the problem accumulating: prune remote-tracking branches automatically, and delete local branches as part of finishing a pull request.

git config --global fetch.prune true
git config --global alias.done '!f() { b=$(git branch --show-current); git switch main && git pull --ff-only && git branch -D "$b"; }; f'
Layers of branch hygieneFetch pruning removes remote-tracking refs for branches deleted on the forge. A done alias deletes the local branch right after a pull request merges. The periodic prune script catches whatever the first two missed, and only deletes when two independent checks agree.from automatic to deliberatefetch.prune = truestale origin/* refs removedgit donelocal branch removed at mergeprune-squashed.shcatches the rest, double-checkedmost branches never reach the script if the first two layers are in place Layers of branch hygieneFetch pruning removes remote-tracking refs for branches deleted on the forge. A done alias deletes the local branch right after a pull request merges. The periodic prune script catches whatever the first two missed, and only deletes when two independent checks agree.from automatic to deliberatefetch.prune = truestale origin/* refs removedgit donelocal branch removed at mergeprune-squashed.shcatches the rest, double-checkedmost branches never reach the script if the first two layers are in place

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not just delete every branch older than a month? Jump to heading

Because age says nothing about whether the work landed. Abandoned experiments, parked branches and unmerged fixes are exactly the branches people regret losing.

Does this work for rebase-merged branches? Jump to heading

Rebase merging also creates new commits on main, so --merged misses those too. The patch-ID approach works better here: run git cherry main <branch> directly, and if every commit is marked -, each one has an equivalent on main.

Can the forge delete the remote branch automatically? Jump to heading

Yes, most forges have a setting to delete head branches after merge. That handles remote branches; local branches still need one of the methods above.