Skip to content

·  · 5 min read

git branch --no-merged Lies to You After a Squash Merge

My cleanup list said eight branches were unmerged. Every single one had already shipped to production. The problem wasn't the branches. It was the question I was asking git.

My cleanup list said eight branches were unmerged. Every single one had already shipped to production. The problem wasn't the branches. It was the question I was asking git.

This morning I ran git branch --no-merged main on this site’s repo to find cleanup candidates. I expected one or two stragglers. I got a wall of branches, and every single one of them had already shipped. The squash commits were sitting right there on origin/main: content: ... (#24), content: ... (#23), feat(brand): ... (#28). The work was live in production, yet git insisted the branches that produced it were not merged.

If your team squash-merges pull requests (most teams do), git branch --no-merged has been lying to you the same way. Here’s why it happens, and the small gh snippet I use now instead.

Why git branch —no-merged breaks under squash merge

Both git branch --merged and git branch --no-merged answer exactly one question: is this branch’s tip commit an ancestor of main? That’s it. No diff comparison, no content check, pure ancestry.

With a plain merge commit, that model works. Your branch’s commits get woven into main’s history, the tip becomes an ancestor, and --merged reports the truth.

Squash merge does something fundamentally different. When you click “Squash and merge” on GitHub, it takes the entire diff of your PR and creates one brand-new commit on main, with a brand-new hash. Your branch’s original commits never become part of main’s history. Not one of them. The content landed, but the commits didn’t.

So from git’s point of view, the branch tip is not an ancestor of main, and it never will be. By the only measure --no-merged knows, that branch is unmerged forever, even though every line of its work shipped weeks ago.

The misleading list, in practice

Here’s roughly what I stared at today (branch names trimmed):

$ git branch --no-merged main
  content/astro-validation-post
  content/image-weight-post
  feat/brand-refresh
  fix/newsletter-thanks
  ...

Scary list. All shipped. On my last real cleanup pass, I counted 8 stale local branches, and every one of them turned out to be already squash-merged.

The trap gets worse when you try to act on it. The polite delete refuses:

$ git branch -d feat/brand-refresh
error: the branch 'feat/brand-refresh' is not fully merged

Git is protecting you based on the same broken ancestry check. So now you’re stuck with two bad options: force-delete with -D blindly and hope none of those branches actually holds unshipped work, or leave the graveyard alone and watch it grow. I lived with the graveyard for a while. It gets big fast when an AI agent works your backlog overnight and opens a PR per task; a week of that and your local branch list looks like an abandoned warehouse.

The fix: ask the forge, not the ancestry

Here’s the reframe that fixed it for me: after a squash merge, “is this branch merged?” is no longer a git question. Git genuinely doesn’t know. But GitHub does, because GitHub performed the merge. It knows exactly which PRs merged and which branch each one came from.

So instead of asking git about ancestry, I map my local branches against the head refs of merged PRs:

# branches whose PR was merged (squash-safe)
gh pr list --state merged --limit 200 --json headRefName --jq '.[].headRefName' > /tmp/merged-branches
for b in $(git branch --format='%(refname:short)' | grep -v '^main$'); do
  grep -qxF "$b" /tmp/merged-branches && echo "MERGED, safe to delete: $b"
done

Then, for each branch it flags:

git branch -D feat/brand-refresh

Yes, -D, the force delete. After years of treating -D as a red flag, this felt wrong the first time. But it’s precisely correct here: the safety check behind -d measures ancestry, and ancestry is the wrong measure for a squash-merge workflow. The PR merged, the work is on main, the local branch is just a leftover label pointing at commits nobody needs anymore.

Two details worth knowing about the snippet. --limit 200 covers this repo’s whole PR history; bump it for older projects. And grep -qxF matches the exact whole line as a fixed string, so feat/foo can’t accidentally match feat/foo-2 and get something deleted by a substring collision.

This is the same lesson I keep relearning in different costumes, most recently while building the validation checklist I run on every Astro post before publishing: a check is only worth trusting if it measures the thing you actually care about. Ancestry was always a proxy for “did this ship?”. Squash merge broke the proxy, and the answer is to go ask the system that holds the real record.

The honest caveat

This is specifically a squash-merge effect (rebase-merge too, since it also rewrites commits onto main with new hashes). If your team merges with plain merge commits, ancestry stays intact and git branch --merged keeps telling the truth. No need to change anything. The reason this trap bites so many people is that squash is the default culture on most teams now, chosen for the clean linear history, and the branch-cleanup workflow never got updated to match.

One more assumption to be honest about: the snippet says “this branch’s PR was merged”, not “this branch’s current tip matches what was merged”. If you kept committing to a branch after its PR merged (I don’t, branches die after merge here, but some people do), those extra commits would be lost to -D. In that workflow, check gh pr view <branch> before deleting, or just don’t reuse branch names for new work.

Where this leaves me

git branch --no-merged is off my cleanup toolbelt for any repo that squash-merges, which today means all of mine. It’s not broken software, it’s a correct answer to a question that stopped being the right question. For “which branches can I delete?”, the forge is the source of truth, and gh pr list --state merged is how you ask it.

The snippet above lives in my shell history now. Eight branches down, zero guesswork, and the next overnight batch of PRs won’t leave a graveyard behind.

    Share:

    Enjoyed this post?

    Get new posts on web dev, AI and SEO straight to your inbox. No spam, unsubscribe anytime.

    No spam. By subscribing you agree to the privacy policy .

    Back to blog

    Related Posts

    View All Posts »
    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    A static Astro site with 15KB of JavaScript was shipping 3.4MB of images. Here are the three failure modes that got past the image pipeline — the fallback-always-ships rule, the screenshot the optimizer never touched, and the boilerplate nobody referenced — and the 15 lines of Sharp that fixed all of it.