Skip to content

docs(worktrees): the pre-squash merge-base trap — corrected, the numbers were real but the pairing was not - #149

Merged
wshallwshall merged 9 commits into
mainfrom
claude/worktrees-merge-base-trap
Aug 3, 2026
Merged

docs(worktrees): the pre-squash merge-base trap — corrected, the numbers were real but the pairing was not#149
wshallwshall merged 9 commits into
mainfrom
claude/worktrees-merge-base-trap

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

This PR was rewritten after three self-found defects. The original illustration paired two readings taken at different times and presented them as one comparison. Everything below is re-measured on the same commit pair (d6cb23b7 / 714432fc); the corrections are called out explicitly rather than quietly replaced.

What

One subsection in docs/WORKTREES.md, under "Your PR won't merge — triage before you touch anything".

Why

Rescuing work from an old or trailing commit is routine here, and this failure mode was not covered. main squash-merges, so a branch's own commits never become ancestors of main — their content arrives as one new commit. Branch again from one of those commits and the new branch inherits a merge base from before the squash: everything that landed in between is missing from it.

Measured 2026-08-02 while landing ADR 0158 (#145) from a commit pushed 1h37m after its own PR had squash-merged. Merge base 002be18211 squash-merged PRs behind main:

Check Result
git diff origin/main...HEAD (three-dot) 13 files, 2,967 insertions, 19 deletions — accurate, and silent about staleness
git diff --stat origin/main HEAD (two-dot) 58 files, 959 insertions, 5,726 deletions
git merge-tree --write-tree conflicts on 5 files

The three corrections

  1. "three-dot shows the two files you added and nothing else" — false. That was a post-merge reading placed beside a pre-merge two-dot reading. On the same pre-merge pair, three-dot reports 13 files / 2,967 insertions / 19 deletions.
  2. "the PR proposes deleting all of it" — false. A three-way merge keeps main's side of every file the branch never touched. The 5,726 deletions are an artefact of how you asked, not a change anyone proposed. Real deletions: 19.
  3. "would have reverted a dozen merged PRs" — overstated. The hazard is the five conflicts and the chance of resolving one wrongly, not reversion. Smaller claim, and the one the measurements support.

What survives unchanged

The advice was never the problem. The branch really was 11 PRs stale; merge-tree really did conflict on five files; three-dot really does conceal staleness; --is-ancestor really is the load-bearing check; and merging main in while taking main's side really was the correct repair.

What it now records

  • Two questions, two diffs"does merging revert anything?" → three-dot; "is the branch missing main's work?" → two-dot / --is-ancestor. Asking one and reading its answer as the other is the trap.
  • The fix is to merge origin/main in, not rebase — the conflicting files already landed via the squash, so main's side is authoritative.
  • Why the obvious shortcut fails: a blob spot-check reported all five files identical, which was correct when measured and false twenty minutes later because an armed PR touching those five merged in between.
  • Why the original version survived review, because that is the transferable part: every published number was real, only the join between them was false, and nothing anywhere checks joins. It passed its author, a coordinator, an independent verification and a green CI run on that basis.

Verification

Every figure in this PR re-derived from the repo, not carried over: git merge-base002be182; git rev-list --count → 11 commits, 11 squash-merged PRs; git merge-tree --write-tree --name-only → exactly five conflicting files (docs/SESSION-DRIFT-CONTROLS.md, docs/WORKTREES.md, scripts/hooks/announce-session.ps1, tests/test_collision_gate.py, tests/test_coord_overlap_signals.py); both diffs re-run on the cited pair.

No engine behaviour changes. Left unarmed deliberately — this is a corrected document and should be read before it lands.

🤖 Generated with Claude Code

…diff hides it

Rescuing work from an old or trailing commit is routine here, and it has a
failure mode nothing in this document covered: `main` squash-merges, so a
branch's own commits never become ancestors of `main`. Branch again from one of
them and the new branch inherits a merge base from BEFORE the squash, so
everything that landed in between is missing from it and the PR proposes
deleting all of it.

Measured 2026-08-02 while landing ADR 0158 from a commit pushed 1h37m after its
own PR had squash-merged: 58 files and 5,726 deletions of divergence from main,
conflicting on five. A three-dot diff showed two files, because three-dot
resolves the merge base and the merge base is exactly what is stale.

Records the two checks that do see it (`merge-base --is-ancestor` and a two-dot
`diff --stat`), and that the fix is to MERGE main in rather than rebase, since
the conflicting files are work that already landed via the squash.

Also records why the obvious shortcut fails: a blob spot-check of a few files
was run here and reported all five identical. That was true when measured and
false twenty minutes later, because an armed PR touching exactly those five
merged in between. It answers "are these equal now", not "will this merge".

Placed under "Your PR won't merge" rather than beside the prune material, and
deliberately does not restate the armed-auto-merge/BEHIND point already made at
that section's third bullet.
…e block

The section already prescribed merge-over-rebase for the squash case, on the
grounds that main's side is authoritative. There is a second and nastier reason,
and it generalises beyond the squash trap.

A rebase replays each commit against the new base, so a seam that every commit
rewrites -- an item appended at the same EOF point -- re-raises the same conflict
once per commit. The hazard is not tedium: a mid-stack resolution can keep an
EARLIER DRAFT of the block, and that result carries no conflict markers, leaves
git status clean, and passes a structural check, because an item that lost half
its prose still has exactly one banner and still counts as one item. Nothing
reports it.

The rule it produces: a structural check tells you the block is COMPLETE, not
that it is the version you MEANT. Verify by grepping for strings only the latest
revision contains.

Also records that `gh pr update-branch` cannot rescue that class -- it merges
server-side, so a conflicting merge fails and the PR stays DIRTY. Complements
the DIRTY row in the table above rather than restating it, and deliberately does
not restate the UNKNOWN/async row, which already covers re-querying.

found by: the cranky-lumiere session on docs/BACKLOG.md EOF appends, confirmed
independently by the sandbox-codec session, which supplied the complete-vs-correct
distinction. Routed here rather than edited in directly because docs/WORKTREES.md
is contended and this section is on an open PR.
The rebase paragraph closed with "Measured ... independently by two sessions".
I did not measure it and could not verify the second session; I had it
second-hand from the session that raised the finding, which has since retracted
the "two sessions" wording as over-attributed -- it applied to a different fact
in the same message.

The finding itself is unchanged and stands on its own: the failure mode is
reproducible from the description, and the paragraph already states the
mechanism rather than resting on how many people saw it. What is removed is a
CONFIDENCE claim about provenance, which is the one kind of sentence whose whole
function is to tell the reader how much to trust the rest -- so it is the worst
place to carry an unchecked number.

The `gh pr update-branch` clause is deliberately left as-is. It states the
mechanism (a server-side merge cannot complete a conflicting merge, so the PR
stays DIRTY) rather than asserting a measurement, which is why it survives the
retraction untouched.
… measured

The clause asserted that `gh pr update-branch` fails on a conflict and leaves the
PR DIRTY. That outcome had been relayed as measured by two sessions, then by one,
then -- on audit -- by none: every session that reported it had only ever run the
command against a BEHIND branch, never a DIRTY one. So the sentence had no
observer at all.

Checked what IS sourceable before rewriting rather than just softening it. `gh pr
update-branch --help` documents the default as updating "with a merge commit
(i.e., merging the base branch into the PR's branch)", and the REST endpoint
takes no conflict resolution -- both real. GitHub does NOT document the
endpoint's behaviour on conflict; the 422 it lists is generic, and the only
conflict-adjacent note concerns a mismatched expected SHA.

So the guidance stands on the mechanism, which is sound, and now SAYS it stands
on the mechanism. A reader who wants to rely on the failure mode can see it was
deduced from the documented default rather than observed, and weight it
accordingly -- which is the whole point of the section it sits in.

No attribution added: once the mechanism carries the claim, naming an observer
would lend it authority it does not have, and there is no observer to name.
…ck does the work

Two corrections, both prompted by peer review of the section this PR adds.

1. The armed-auto-merge bullet was measured when `allow_update_branch` was
   `false` on this repo. It was set `true` later the same day, which may have
   falsified it. Deliberately NOT rewritten: GitHub's documentation does not
   connect that setting to base-move auto-update, and no back-fill has been
   observed by anyone -- replacing a stale-but-measured claim with a
   plausible-but-never-observed one is a strict downgrade. The bullet now carries
   its measurement date, states the new behaviour is unverified, and asserts
   nothing about back-fill. Rewrite it when someone records one.

2. The new subsection presented `--is-ancestor` and the two-dot diff as two
   co-equal checks. They are not. Once `--is-ancestor` passes, the merge base IS
   origin/main, so two-dot and three-dot compute the same thing and cannot
   disagree -- the diff is confirmation, not detection, and the trap only exists
   in the window where that check fails.

   The table is measured on this branch minutes apart, when a stale local
   checkout put it on the wrong side of the very trap it documents: three-dot
   reported 1 file / 50 insertions while two-dot reported 2 files / 52 insertions
   and 22 DELETIONS. After syncing, both read 1 file / 50 insertions.

   That is the failure mode a reader is most likely to miss, because on the
   branch they are most likely to test -- their own, up to date -- the diff
   agrees with itself.

found by: the coordinator session for the dating (its own config change caused
the drift, and it escalated rather than rewrote), and the cranky-lumiere session
for the load-bearing-check distinction, while independently verifying #145.
…al, the pairing was not

The section's load-bearing illustration was wrong in three ways, all self-found, and all
corrected here against re-measurement of the same commit pair (d6cb23b / 714432f):

1. "the diff shows the two files you added and nothing else" was a POST-merge three-dot
   reading placed beside a PRE-merge two-dot reading and presented as one comparison. The
   real three-dot on that pair is 13 files / 2,967 insertions / 19 deletions.
2. "the PR proposes deleting all of it" is false. A three-way merge keeps main's side of
   every file the branch never touched, so the 5,726 deletions were an artefact of the
   two-dot view, not a change anyone proposed.
3. "would have reverted a dozen merged PRs" overstated it. Re-measured: the merge base was
   002be18, 11 squash-merged PRs behind, and merge-tree conflicted on exactly five files.
   The hazard is those conflicts and a bad resolution -- not reversion.

The advice itself was sound and is kept: the branch really was stale, three-dot really does
conceal staleness, --is-ancestor really is the load-bearing check, and merging main in while
taking main's side really was the right repair. What changes is the framing -- the danger is
restated as conflicts and a bad resolution, and the two questions are separated explicitly,
since asking one and reading its answer as the other is the actual trap.

Adds a short note on WHY the original survived review, because that is the transferable part:
every published number was real, only the join between them was false, and nothing anywhere
checks joins. It passed its author, a coordinator, an independent verification and a green CI
run on that basis.
@wshallwshall wshallwshall changed the title docs(worktrees): the pre-squash merge-base trap, and why a three-dot diff hides it docs(worktrees): the pre-squash merge-base trap — corrected, the numbers were real but the pairing was not Aug 2, 2026
@wshallwshall
wshallwshall enabled auto-merge (squash) August 3, 2026 00:25
@wshallwshall
wshallwshall merged commit 3c2fa41 into main Aug 3, 2026
31 checks passed
@wshallwshall
wshallwshall deleted the claude/worktrees-merge-base-trap branch August 3, 2026 00:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant