Lesson 08 / 15
Cherry-Pick
Copying a single commit to another branch leaves the same change in history in two places under two separate IDs; the file trail question still says yes, but the commit count it counts exceeds the count of separate changes, and the isolation question drops when a buggy commit is copied.
Contents
Rebase moved an entire branch’s commits onto a new base. Sometimes what is needed is much less than that: a single fix sitting on a published release also needs to enter the development branch, or a single commit written on one branch needs early use on another. Taking the whole branch would be wrong here; what is meant to be taken is a single change.
Cherry-pick does exactly this. This lesson’s question is as much about the operation’s result as about how it works: the same change now sits in two places in history, and this affects the file trail question, which counts the commits that touched a file.
Copying a Single Commit
Cherry-pick takes the change the selected commit made relative to its parent and applies it to the tip of the branch you are on. If the application succeeds, a new commit is written. The new commit’s parent is the target branch’s old tip, its tree is new, its ID is new. The source commit stays in place and loses nothing.
This is rebase’s single-commit case. The difference is scope: rebase moves every commit from a branch’s tip down to the common ancestor and leaves the old ones unreachable; cherry-pick copies a single commit and leaves the old one in place. There is a third operation that runs in reverse: revert flips a commit’s change and writes it as a new commit. All three are separate uses of the same mechanism — extract the patch, apply it again.
# taught command and example dump — not executed $ git switch identity $ git cherry-pick 4d5e6f1 [identity 8c9d0e1] report: fix field width 1 file changed, 6 insertions(+), 2 deletions(-)
The bracket on the left shows the target branch and the new ID; the ID given to the
command is 4d5e6f1, the ID produced is 8c9d0e1. The two commits’ content is the
same, their IDs are separate. This is the whole of what the lesson measures.
Application does not always succeed. If the context the source commit depends on is not present on the target branch, the patch does not hold, and the conflict situation from previous lessons arises: the working area is left with markers, the resolution is given by hand, and then continue or abort is called. When given a range, commits are copied in order and conflict can be asked separately for each.
There is also the reverse case: if the copied change is already applied on the target branch, the patch is left empty. The tool does not make a decision on its own here; it stops and leaves the choice to the user — writing an empty commit and skipping that step each write different things to history.
A Range, an Uncommitted Copy, and a Two-Parent Source
What gets copied does not have to be a single commit. When the command is given a commit range, the tool copies the commits in the range in order and writes a separate commit for each. The range notation here too is ancestor-based: the given lower bound is excluded, the upper bound is included. This is the most common reason a range is copied one commit short.
A second option leaves the copy uncommitted: the change is applied to the working area and the staging area, no commit is written. This is used when several commits are meant to be copied and recorded as a single commit on the target branch — and in that case, no separate counterpart of the source commits ever forms in history.
The case is different if the source is a merge commit. A merge commit has two parents, and “the change it made relative to its parent” does not give a single answer: two separate patches emerge depending on which parent is looked at. The tool therefore requires which parent is to count as the base to be stated explicitly, and refuses the work if it is not.
# taught command and example dump — not executed $ git cherry-pick a1b2c3d error: commit a1b2c3d is a merge but no -m option was given. fatal: cherry-pick failed
This refusal is a direct consequence of the first lesson’s two-parent structure. What a merge commit carries is not a change but the meeting of two lines; reducing it to a single patch can only be done by choosing one side.
Is the Copy’s Origin Recorded
The second lesson’s result repeats here exactly. Unless the tool writes it on its own, the copy’s source does not stand in history: the new commit carries no field saying which commit it was copied from. There is an option that records this information, and it is optional — given, the tool adds a line to the end of the commit message writing the source’s ID.
# taught command and example dump — not executed $ git cherry-pick -x 4d5e6f1 $ git log -1 --format=%B report: fix field width (cherry picked from commit 4d5e6f1)
Notice where the line is written: inside the commit message. The tool defines no separate field for this; it writes the origin into the message the same way it writes the rationale. What was said about conflict resolution in the previous lesson holds here too — information not written into the message stands nowhere.
Can a copy whose source was not written be found afterward? Partly. The tool has an ability to compare patches by content: if two commits produce the same change, they carry the same patch ID even with separate commit IDs. This comparison tells, between two branches, which commits are already present on the other side.
# taught command and example dump — not executed $ git cherry -v main report + 7e8f9a0 report: add summary line - 4d5e6f1 report: fix field width
The line marked with a minus sign reports that the change already exists on the other side as an equivalent patch. This finds that the copy exists; it does not find why it was taken. The two questions are separate, and the answer to the second is, again, only in the message.
The patch comparison has a limit too: equivalence looks at the text of the change. If a conflict was resolved during the copy, or the context differed, the patches are not identical and no match can be established. The copy then looks like a separate change.
The measurement’s assumptions:
- MR22 — Cherry-pick writes the change the source commit made relative to its parent as a new-ID commit at the target branch’s tip; the source commit stays in place.
- MR23 — The patch ID is the change itself. The copy carries the same patch ID; the commit ID is separate. The measurement counts the two as separate commits.
- MR24 — The copy is written after the target branch’s own commits and before the merge commit; it carries its own time and does not break branch integrity.
- MR25 — The baseline history is the merge commit format. Nothing is changed except adding the copy.
- MR26 — The tracked file is
report.py. The file trail question checks whether the commits that touched it can each be found individually; it does not check how many separate changes there are. - MR27 — A copy of a buggy commit is also buggy; the copied change carries the bug with it.
- MR28 — The set’s resolution is 1/12 at twelve commits and 1/6 at six questions.
Measurement
"""Cherry-pick: the same change sits in two places in history. Part 1 - before and after the copy: commits touching report.py. Part 2 - how the file trail and bug isolation questions are affected by the copy. """ SEED = 20260813 BRANCHES = ("metrics", "report", "identity") FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"} BUGGY = ("report", 2) SHARED_FILE = "config.py" TRACKED = "report.py" def rng(seed): state = seed % 2147483646 + 1 def draw(n): nonlocal state state = (state * 48271) % 2147483647 return state % n return draw def development(): draw, record, time = rng(SEED), [], 0 for step in range(1, 5): for branch in BRANCHES: time += 1 + draw(3) file = (SHARED_FILE if step == 3 and branch in ("metrics", "report") else FILES[branch]) record.append({"branch": branch, "step": step, "file": file, "buggy": (branch, step) == BUGGY, "time": time}) return record def integrate(record, format): t = [] if format == "merge commit": for branch in BRANCHES: for k in [x for x in record if x["branch"] == branch]: t.append({**k, "branch_record": branch, "time_preserved": True}) t.append({"branch": branch, "step": 0, "file": None, "buggy": False, "time": max(x["time"] for x in record if x["branch"] == branch), "branch_record": branch, "merge_commit": True, "time_preserved": True}) return t def q2_bug(t): buggy = [x for x in t if x["buggy"]] return len(buggy) == 1 and not buggy[0].get("squashed") def q4_file(t): return all(isinstance(x["file"], str) or x.get("merge_commit") for x in t) def patch_id(x): """Patch ID: the change itself. A copy carries the same patch ID.""" return x.get("patch", (x["branch"], x["step"])) def cherry_pick(t, source, target): """The source commit is copied to the target branch: new ID, same patch.""" y, k = [], next(x for x in t if (x["branch"], x["step"]) == source) last = max(x["time"] for x in t if x["branch"] == target and not x.get("merge_commit")) for x in t: if x.get("merge_commit") and x["branch"] == target: y.append({**k, "branch": target, "branch_record": target, "time": last, "patch": source, "copy": True}) y.append(dict(x)) return y def touching(t, name): return [x for x in t if x["file"] == name] record = development() BASELINE = integrate(record, "merge commit") HISTORIES = (("no copy", BASELINE), ("bug-free commit copied", cherry_pick(BASELINE, ("report", 1), "identity")), ("buggy commit copied", cherry_pick(BASELINE, ("report", 2), "identity"))) print(f"real development: {len(record)} commits | tracked file: {TRACKED} | " f"commits touching it in development {sum(1 for k in record if k['file'] == TRACKED)}") print() print(f"{'history':<26s} {'commits':>7s} {'touching':>8s} {'distinct patch':>14s}" f" {'duplicate':>9s} {'file trail':>10s}") for name, t in HISTORIES: d = touching(t, TRACKED) distinct = len({patch_id(x) for x in d}) print(f"{name:<26s} {len(t):7d} {len(d):8d} {distinct:14d} {len(d) - distinct:9d}" f" {('yes' if q4_file(t) else 'no'):>10s}") print() print(f"{'history':<26s} {'buggy commits':>13s} {'bug isolation':>14s}") for name, t in HISTORIES: print(f"{name:<26s} {sum(1 for x in t if x['buggy']):13d}" f" {('yes' if q2_bug(t) else 'no'):>14s}") print() for name, t in HISTORIES[1:]: k = [x for x in t if x.get("copy")][0] print(f"{name}: copy on branch {k['branch']}, patch {k['patch']}, " f"file {k['file']}")
real development: 12 commits | tracked file: report.py | commits touching it in development 3
history commits touching distinct patch duplicate file trail
no copy 15 3 3 0 yes
bug-free commit copied 16 4 3 1 yes
buggy commit copied 16 4 3 1 yes
history buggy commits bug isolation
no copy 1 yes
bug-free commit copied 1 yes
buggy commit copied 2 no
bug-free commit copied: copy on branch identity, patch ('report', 1), file report.py
buggy commit copied: copy on branch identity, patch ('report', 2), file report.py
What the File Trail Says and Does Not Say
The top table gives the file trail question a yes in all three histories. The
question’s definition did not change: every commit touching report.py can be found
individually. Copying does not break this answer, because a copy is also a single commit
touching a single file.
What breaks is the answer’s meaning. With no copy, 3 commits touch report.py
and they carry 3 distinct patches; the numbers coincide. After the copy, commits
touching it are 4, distinct patches stay 3, duplicates are 1. File trail is
still complete, but it now gives two separate numbers to two separate questions: the
answer to “how many commits touched this file” is 4, the answer to “how many distinct
changes were made to this file” is 3.
This distinction was invisible before the measurement, because in a history with no copy the two numbers are always equal. Cherry-pick separates them, and whoever reads history has to know which one they are reading. A reading that measures instability by how many times a file was changed, by counting copies, shows that file as more volatile than it is. The trail is not missing; the trail is duplicated, and the mark of the duplication does not sit in history itself. Finding it requires a separate tool that compares patches by content.
The same duplication is visible at the line level too. A tool that asks which commit a line came from shows, for a copied line, the copy, not the source. Since the question asked is usually “why is this line like this,” the message that gets read is the copy’s message. However detailed the source’s message is, if the origin line was not written, there is no link leading there. The value of the option that records the origin shows up exactly here: a two-line addition is the only link from the copy to the source.
The Bug’s Two Places
The bottom table gives a harsher result. When a bug-free commit is copied, the buggy commit count stays at 1 and the isolation question is yes: history changed, the measurement did not. When the buggy commit is copied, the count becomes 2 and the isolation question is no.
The reason it drops can be read from the definition: the isolation question looks for the commit that introduced the bug to be found on its own. If the bug sits in two separate commits, “the commit that introduced the bug” is not a single answer. A bug fixed on one branch stays alive on the other, and the fix needs to be carried there too; once carried, history ends up with two fixes, two IDs, and two separate places.
This duplication deepens over time. The copy and the source are separate commits, and from here on they run separately: a fix made on top of that change on the source branch does not go to the copy on its own, and a change made on the branch holding the copy does not return to the source either. The two places start with the same text and drift apart a little more with every edit. Patch comparison finds a match in the early days, and stops finding one once the drift begins — meaning the duplication becomes permanent exactly at the moment it becomes hard to find.
The numeric effect is 1/6: one of six questions drops, and the number of questions the merge commit answers falls from 5 to 4. This equals the set’s resolution and is the smallest defensible difference.
A criterion follows from this. Cherry-pick is cheap when it is one-directional and short-lived: a fix in a published branch is taken into the development branch, and since the two branches will merge anyway, the copy creates no lasting duplication. Cherry-pick is expensive when used as a regular integration path between two branches: every copy is a duplicate patch, every duplicate patch is a miscount, and a bug ends up with two separate places. In the second use, what is needed is not copying, it is merging.
Summary
- Cherry-pick writes the change a commit made relative to its parent as a new-ID commit onto another branch; the source commit stays in place and the same change now sits in two places.
- The copy’s origin is not recorded by the tool on its own; the option that records it writes the ID inside the commit message, there is no separate field.
- A tool that compares patches by content finds that a copy exists; it does not find why it was taken, and it may fail to match a copy taken by resolving a conflict.
- File trail still says yes after a copy, but commits touching 3 rise to 4 while distinct patches stay at 3; the trail is not lost, it is duplicated.
- When a buggy commit is copied, buggy commits become 2 and the isolation question turns no; the number of answered questions falls from 5 to 4.
Next Step
Three of the course’s integration formats are now built: merge commit, fast-forward, and rebase. Cherry-pick is not one of them — it is not a way of bringing a branch into main, it is a way of moving a single change. What remains is the fourth format and setting the formats side by side. The next lesson adds squash, which collects each branch’s commits into a single commit, and compares all four in one table — showing, with numbers, that the format producing the most readable history is the one answering the fewest questions, and why cleaning up history erases information.
To keep your progress and take notes, Log in
My notes
Log in to take notes.