Skip to content

·  · 7 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.

Update, three weeks later: I re-ran the numbers

That last line aged badly. I kept running overnight agent batches and skipped the cleanup, so on 2026-08-03 I measured this same repo again:

$ git branch --no-merged main | wc -l
39

Thirty-nine branches flagged as unmerged, up from eight. I mapped them against merged PRs with the snippet above: 30 of the 39 were already squash-merged and live in production. This repo has 56 merged PRs in its whole history, so more than half of everything ever shipped still had a zombie branch in my local list. The nine leftovers were still in flight, and seven of those had an open PR at the moment I measured. The forge check drew the line exactly where it belonged, while ancestry called all 39 by the same name.

The sibling repo for my agency site told the same story: 34 branches flagged, 18 already shipped. Between the two repos that night: 73 “unmerged” branches, at least 48 of them dead. “At least” because name-matching has a blind spot: a branch checked out under a local name that differs from the PR’s head ref (a pr53 from a manual review checkout, say) never matches, merged or not.

One wrinkle the first cleanup never hit: worktrees. Parallel agents each work in their own git worktree here, and 11 of those 30 dead branches were still checked out in one. For those, even -D refuses:

error: Cannot delete branch '<branch>' checked out at '<worktree-path>'

The order that works is worktree first, branch second:

git worktree remove <worktree-path>
git branch -D <branch>

So the snippet held up against a list almost five times longer than the one that prompted this post, and the caveat list grew by one. The conclusion I didn’t want: a cleanup that lives in shell history is a cleanup that doesn’t run. Mine is moving to a scheduled job.

    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 to Buy an AI Sales System Without Getting Burned
    EN

    How to Buy an AI Sales System Without Getting Burned

    Demos are the vendor's best case; what you're buying is the Tuesday-afternoon case. This final post is the checklist for seeing it before you pay: five contract-level questions, the red flags, a fair pilot design — and, since I sell these systems, the scenario where the honest answer is don't buy from me.

    Speed to Lead: The Sales Metric AI Actually Fixes
    EN

    Speed to Lead: The Sales Metric AI Actually Fixes

    You can't verify most AI sales claims without buying first. Speed to lead is the exception: how long a new lead waits for a real answer is a number, and you can measure yours this week with a stopwatch. I build these systems for a living, and this is the one part of my pitch you don't have to take on trust.

    What an AI Receptionist Actually Does All Day (and Where It Breaks)
    EN

    What an AI Receptionist Actually Does All Day (and Where It Breaks)

    Vendors sell the term 'AI receptionist' on warmth. Here's the shift log instead: what one actually handles in a day for a clinic or a contractor, what a good handoff to a human looks like, and the four places where it breaks. I build these systems, so the failure modes come first.