Safeguard
Engineering

Git Ancestry Lies About Whether Work Shipped

A commit hash derives from its parents, so a rewrite gives identical changes new identities. is-ancestor answers graph position, not whether the change is present, and squash merges put most teams in this state permanently.

Tomas Lindgren
Platform Engineer
6 min read

You are checking whether a colleague's work made it into main. git merge-base --is-ancestor says no. git log main does not show their commits. Every tool agrees the work is unmerged.

The work is in main. It went in months ago, and then somebody rewrote history, and every commit acquired a new hash. Ancestry now describes the shape of the history, which is a different question from whether the change is present.

This post is about the difference between those two questions, when they stop agreeing, and how to answer the one you actually care about. For anyone auditing what shipped, especially across a repository that has had its history rewritten.

What ancestry actually tells you

A commit hash is derived from its content and its parents. Change any ancestor and every descendant hash changes, even if not one line of the diff differs.

So after a rewrite, the old commit a1b2c3 and the new commit f9e8d7 can contain exactly the same change, and git regards them as unrelated objects. --is-ancestor compares graph position. It answers "is this object reachable from that object", which is only a proxy for "is this change present", and the proxy breaks the moment the graph is rebuilt.

Operations that rebuild the graph:

  • git filter-branch and git filter-repo, usually run to purge a secret or a large file.
  • An interactive rebase that squashes or reorders.
  • A squash merge, which is the common one. Every squash merge turns several commits into one new commit whose parents are different, so the originals are unreachable from main by construction.
  • git rebase onto a moved base.
  • Repository migrations between hosts that reconstruct rather than mirror.

Squash merges mean most teams live in this condition permanently and never notice, because they only ask the ancestry question when something has gone wrong.

The failure this produces

Someone audits a branch, concludes its work never landed, and re-applies it. Now the change exists twice: once from the original merge and once from the re-application. The second application conflicts, or worse, does not conflict and silently doubles something.

The reverse failure is more dangerous. Work genuinely did not land, but ancestry cannot distinguish that from a rewrite, so it gets written off as a hashing artefact and never checked. The change is missing from production and everyone believes it is present.

Both come from asking about graph position when the real question is content.

Ask about content instead

Is this specific change present? Look for the change, not the commit.

# Does the code contain what that commit introduced?
git log --all --oneline -S 'distinctiveStringFromTheChange' -- path/to/file

# Or diff the branch against main and see if anything is left
git diff main...their-branch -- path/to/file

An empty diff between main and the branch means everything on that branch is present in main, whatever the graph says. This is the strongest single signal and it costs one command.

Was this commit applied under a different hash? git patch-id normalises a diff so the same change hashes identically regardless of parentage:

git show <their-commit> | git patch-id --stable
# compare with candidates in main
git log --format=%H main | while read c; do
  echo "$(git show "$c" | git patch-id --stable) $c"
done | grep "^<the-patch-id>"

This is the reliable equivalence test after a rewrite, and it is what git cherry uses under the hood:

git cherry main their-branch     # + means not upstream, - means already applied

git cherry is the answer most people want and few know about. It compares patch identity rather than ancestry, which is exactly the distinction this whole post is about.

Does the artifact contain it? The strongest check, and the one people skip because it feels indirect. Pull the deployed image or binary, and look for the behaviour. History is a claim about the past. The running artifact is the present.

The rule worth adopting

Judge merged work by content, not by ancestry. When the two disagree, content wins, because the graph is a representation that can be rebuilt and the diff is the thing you care about.

In practice that means:

  • Use git cherry or git diff main...branch for "did this land", not --is-ancestor.
  • When a rewrite happens, record it: the date, the reason, and the old-to-new hash mapping if the tool produced one. filter-repo writes one. Six months later that file is the only thing that makes historical references resolvable.
  • Treat any pre-rewrite commit hash in a ticket, changelog, or incident report as a dead link, and say so in the rewrite announcement.

The thing that makes this expensive

Rewrites are usually done under pressure, to purge a leaked credential, and the team is focused on the secret rather than on the consequences for everything referencing history. So the mapping is not kept, the announcement is a message in a channel that scrolls away, and the knowledge that a rewrite happened at all survives only in whoever ran it.

Then a year later somebody audits a branch, gets a confident wrong answer, and there is nothing to tell them the graph was rebuilt. The cheapest fix is a file in the repository, docs/history-rewrites.md, one paragraph per event. It takes ten minutes during the rewrite and it is the only durable record.

And separately: a rewrite does not remove the secret. Old objects survive in forks, clones, the host's caches, and anyone's local checkout. Rotate the credential. The rewrite is tidying, not remediation.

The concession

Ancestry is the right question much of the time. On a repository that has never been rewritten and merges with true merge commits, --is-ancestor is fast, exact, and means what you want it to mean. Patch-id comparison is slower and has its own false negatives: a commit that was applied and then partially modified has a different patch id but is still substantially present.

So the honest position is that neither check is definitive alone. Ancestry answers quickly and can be wrong in one direction; content answers slowly and can be wrong in the other. When it matters, use both, and when they disagree, go and look at the artifact.

The implication

Version control history is a story about how the code got here, and stories get rewritten. The code itself does not.

So when the question is whether something shipped, ask the code. It is a slower question and it is the one with a reliable answer.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.