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 --mergedshows 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 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 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 -Dprints its tip hash, and the branch can be recreated withgit 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' 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.
Related Jump to heading
- Squash & Fixup Strategies — the parent topic.
- Squash vs Merge vs Rebase Decision Matrix — the merge-method choice that causes this.
- Finding Unported Commits with git cherry — patch-ID comparison in another setting.
- Closing Stale Branches and Pull Requests — server-side cleanup.