---
title: Cherry-Pick
source: 'https://academia.sh/en/courses/branching-and-collaboration/cherry-pick'
course: 'Branching and Collaboration'
language: en
updated: '2026-08-17T18:10:44+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```text
# 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.

```text
# 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.

```text
# 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.

```text
# 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

```python
"""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.
