Software Engineering · Version Control Practice
Cherry-Pick, Patch Identity, and Duplicate Commits
Why applying one commit to another branch creates a new id for the same change, how patch-id recognises duplicates, and where that recognition fails.
Moving a commit from one line to another without moving either line is the one operation that guarantees the same change exists twice in the repository. A cherry-pick computes the source commit’s diff against its parent, applies it to the current tip, and commits the result. The content is the same; the object is not, because the parent and the committer differ. Every subsequent question about whether a fix is present in a branch has to cope with that duplication.
The operation in detail
git cherry-pick <commit> resolves the commit’s parent, computes the diff from parent to commit, and runs a three-way merge with the parent as the base, the current tip as ours, and the source commit as theirs. Framing it as a merge explains most of its behaviour: conflicts are the ordinary kind, the merge machinery decides per file, and -x appends a line to the new message recording the source id.
The parent-as-base choice means the applied patch is exactly the source commit’s own change, not the difference between the two branches. If the source commit was itself a merge, the default is to fail unless -m names which parent to diff against, because a merge has no single change.
The new commit has the source’s author line and the current committer. If the author date is preserved, the commit’s position in a chronological log will not match its ancestry, which is why logs sorted by author date and logs sorted by topology disagree on a branch that has received backports.
-x is the only mechanism that records provenance, and it is a message convention rather than an object reference. It survives a rebase of the receiving branch but not a squash, and nothing enforces it.
What patch-id measures
git patch-id computes a hash over the diff itself, ignoring whitespace and line numbers. The input is a diff; the output is an identifier that is stable across commits that change the same lines in the same way, regardless of the surrounding file content or the commit metadata.
The normalisation is the key. Whitespace changes are stripped, and hunk headers are recomputed from the actual content rather than taken from the diff text, so a patch applied at a different offset yields the same id. The result is that a commit and its cherry-picked copy usually share a patch id, and a commit whose only difference is indentation does not get a different one.
git cherry <upstream> <head> uses exactly this. It lists the commits in head that are not in upstream by ancestry, and for each, computes the patch id and compares it against the patch ids of the commits in the upstream-only set. Commits whose patch id matches are prefixed with -; those with no match get +. The - entries are changes that appear to be already present upstream by content.
The comparison is pairwise over patch ids, so it is a content heuristic, not a proof. Three failure modes follow.
First, a commit whose change was applied upstream with modifications has a different patch id and is reported as missing. A backport that also adjusted surrounding code, or an upstream fix that solved the same problem differently, both look absent.
Second, a commit that is genuinely present can be reported as missing if it was squashed into a larger commit upstream, because the squashed commit’s diff contains the change plus others, and the patch id is over the whole diff.
Third, two commits that make the same trivial change in different places — adding a blank line, renaming a local variable — can collide. Patch id has no notion of file identity, so the same textual change applied to two different files produces the same id. The collision is real and the tool does not distinguish it.
Why the receiving branch cannot just merge
The obvious alternative to cherry-picking is merging the source branch. That carries all the other commits too, which is precisely what a backport does not want: a release branch should receive the fix and nothing else. Merging a branch into a release line would pull in features that are not ready to ship.
So the duplication is structural, not accidental. Any flow with two lines that must both contain a subset of changes will produce duplicate commits. The variants differ in how much of the duplication is recorded:
- Cherry-pick without
-xrecords nothing. - Cherry-pick with
-xrecords the source id in the message. - Merging a single-commit branch records the source through ancestry, and the merge’s second parent is the source commit.
- Applying a patch file records whatever the patch header contains.
Only the merge variant makes the relationship queryable through the object graph. The others require text parsing or content comparison.
A related trick is to create a branch at the source commit, cherry-pick or merge it, and then keep that branch as a marker. This is what git rebase --onto does when transplanting: the old commits stay reachable from a tag if one is placed, and the tag makes the provenance explicit.
Backport mechanics that avoid the worst cases
The pattern that works is to apply the fix to the oldest line first and merge forward. If a fix is needed on a release branch and on the main line, applying it to the release branch and then merging the release branch into main carries the commit by ancestry, so main has exactly one copy with a recorded provenance. Applying it to main first and cherry-picking backwards produces a duplicate with no ancestry link.
The order matters because merges only flow in one direction usefully: merging an older line into a newer one carries everything the older line has, which is usually acceptable when the older line only receives fixes. Merging a newer line into an older one would carry features, which is why that direction is not used.
Where the fix must be authored differently on each line — because the code has diverged — the two commits are genuinely different changes and patch id will not match them. That is correct behaviour: they are not the same patch. Recording the relationship then becomes a documentation problem, and -x plus a consistent issue reference is the only available mechanism.
Detecting presence without ancestry
The general question “does this branch contain the effect of this commit” has no exact answer in the object model, because the object model records content, not intent. The available approximations, in increasing cost:
Ancestry check: git merge-base --is-ancestor answers whether a commit is reachable. Exact, but fails for any duplicated change.
Patch-id set comparison: git cherry or a manual comparison of git log --format piped through git patch-id. Cheap, and wrong in the three ways above.
Content diff of the affected region: check whether the current file contains the post-change text. Exact for the specific lines, but brittle if the surrounding code has moved.
Behavioural check: a test that fails without the fix. The most expensive and the only one that verifies the effect rather than the text. This is why backport tracking tends to end up as a test that was written with the fix.
The engineering conclusion is that a repository with a release line needs a convention stronger than “the fix is in the branch”. Either fixes are applied oldest-first and merged forward, so ancestry answers the question, or the tracking lives outside the repository in a record keyed by issue rather than by commit.
Interaction with rewriting
A cherry-picked commit that is later rebased gets yet another id. The -x line survives because it is in the message, which is why the convention is worth its cost on any branch that will be rewritten. Without it, a rebase of a backport branch severs the last link to the original.
There is a symmetric problem when the source branch is rebased after the cherry-pick. The recorded source id now refers to a commit that is no longer reachable from the branch, so the -x line points at a dangling id. It remains in the object store until garbage collection, and the message still names it, so the record is readable but not resolvable.
Empty cherry-picks are the other rewriting interaction. Applying a commit whose change is already present produces an empty result; Git reports it and, by default, stops so the operator can decide whether to skip. --allow-empty forces a commit with no change, which is occasionally wanted to record that a change was considered, and is otherwise a way to create a commit that breaks every assumption about commits representing changes.
The mechanics of replay are shared with rebase, since both compute a diff and apply it; the differences are in which base is used and what happens to the source line. That comparison is in rebase as patch replay. The way a series is collapsed before it is picked at all is in squash, fixup, and series composition.
References
- Git documentation — git-cherry-pick — Git project
- Git documentation — git-patch-id — Git project
- Git documentation — git-cherry — Git project