Cherry-pick Is Not a Patch Application

A common mental model for git cherry-pick COMMIT_ID is: compute the diff for that commit with git show COMMIT_ID --patch, then apply it to the current branch with git apply. That model works most of the time, but it breaks down in interesting ways when conflicts arise.

If you literally generate a patch file and apply it in a scenario with a merge conflict, git simply fails — there's no mechanism to resolve the conflict or investigate what went wrong.

$ git show 10e96e46 --patch > out.patch
$ git apply out.patch
error: patch failed: content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown:17
error: content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown: patch does not apply

Running git cherry-pick on the same situation produces a proper merge conflict instead:

$ git cherry-pick 10e96e46
error: could not apply 10e96e46... wip
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".

The error message does say "could not apply 10e96e46", so the patch model isn't entirely wrong — but it's not the whole story either. Digging into git's source code reveals that a cherry-pick is actually implemented as a merge.

res = do_recursive_merge(r, base, next, base_label, next_label, &head, &msgbuf, opts);

How 3-Way Merge Works

To understand why, you first need to know how git merges files. Consider two files, v1.py and v2.py:

def greet():
    greeting = "hello"
    name = "julia"
    return greeting + " " + name
def say_hello():
    greeting = "hello"
    name = "aanya"
    return greeting + " " + name

Two lines differ: def greet() versus def say_hello, and name = "aanya" versus name = "julia". Deciding what to keep seems impossible without more context.

But if you know the original file, base.py:

def say_hello():
    greeting = "hello"
    name = "julia"
    return greeting + " " + name

The picture clarifies. v1 renamed the function to greet; v2 set name = "aanya". Merging should preserve both changes:

def greet():
    greeting = "hello"
    name = "aanya"
    return greeting + " " + name

Running git merge-file on these three files yields exactly that result — def greet() and name = "aanya".

$ git merge-file v1.py base.py v2.py -p
def greet():
    greeting = "hello"
    name = "aanya"
    return greeting + " " + name⏎

This technique — merging two files against their common ancestor — is a 3-way merge. The key insight is that git merges changes, not files. Given an original and two modifications to it, git combines both sets of changes where possible. When both sides touch the same line, you get a conflict.

Applying a Commit as a 3-Way Merge

When git "applies a patch" during a rebase, revert, or cherry-pick, it doesn't construct a patch file at all. Instead it performs a 3-way merge with these roles:

  1. The file in your current commit is v1.
  2. The file before the target commit is base.
  3. The file in the target commit is v2.

Git then merges v1, base, and v2 (technically via a C function, not the git merge-file command). The pair of base and v2 together define the "patch" — their diff is the change being applied to v1.

Cherry-Pick and Revert as Merges

Consider a commit graph where you want to cherry-pick Y onto main:

A - B (main)
 \
  \
   X - Y - Z

In 3-way merge terms:

  • B (your current commit) is v1.
  • X (the parent of Y) is base.
  • Y itself is v2.

This is why git rebase is essentially a sequence of cherry-picks.

Revert works the same way, but with the roles of X and Y swapped:

X - Y - Z - A - B

Here B is v1, Y becomes the base, and X is v2. Flipping the base and target effectively applies a reverse patch. Cherry-pick and revert are so similar that git implements both in a single source file, builtin/revert.c.

Why a "3-Way Patch" Is More Robust

Specifying a patch as two full files — the state before and after — gives git far more context for merging than a traditional diff. A conventional patch for the earlier example looks like this:

@@ -1,1 +1,1 @@:
- def greet():
+ def say_hello():
    greeting = "hello"

A "3-way patch" (not a real file format, simply a conceptual framing) would instead present both complete file versions:

BEFORE: (the full file)
def greet():
    greeting = "hello"
    name = "julia"
    return greeting + " " + name
AFTER: (the full file)
def say_hello():
    greeting = "hello"
    name = "julia"
    return greeting + " " + name

With both full files available, git can resolve conflicts far more intelligently than a context-based patch tool, which must hunt for the right location in the target file.

What git apply Actually Does

The git apply command lives in apply.c. Its core logic parses the patch file and searches the target file for the correct application point, typically starting at the line number suggested in the patch and scanning forward and backward from there.

	/*
	 * There's probably some smart way to do this, but I'll leave
	 * that to the smart and beautiful people. I'm simple and stupid.
	 */
	backwards = current;
	backwards_lno = line;
	forwards = current;
	forwards_lno = line;
	current_lno = line;
  for (i = 0; ; i++) {
     ...

git apply also supports a --3way flag that performs a 3-way merge, which closely approximates a cherry-pick:

$ git show 10e96e46 --patch > out.patch
$ git apply out.patch --3way
Applied patch to 'content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown' with conflicts.
U content/post/2023-07-28-why-is-dns-still-hard-to-learn-.markdown

The flag relies on blob IDs embedded in the patch's header:

index d63ade04..65778fc0 100644

Those IDs let git retrieve the old and new file versions from its object database. This fails if you only have a patch file without access to those blobs — for example, when emailing patches between repositories:

$ git apply out.patch
error: repository lacks the necessary blob to perform 3-way merge.

Broader Context

3-way merge predates git by decades, dating back to the late 1970s. Git's actual merge machinery includes additional layers — recursive merges, file rename and deletion handling, and multiple merge algorithms — that go well beyond the basic 3-way case described here.

The approach is nonetheless elegant: whether git is merging branches, cherry-picking commits, or reverting changes, the underlying operation is the same 3-way merge of your current state against the change's before-and-after versions. It's an implementation detail most users never need to think about — but it explains a lot about how git behaves when things go wrong.