Paul Hammant's Blog: The limits of merging experiment
A colleague suggested the cherry-picking way for trunk-based-development with branch for release needs worked examples cos there are foot-guns.
Cherry-pick to a release branch isn’t crystal clear as a workflow
The failure modes are more interesting than they look. To pick at it, I built a small playground - a single-file Sinatra CRUD app, an end-to-end Playwright test, and a series of trunk commits that would let me run the same scenario two different ways and see what git actually does.
The repo is here: paul-hammant/limits-of-merging-experiment.
Clone it, run ./start.sh to make solution/ folder the git folder not the one you cloned.
The run bundle install to go get deps.
The setup
Folder solution/ contains the ruby/sinatra app and a playwright test for it. It is three tier - html with JS, a ruby middle tier, and a sqlite base tier. reset.sh will
keep getting us back to a starting point. See “C1” below.
Five hypothetical trunk commits, all reachable as patches in patches/ and re-applicable on demand, are key to this experiment.
| Change | Touches | |
|---|---|---|
| C1 | Initial Person CRUD app, seeded Flintstones, Playwright happy-path test | everything |
| C2 | Add hair_color (string) - dropdown, JS validation, DB CHECK constraint |
app.rb, happy_path_test.rb |
| C3 | Button text change to UPPERCASE: NEW PERSON / EDIT / DELETE / SAVE / CANCEL | app.rb, happy_path_test.rb |
| C4 | hair_color becomes INTEGER (1..6); dropdown values are now ints |
app.rb, happy_path_test.rb |
| C5 | Add maintainer comment in header (cosmetic, previously untouched region) | app.rb |
The shape that matters for this experiment: C3 is cosmetic and unrelated to hair colour. C4 builds on C2. C5 is in an untouched corner. This is normal trunk life - small unrelated changes interleaving in the same files.
The hypothetical release branch is cut from C2. The team wishes that was it for the release, and continues on an unfrozen trunk as normal. Later there’s something that agreed as a bug fix (definately not feature creep) and it should be cherry picked to the release branch by the responsible “merge meister” or release engineer. We want to ship bugfix C4 (the int conversion) but not C3 feature change (the uppercase buttons).
Scenario 1: git am is honest, and that’s why it fails
First release engineer instinct: apply the C4 patch directly to “stable” release branch.
$ git checkout -b release c2
$ git am patches/c4-hair-color-int.patch
Applying: C4: hair_color stored as INTEGER (1..6), dropdown values become ints
error: patch failed: app.rb:133
error: app.rb: patch does not apply
Loud failure. Why? Look at the failing hunk:
<td><%= h p['dob'] %></td>
- <td><%= h p['hair_color'] %></td>
+ <td><%= h(HAIR_COLORS[p['hair_color']]) %></td>
<td class="row-actions">
<a class="button" href="/people/<%= p['id'] %>/edit">EDIT</a>
The patch’s context lines (the unchanged EDIT reference around the change) include C3’s uppercase text. On the release branch the buttons still say Edit. Context doesn’t match. git am is strict about context - it refuses.
This is git am correctly doing its job. It’s not the failure mode I’m interested in.
Scenario 2: git cherry-pick is helpful, and that’s why it lies
(we do a ./reset.sh to go back to the starting position)
Second instinct, and what most developers actually type:
$ git checkout -b release c2
$ git cherry-pick c4
Auto-merging app.rb
[release ...] C4: hair_color stored as INTEGER (1..6), dropdown values become ints
Clean. No conflict. Test passes.
Why? Because git cherry-pick doesn’t apply patches by context - it does a three-way merge with the parent of C4 as the merge base. The merger sees:
- merge base (C3 on trunk) had
EDIT - the cherry-pick target (release branch) has
Edit - C4 didn’t change either of those lines
So three-way merge correctly concludes “leave the case alone, just apply the hair-colour change.” Release ends up with C4’s diff cleanly applied on top of Edit.
That’s the textbook good outcome. And it’s exactly the failure mode the colleague was warning about - not because this cherry-pick was wrong, but because the success was contingent on a property of the diffs that nobody checked. C3 happened to touch only button text. If C3 had ever-so-slightly tidied up the dropdown the cherry pick may have conflicted - forcing a human to arbitrate on it.
The lies are little lies, from good intentions, perhaps.
What about merge-point tracking?
Here’s where Subversion fans get nostalgic. SVN’s svn:mergeinfo tries to record on the release branch “I have integrated revisions r3, r4 from trunk.”
A subsequent sweep merge of trunk into release knows to skip those. Not just Subversion, but the “bigger” VCS technologies Perforce and Microsoft’s TFVC.
Git has none of that. A cherry-pick produces a commit with a different SHA from the original - git cat-file -p on the two reveals different parents,
different trees, different hashes. The only audit trail is the optional (cherry picked from commit ...) line that git cherry-pick -x leaves in the
commit message, and git itself does not consult that line for anything. It’s a comment.
So if you later merge trunk wholly into release, git’s merge base is the last common ancestor - which is C2, not C4. Git will re-apply C3, C4, and C5 from trunk on top of release. If the cherry-picked C4 on release is byte-identical to trunk’s C4, the merger deduplicates silently. If they differ even slightly (a hotfix on release, a typo correction), you get spurious conflicts that look very real, with no way for git to say “you already integrated this, just take trunk’s copy.” Coming from Svn, Perforce or TFVC merge-point-tracking is not as you remember it. In a TBD + release branches workflow you would never do a sweeping commit from trunk to the release branch - you would only do cherry picks and less and less so over time to that release branch. At some point the release branch has been superceded and eleigible for deletion.
SVN’s mergeinfo aimed at this and got bitten by edge cases - subtree mergeinfo creep, properties getting out of sync if you bypass svn merge. The
“slightly broken” reputation is earned. But the intent - cross-branch awareness of what’s been integrated - is something git deliberately doesn’t have.
Linus rejected it on simplicity grounds. The price is paid by anyone running long-lived release branches.
The order of cherry-picks question
If C3 and C4 are eventually both wanted on the release branch, does the order matter? Two scenarios:
Scenario A - out of order. Cherry-pick C4 first (the urgent fix), then C3 later (because someone decided uppercase buttons should ship after all):
release branch cherry-picks: C2 then C4' then C3'
Scenario B - in order. Cherry-pick in trunk order:
release branch cherry-picks: C2 then C3' then C4'
In both cases, trunk has C2, C3, and C4. We could then sweep-merge c5 from trunk into the release branch.
Resulting tree hashes (with author/date/identity pinned so SHAs are deterministic):
| Tree hash after sweep merge | Merge commit SHA | |
|---|---|---|
| Scenario A (out of order) | 088b679... |
e96a8e0... |
| Scenario B (in order) | 088b679... |
4ad3efc... (fast-forward!) |
Tree hashes match. Content is byte-identical. Empty git diff between the two release branches.
But the commit graphs are different shapes. Scenario A produced a real merge commit with two parents - the cherry-picked C4’/C3’ on release and the trunk C5 - because the histories diverged. Scenario B’s cherry-picks produced commits byte-identical to trunk’s (same diffs, same pinned timestamps), so the sweep merge fast-forwarded; release’s tip is main’s tip.
So the answer to “does order matter” is layered:
- Content: no, both converge to the same source tree.
- History shape: yes, you get a merge node in one and a flat history in the other.
- SHA equivalence: no, never - parent chains differ, so commit SHAs cascade differently. SHA equality was never the right test for “did this work.”
Blocking a commit (Scenario C)
The order question above is about C3 and C4 both eventually shipping. What about C3 not shipping at all — a hard “no, this isn’t for this release”? Cut release from C1 (so C2 also becomes a cherry-pick), cherry-pick C2 and C4, then:
$ git merge -s ours --no-edit \
-m "block C3: merge -s ours (record without applying diff)" c3
$ git merge --no-edit main # sweep
git merge -s ours c3 makes c3 a parent of release via a merge commit, but the resulting tree is exactly “ours” — none of c3’s diff is applied. The merge-base machinery on subsequent merges then sees c3 as already integrated and skips it. It’s the structural equivalent of SVN’s --record-only and Perforce’s resolve -ay.
The scenario script probes “is each trunk tag reachable from release?” after every step, since reachability is git’s audit trail:
after Cherry-pick C2: c2 yes, c3 no, c4 no, c5 no
after Cherry-pick C4: c2 yes, c3 no, c4 no, c5 no
after -s ours block of C3: c2 yes, c3 YES, c4 no, c5 no ← merge node makes c3 an ancestor
after sweep: c2 yes, c3 yes, c4 yes, c5 yes ← main is now an ancestor
The c3 flip on the third row is the block being recorded. After the sweep, all of main’s tags are reachable through the merge edge — but git diff release main reports only C3’s button-text changes (UPPERCASE on main, mixed-case on release). The block held.
(Aside: c2 says “yes” right after its cherry-pick because the experiment pins author/date/identity, so the cherry-pick reproduced c2’s original SHA byte-for-byte. In a real workflow timestamps differ on every cherry-pick, so c2 would show “no” too — cherry-picks leave no DAG fingerprint, only -s ours does.)
The thing to notice: git can block a commit durably, but the audit shape is different from SVN/P4. Where SVN updates a property string and P4 writes an ignored integration record, git records the decision as a graph fact — a merge node with the blocked commit as a parent. Reading the audit trail later means inspecting the DAG and the commit message at the block step. There’s no git mergeinfo, no git integrated. The data lives in git log --merges, and the meaning lives in the message you typed.
What the SHA actually proves
This is the bit I had to stop and think about. I’d been comparing commit SHAs and getting confused by the differences. The right framing:
Commit SHA equality means “byte-identical commit object including parents.” Tree hash equality means “byte-identical content.” Cherry-picks change parents. They cannot preserve commit SHAs. They can preserve tree hashes - and that’s the only equivalence that matters for correctness.
So when judging whether a cherry-pick + sweep merge produced the right result: diff the trees, not the commits. A clean git diff main release after the sweep is the
only proof you need.
What git can’t tell you
Putting it together, here is what git silently cannot answer for a release branch built from cherry-picks:
- “Have I already integrated this trunk commit?” Git’s answer is “no” - even if you cherry-picked it. The DAG has no link after the even (ignoring formatted comments).
- “When I sweep merge runk to release, will it be a no-op?” Only if every cherry-picked patch is byte-identical to its trunk twin. There’s no machine check.
- “Are these two release branches functionally equivalent?” Only
git diffof trees can tell you. Commit history is misleading. - “Did this cherry-pick land safely?” Only your automated tests can tell you. Three-way merge succeeding is necessary, not sufficient.
The real risk in real corporate codebases
A common pushback: “in a million-line corporate codebase, two unrelated commits almost never touch the same file region - cherry-picks land cleanly the vast majority of the time.” That’s empirically true. The base rate of textual collision is low.
But the question isn’t frequency, it’s severity when it does happen. The classic bad outcome isn’t a noisy git am rejection - it’s a quiet git cherry-pick
that three-way-merges into wrong-but-plausible code, ships to a release branch, passes your tests because the tests don’t cover the exact corner that broke,
and surfaces in production a week later. Git gives you no warning. There’s no git fsck --semantic.
The mitigations I keep coming back to:
- Work in thin vertical slices. This is a good idea generally, but it especially pays off when cherry-picks are in your future. A single commit/PR that changes the DB schema, the middle tier, and the UI together is one cherry-pick - either it all lands on the release branch or none of it does. Split the same bug fix across three commits (one per tier) and you now have three cherry-picks that each have to be remembered, ordered, and applied. Miss one and you ship a half-fix; the release branch compiles, the smoke test passes, and the bug is “fixed” everywhere except the layer you forgot. Your pre-commit automated tests on the release branch should catch the omission - but “should” is doing heavy lifting there, and the failure mode is exactly the kind of partial-state subtlety tests are weakest at.
- Test the release branch like it’s a fresh codebase, not “trunk minus a few commits.” End-to-end, not just the change you cherry-picked.
- Cherry-picks of schema/data changes need extra scrutiny. Migration logic written assuming a trunk DB state may break against a release DB state.
- Prefer release-from-trunk over release-with-cherry-picks when your cadence allows it - roughly weekly or faster. If you ship every few days, a release branch buys you very little and the cherry-pick overhead isn’t worth it; just tag trunk. Cherry-picked release branches earn their keep at monthly/quarterly cadences where the stabilization window is long enough that trunk has moved on substantially. Long-lived release branches that absorb selective fixes are a structural risk, not a tooling problem git is going to grow out of.
- For high-cost cherry-picks, run the full test suite on the cherry-picked branch before merge. This is what the playground demonstrates: a happy-path Selenium/Cypress/Playwright test that hits the UI and asserts on the DB shows up regressions that
git diffwon’t.
Reproducing this
git clone https://github.com/paul-hammant/limits-of-merging-experiment
cd limits-of-merging-experiment
./start.sh # set up solution/ as a fresh playground
./scenario-a-out-of-order.sh # release: C2, C4, C3, then merge main
./rollback.sh
./scenario-b-in-order.sh # release: C2, C3, C4, then merge main
./rollback.sh
./scenario-c-block-c3.sh # release@C1: cherry-pick C2 + C4, BLOCK C3, sweep main
Each script prints the resulting graph, tree hash, and diff. Compare the two.
The patches in patches/ are real git format-patch output - readable, replayable, and the source of truth for the trunk timeline. The scenario scripts pin author identity and timestamps so anyone running them gets the same SHAs I did. That’s the only way to make a cherry-pick experiment reproducible.
Repeating in SVN: what svn:mergeinfo actually looks like
Earlier I waved at svn:mergeinfo as the thing git deliberately doesn’t have. Worth showing the property string itself, because the shape is the whole point.
The same C2–C5 timeline replayed in a fresh local SVN repo gives this revision map:
| Repo rev | Meaning |
|---|---|
| r1 | layout (mkdir trunk + branches + tags) |
| r2 | C1 — initial Person CRUD app |
| r3 | C2 — hair_color (string) |
| r4 | C3 — UPPERCASE buttons |
| r5 | C4 — hair_color INTEGER |
| r6 | C5 — maintainer comment |
| r7 | svn copy /trunk@r3 /branches/release (cut at C2) |
Scenario A — cherry-pick out of order (C4 then C3, then sweep)
$ cd svn-wc/branches/release
$ svn merge -c5 ^/trunk . # cherry-pick C4
$ svn commit -m "cherry-pick C4 from trunk@r5"
$ svn propget svn:mergeinfo .
/trunk:5
$ svn merge -c4 ^/trunk . # cherry-pick C3
$ svn commit -m "cherry-pick C3 from trunk@r4"
$ svn propget svn:mergeinfo .
/trunk:4-5
$ svn merge ^/trunk . # sweep
--- Merging r6 through r9 into '.':
U app.rb
$ svn commit -m "sweep merge ^/trunk into release"
$ svn propget svn:mergeinfo .
/trunk:4-9
Scenario B — cherry-pick in trunk order (C3 then C4, then sweep)
$ svn merge -c4 ^/trunk . # cherry-pick C3
$ svn commit -m "cherry-pick C3 from trunk@r4"
$ svn propget svn:mergeinfo .
/trunk:4
$ svn merge -c5 ^/trunk . # cherry-pick C4
$ svn commit -m "cherry-pick C4 from trunk@r5"
$ svn propget svn:mergeinfo .
/trunk:4-5
$ svn merge ^/trunk . # sweep
$ svn commit -m "sweep merge ^/trunk into release"
$ svn propget svn:mergeinfo .
/trunk:4-9
What the property is telling you
Both scenarios converge to /trunk:4-9, and svn diff ^/trunk ^/branches/release reports no file content difference — only this property exists on the branch root. The intermediate path differs (/trunk:5 → /trunk:4-5 versus /trunk:4 → /trunk:4-5), but order doesn’t matter to the end state, just like in git.
What is different from git: the sweep svn merge ^/trunk reads the property and refuses to re-apply revisions named in it. Only r6 (C5) actually produced edits in the sweep — r4 and r5 were already accounted for. SVN can answer the question “have I integrated this trunk revision yet?” because it wrote down the answer the first time. Git cannot, because git deliberately wrote nothing down.
There’s a quirk visible in the final string: /trunk:4-9, not /trunk:4-6. SVN records the closed range it considered during the sweep, including repo revisions that touched neither trunk nor any merge source — r7 was the branch copy, r8 and r9 were the cherry-pick commits themselves. This is exactly the kind of “mergeinfo creep” SVN earned its slightly-broken reputation for. It’s harmless here; it can become noisy across years of long-lived branches, particularly if anyone bypasses svn merge and edits properties by hand.
Scenario C — blocking C3 with --record-only
The third question worth asking: what about the change you don’t want to ship? Suppose the team’s verdict on C3 (UPPERCASE buttons) is “not for this release” — not “we’ll cherry-pick it later” but a hard no. Re-cut the release branch from C1 (so C2 also becomes a cherry-pick), then:
$ svn merge -c3 ^/trunk . # cherry-pick C2 (the wanted feature)
$ svn merge -c5 ^/trunk . # cherry-pick C4 (bugfix on top of C2)
$ svn merge --record-only -c4 ^/trunk . # block C3: record but do not apply
$ svn merge ^/trunk . # sweep — should only pick up C5
The --record-only flag adds the revision to svn:mergeinfo without applying its diff. The property evolution:
after cherry-pick C2 (-c3): /trunk:3
after cherry-pick C4 (-c5): /trunk:3,5 ← non-contiguous, r4 absent
after record-only block (-c4): /trunk:3-5 ← r4 fills in, no diff applied
after sweep: /trunk:3-10
The sweep consults the property, sees r4 is accounted for, and skips it. The final release tree differs from trunk only by C3’s button-text change — the block held.
The thing to notice: the post-block property string is /trunk:3-5. That is exactly what the string would say if C3 had been merged normally. It cannot tell you whether r4 was applied or blocked — only that it was considered. SVN’s mergeinfo records “we accounted for this revision,” not the intent behind that accounting. Future-you reading the property in a year has to fall back on the commit message at the block step. The data structure remembers the bookkeeping but not the decision.
Reproducing the SVN side
The scripts are on the svn-version branch of the same repo:
git checkout svn-version
sudo apt install subversion # or your platform's equivalent
./start.sh # build trunk r2..r6 + release@r3
./scenario-a-out-of-order.sh # cherry-pick C4 then C3, then sweep
./rollback.sh # wipe svn-repo/ and svn-wc/
./scenario-b-in-order.sh # cherry-pick C3 then C4, then sweep
./rollback.sh
./scenario-c-block-c3.sh # release@r2; cherry-pick C2 + C4, BLOCK C3, sweep
Each scenario script wipes and rebuilds the repo (SVN is append-only, so “reset to a past revision” means start over), prints the svn:mergeinfo value after every step, and ends with a svn diff of trunk against release. The same patches/ directory feeds both the git and SVN flows — the patches are applied with patch -p1 rather than git apply, because the SVN working copy lives inside the outer git worktree and git apply would treat it as the parent repo’s index.
So: SVN does have the audit trail, the property is human-readable, and the sweep merge is genuinely aware of it. The cost is the property’s tendency to grow ranges that include revisions it had no business including, plus the institutional discipline of never touching svn:mergeinfo directly. Whether that’s a better trade than git’s “we keep no record at all” is a judgement call about what failure mode you’d rather face — false reassurance from a slightly-wrong record, or no record and a test suite doing all the work.
Repeating in Perforce: integration records, not properties
Perforce solves the same problem SVN does — what’s been integrated where — but it stores the answer per-file in the integration database rather than as a string-property on the branch root. Run p4 integrated and the depot tells you, for every file revision on the branch, which trunk revision it came from and whether it arrived as a clean copy or a three-way merge.
Same C2–C5 timeline, replayed against a local p4d:
| Changelist | Meaning |
|---|---|
| CL1 | C1 — initial Person CRUD app |
| CL2 | C2 — hair_color (string) |
| CL3 | C3 — UPPERCASE buttons |
| CL4 | C4 — hair_color INTEGER |
| CL5 | C5 — maintainer comment |
| CL6 | p4 populate //depot/trunk/...@2 //depot/branches/release/... (cut at C2) |
Scenario A — cherry-pick out of order (C4 then C3, then sweep)
$ p4 integrate //depot/trunk/...@4,@4 //depot/branches/release/...
$ p4 resolve -am //depot/branches/release/...
$ p4 submit -d "cherry-pick C4 from trunk@CL4"
$ p4 integrated //depot/branches/release/app.rb
//depot/branches/release/app.rb#1 - branch from //depot/trunk/app.rb#1,#2
//depot/branches/release/app.rb#2 - merge from //depot/trunk/app.rb#4
$ p4 integrate //depot/trunk/...@3,@3 //depot/branches/release/...
$ p4 resolve -am //depot/branches/release/...
$ p4 submit -d "cherry-pick C3 from trunk@CL3"
$ p4 integrated //depot/branches/release/app.rb
//depot/branches/release/app.rb#1 - branch from //depot/trunk/app.rb#1,#2
//depot/branches/release/app.rb#3 - merge from //depot/trunk/app.rb#3
//depot/branches/release/app.rb#2 - merge from //depot/trunk/app.rb#4
$ p4 integrate //depot/trunk/... //depot/branches/release/...
$ p4 resolve -am //depot/branches/release/...
$ p4 submit -d "sweep merge //depot/trunk into //depot/branches/release"
$ p4 integrated //depot/branches/release/app.rb
//depot/branches/release/app.rb#1 - branch from //depot/trunk/app.rb#1,#2
//depot/branches/release/app.rb#3 - merge from //depot/trunk/app.rb#3
//depot/branches/release/app.rb#2 - merge from //depot/trunk/app.rb#4
//depot/branches/release/app.rb#4 - copy from //depot/trunk/app.rb#5
Scenario B — cherry-pick in trunk order (C3 then C4, then sweep)
$ p4 integrate //depot/trunk/...@3,@3 //depot/branches/release/...
$ p4 resolve -am //depot/branches/release/... ; p4 submit -d "cherry-pick C3"
$ p4 integrated //depot/branches/release/app.rb
//depot/branches/release/app.rb#1 - branch from //depot/trunk/app.rb#1,#2
//depot/branches/release/app.rb#2 - copy from //depot/trunk/app.rb#3
$ p4 integrate //depot/trunk/...@4,@4 //depot/branches/release/...
$ p4 resolve -am //depot/branches/release/... ; p4 submit -d "cherry-pick C4"
$ p4 integrated //depot/branches/release/app.rb
//depot/branches/release/app.rb#1 - branch from //depot/trunk/app.rb#1,#2
//depot/branches/release/app.rb#2 - copy from //depot/trunk/app.rb#3
//depot/branches/release/app.rb#3 - copy from //depot/trunk/app.rb#4
$ p4 integrate //depot/trunk/... //depot/branches/release/...
$ p4 resolve -am //depot/branches/release/... ; p4 submit -d "sweep merge"
$ p4 integrated //depot/branches/release/app.rb
//depot/branches/release/app.rb#1 - branch from //depot/trunk/app.rb#1,#2
//depot/branches/release/app.rb#2 - copy from //depot/trunk/app.rb#3
//depot/branches/release/app.rb#3 - copy from //depot/trunk/app.rb#4
//depot/branches/release/app.rb#4 - copy from //depot/trunk/app.rb#5
What the records are telling you
Same content in both scenarios — p4 diff2 //depot/trunk/... //depot/branches/release/... reports identical for every file. The two depots converged.
But the integration verbs differ in a way SVN’s mergeinfo and git’s history don’t expose at all:
- Scenario A: every cherry-pick is recorded as
merge from. Cherry-picking C4 onto a branch that doesn’t yet have C3 forced a three-way resolve under the hood — P4 noticed and labelled it. - Scenario B: every cherry-pick is recorded as
copy from. C3 then C4 in trunk order produced clean takes on each step.
The “verb that landed me here” is part of the audit trail. If you ever investigate why a release branch file diverges from trunk, knowing whether it got there via a merge or a copy (and from which exact source revision) is the question P4 answers and the question git can’t.
The sweep p4 integrate //depot/trunk/... //depot/branches/release/... with no rev range consults the integration database and only re-applies revisions that haven’t been credited yet — r5 (C5) in our run. That’s what P4 marketing meant by “merge tracking” decades before SVN tried to bolt the same idea on with svn:mergeinfo. The cost is that it’s all server-side state, locked behind the depot — there’s no offline, no pull-request workflow, and an Unloaded depot for archival is its own ceremony. The benefit is the integration history is structured, queryable per-file, and never gets out of sync with what was actually integrated.
Scenario C — blocking C3 with resolve -ay
Same workflow as the SVN scenario C, branched from C1: cherry-pick C2, cherry-pick C4, block C3, sweep.
$ p4 integrate //depot/trunk/...@3,@3 //depot/branches/release/...
$ p4 resolve -ay //depot/branches/release/... # accept yours = keep target, ignore source
$ p4 submit -d "block C3: integrate + accept-yours of trunk@CL3 (no diff)"
$ p4 integrated //depot/branches/release/app.rb
//depot/branches/release/app.rb#1 - branch from //depot/trunk/app.rb#1
//depot/branches/release/app.rb#2 - copy from //depot/trunk/app.rb#2 (C2)
//depot/branches/release/app.rb#3 - merge from //depot/trunk/app.rb#4 (C4)
//depot/branches/release/app.rb#4 - ignored //depot/trunk/app.rb#3 (C3 — blocked)
The integration verb is ignored. P4’s per-file integration database has three states for any source revision: branch/copy/merge (it landed) or ignored (it was considered and rejected). After the sweep:
//depot/branches/release/app.rb#5 - merge from //depot/trunk/app.rb#5 (C5)
C3 stays ignored. The block held, and the audit trail says, in machine-readable form, what happened.
The thing to notice: this is the cleanest case where P4 carries strictly more information than SVN. SVN’s svn:mergeinfo records that a revision was accounted for; P4’s integration database records whether the bytes were taken or rejected. Asked “did anyone consider C3 for this release?” the SVN string says yes; the P4 record says yes and tells you the answer was “no, ignored.”
Reproducing the Perforce side
Scripts are on the perforce-version branch:
git checkout perforce-version
# install p4 + p4d — see https://www.perforce.com/downloads/helix-core
# (or the primer at github.com/paul-hammant/fast_perforce_setup)
./start.sh # build trunk CL1..CL5 + release@CL2 on a sandbox p4d
./scenario-a-out-of-order.sh # cherry-pick C4 then C3, then sweep
./rollback.sh # stop p4d, wipe p4-server/ and p4-wc/
./scenario-b-in-order.sh # cherry-pick C3 then C4, then sweep
./rollback.sh
./scenario-c-block-c3.sh # release@CL1; cherry-pick C2 + C4, BLOCK C3, sweep
The sandbox runs p4d on localhost:1667 (not the conventional 1666), with no SSL and no security level set, so no passwords. Everything lives under p4-server and p4-wc; both are wiped on every run. Patches are applied with patch -p1 for the same reason as the SVN scripts — git apply would notice the outer git worktree and refuse to write.
Closing
So which VCS “knows what’s been integrated”?
| Records integration history? | Where it lives | What it records | Distinguishes “applied” from “blocked”? | |
|---|---|---|---|---|
| Git | No | Nowhere (optional (cherry picked from …) comment, never read) |
Nothing machine-checkable | No |
| SVN | Yes | svn:mergeinfo property on branch root |
A revision-range string, e.g. /trunk:4-9 |
No — both look the same in mergeinfo |
| Perforce | Yes | Per-file integration database | Source path, source rev, integration verb (branch, copy, merge, ignored) |
Yes — the ignored verb |
All three converge on the same source tree when the underlying patches don’t conflict. The difference is what the tool can tell you afterwards about how that tree got built — and therefore what kinds of “did the cherry-pick land safely?” questions you can ask the tool versus answer with tests.
Cherry-pick is not infallible. It also isn’t usually wrong. The hazard is the gap between those two facts: git’s vocabulary for “this is the same change” is “this is byte-identical bytes,” and outside of that narrow case, it has no opinion. SVN tried to fill that gap and is imperfect in it implementation. Git decided the mess wasn’t worth it.
What this means in practice for trunk-based development with release branches: cherry-pick is a tool that requires you to bring your own audit. The audit is your test suite, your code review, and your CI. If those are weak, cherry-pick is a foot-gun. If they’re strong, cherry-pick is fine - and the SHA divergence between trunk and release is just bookkeeping noise.
So per my colleagues nudge - cherry picks are great, but be careful and know the limits.
One last thing: I’ve been in engineering leadership previously for a 12 planned releases a year team, and sometimes late features were merged to the release branch using cherry-pick. Don’t do that - wait for the next release. I lost the argument cos the business really really wanted those late features - more than once from the for the same component of the system. Chery pick is for release stabilizing things only: toggle off your work that isn’t ready to go live before the branch cut moment.
Updates:
May 7, 2026: add scenario-c. Also svn and perforce branches.