← Blog

August 4, 2026 · Steve Johnson

Squash merges leave a stale-branch graveyard

I have thirty-something branches in a repo right now that are fully shipped. Every line of code. In production. And if you run git branch --merged, not a single one of them shows up as merged. They just sit there, looking like active work, taunting anyone who tries to figure out what's actually still in flight.

This is the squash merge trap, and it got me good.

What Squash Merges Actually Do to Your History

When you squash-merge a pull request, GitHub (or any host doing the same thing) takes all your commits on the feature branch and collapses them into one brand-new commit that lands on the base branch. New SHA. New commit object. The base branch gets the changes, but it gets them as a stranger. That new squash commit has no ancestry relationship to anything on your feature branch.

That's the key word: ancestry.

git branch --merged works by walking the commit graph. It asks, "does every commit on this branch appear somewhere in the history of the base branch?" With a squash merge, the answer is always no. Your feature branch commits aren't in the graph. They never made it there. The squash commit replaced them.

So your branch looks unmerged. Forever.

How the Graveyard Grows

This is a slow-burn problem. One branch sticking around is noise. Twenty branches sticking around is a broken signal. You stop trusting the branch list for anything. You can't tell what's genuinely in progress and what shipped six sprints ago. New teammates look at the repo and try to infer state from branch names, which is a losing game.

I've seen repos where nobody deleted branches for over a year because everyone assumed someone else was vetting them. The real reason nobody deleted them: the tooling said they weren't merged, so nobody wanted to be the one who nuked something live.

That's the practical damage. Not theoretical. People stop making decisions because the data is wrong.

The Fix: Stop Checking Ancestry, Check the Diff

The right question isn't "is this commit in the history?" It's "is this branch's work already represented in the base branch?"

Those are different questions. The first is a graph walk. The second is a content comparison.

Two ways to do this in practice:

Using git cherry

git cherry compares patches by their diff fingerprint, not their SHA. It looks at what the commit actually changes and checks whether an equivalent patch exists downstream.

git cherry -v main my-feature-branch

Any commit prefixed with - in the output has an equivalent patch already in main. If every commit on the branch comes back with -, the branch is effectively merged, regardless of what --merged says.

Using patch-ids directly

git patch-id generates a hash of a commit's diff content. You can compare patch-ids across branches to see if the same changes exist on both sides under different SHAs. It's more manual, but it works cleanly in scripts where you want to automate branch cleanup logic.

Either approach gets you to the real answer: does main already have what this branch introduced?

What I Actually Run Now

For any repo on a squash-merge workflow, I've stopped using git branch --merged as a cleanup signal entirely. It's not wrong exactly. It's just answering a different question than the one I need answered.

The check I actually trust:

git fetch origin
git cherry -v origin/main my-feature-branch | grep "^+"

If that returns nothing, the branch is done. Every patch is already downstream. Safe to delete.

You can wrap that in a loop over all local or remote branches and get a real picture of what's still genuinely unmerged. Takes about ten minutes to wire up. Saves hours of second-guessing a branch list that was lying to you.

The Broader Lesson

git branch --merged is not a bug. It does exactly what it says. The disconnect is that "merged" in everyday team language means "the work is in the base branch." In git's language, it means "these exact commits are ancestors of the tip." Squash merges break the connection between those two definitions on purpose, because that's the whole point of squashing.

Once you see that gap, the fix is obvious. You just have to hit the problem first to know the gap exists.