From b4464625e127693520a7413af60adf63629b661e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 2 Aug 2026 10:29:09 -0500 Subject: [PATCH 1/6] docs(worktrees): the pre-squash merge-base trap, and why a three-dot 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. --- docs/WORKTREES.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index e0f8d872..3726b898 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -236,6 +236,39 @@ original edit — conflict fixup is exactly when a sweep gets re-run carelessly. `316` across `CHANGELOG.md` will happily turn `cp1252` into `cp1316`, in a file nobody re-reads. Scope replacements to the anchored forms (`BACKLOG #252`, `## 252.`), never the bare number. +### A branch cut from a pre-squash commit hides a revert behind a clean-looking diff + +`main` squash-merges, so a branch's own commits never become ancestors of `main` even after its PR +lands — their *content* arrives as one new commit. Branch again from one of those commits (a trailing +commit pushed after the PR merged, say, or an old branch you are rescuing work from) and the new +branch inherits a merge base from *before* the squash. Everything that landed in between is missing +from it, and the PR proposes deleting all of it. + +Measured 2026-08-02, rescuing an ADR from a commit pushed 1h37m after its own PR had squash-merged: +the branch differed from `main` by **58 files and 5,726 deletions** and conflicted on five. Its PR +would have reverted a dozen merged PRs. + +**A three-dot diff cannot see this, which is the trap.** `git diff origin/main...HEAD` and GitHub's +"Files changed" tab both resolve the merge base, and the merge base is exactly what is stale — so the +diff shows the two files you added and nothing else. Two checks that do see it: + +```powershell +git merge-base --is-ancestor origin/main HEAD # exit 0 = your branch CONTAINS main +git diff --stat origin/main HEAD # two-dot: tree vs tree, no merge base +``` + +A non-zero deletion count from the second, on a branch that only adds files, is the signal. + +The fix is to **merge `origin/main` into the branch**, not to rebase: the conflicting files are work +that already landed via the squash, so main's side is authoritative and taking it drops nothing. Then +re-run both checks — the acceptance test is that the two-dot diff shows only your own change. + +Blob-comparing a few files is **not** a substitute, and it is the check most likely to be reached for. +It was run here and reported all five files identical. That was correct when measured and false twenty +minutes later, because an in-flight PR touching exactly those five files merged in between. A +content spot-check answers *"are these equal right now"*, not *"will this merge cleanly"* — and if an +armed PR is queued against the same files, the first question stops predicting the second. + ## What's isolated vs shared | Isolated per worktree | Shared across worktrees | From e43fd3507f428d718a8c4974df6bdf9941d98670 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 2 Aug 2026 10:43:25 -0500 Subject: [PATCH 2/6] docs(worktrees): why merge beats rebase when every commit rewrites one 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. --- docs/WORKTREES.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index ec2f1d3b..f2976f46 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -277,6 +277,21 @@ The fix is to **merge `origin/main` into the branch**, not to rebase: the confli that already landed via the squash, so main's side is authoritative and taking it drops nothing. Then re-run both checks — the acceptance test is that the two-dot diff shows only your own change. +**Prefer merge over rebase generally when your commits all touch one block, for a second and nastier +reason.** A rebase replays each commit against the new base, so a seam every commit rewrites — an item +appended at the same EOF point, say — re-raises the same conflict once *per commit*. The hazard is not +the tedium. A mid-stack resolution can keep an **earlier draft** of the block, and that result has no +conflict markers, leaves `git status` clean, and passes a structural check: an item that lost half its +prose still has exactly one banner and still counts as one item. Nothing anywhere reports it. One +`git merge origin/main` raises the seam once, against the final text. Verify by grepping for strings +only your latest revision contains — **a structural check tells you the block is complete, not that it +is the version you meant**, and those are different properties. *Measured 2026-08-02 on `docs/BACKLOG.md` +EOF appends, independently by two sessions.* + +`gh pr update-branch` cannot rescue that class either: it performs the merge server-side, so a +conflicting merge simply fails and the PR stays `DIRTY`. A local merge, hand resolution and push is +the only route — the table above says as much for `DIRTY`, and this is why the button does not help. + Blob-comparing a few files is **not** a substitute, and it is the check most likely to be reached for. It was run here and reported all five files identical. That was correct when measured and false twenty minutes later, because an in-flight PR touching exactly those five files merged in between. A From 30b0170512ec7607a3b3303aedadb6ceaf9e1b37 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 2 Aug 2026 10:45:35 -0500 Subject: [PATCH 3/6] docs(worktrees): drop a provenance claim I could not verify 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. --- docs/WORKTREES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index f2976f46..2972e098 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -286,7 +286,7 @@ prose still has exactly one banner and still counts as one item. Nothing anywher `git merge origin/main` raises the seam once, against the final text. Verify by grepping for strings only your latest revision contains — **a structural check tells you the block is complete, not that it is the version you meant**, and those are different properties. *Measured 2026-08-02 on `docs/BACKLOG.md` -EOF appends, independently by two sessions.* +EOF appends.* `gh pr update-branch` cannot rescue that class either: it performs the merge server-side, so a conflicting merge simply fails and the PR stays `DIRTY`. A local merge, hand resolution and push is From a3e02abc74f4abdf49573e554eacb697ead626fa Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 2 Aug 2026 10:48:53 -0500 Subject: [PATCH 4/6] docs(worktrees): mark the update-branch failure mode as inferred, not 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. --- docs/WORKTREES.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 2972e098..228439b5 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -288,9 +288,11 @@ only your latest revision contains — **a structural check tells you the block is the version you meant**, and those are different properties. *Measured 2026-08-02 on `docs/BACKLOG.md` EOF appends.* -`gh pr update-branch` cannot rescue that class either: it performs the merge server-side, so a -conflicting merge simply fails and the PR stays `DIRTY`. A local merge, hand resolution and push is -the only route — the table above says as much for `DIRTY`, and this is why the button does not help. +Do not expect `gh pr update-branch` to rescue that class. Its documented default is to update **by +merging the base into the PR branch**, server-side, and the endpoint accepts no resolution — so there +is nothing it can do with a conflicting merge. Resolve locally and push, as the `DIRTY` row above +already says. *(GitHub does not document that endpoint's behaviour on conflict; this follows from the +documented mechanism, not from a measurement.)* Blob-comparing a few files is **not** a substitute, and it is the check most likely to be reached for. It was run here and reported all five files identical. That was correct when measured and false twenty From f47ab16cd30c58bf8388e26b17a916e164a82d34 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 2 Aug 2026 11:35:32 -0500 Subject: [PATCH 5/6] docs(worktrees): date the armed-auto-merge bullet, and name which check 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. --- docs/WORKTREES.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 228439b5..6fe6a865 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -217,6 +217,11 @@ Three things that cost real time here: update a `BEHIND` branch. Landing PR A puts PR B `BEHIND`, and B sits armed and stalled indefinitely. Someone has to rebase it. If you queue two PRs, expect to rebase the second after the first lands. + *Measured 2026-08-02, when `allow_update_branch` was `false` on this repo. It was set `true` later + that day; whether GitHub then auto-updates an armed `BEHIND` branch when `main` moves is + **unverified** — no back-fill has been observed, and GitHub's documentation does not connect the + setting to that behaviour. Until someone records one, assume the above and keep a capped + `update-branch` loop.* - **`BEHIND` and `DIRTY` are easy to confuse and the wrong fix is destructive.** Treating `DIRTY` as `BEHIND` means resolving conflicts in a hurry to make a force-push succeed. @@ -273,6 +278,19 @@ git diff --stat origin/main HEAD # two-dot: tree vs tree, no merg A non-zero deletion count from the second, on a branch that only adds files, is the signal. +**The first check is the load-bearing one; the second only confirms it.** Once `--is-ancestor` passes, +the merge base *is* `origin/main`, so two-dot and three-dot are computing the same thing and cannot +disagree — a matching diff at that point proves nothing you did not already know. The trap only exists +in the window where that check fails. Measured on this branch, minutes apart: + +| `--is-ancestor` | two-dot | three-dot | +|---|---|---| +| fails (stale checkout) | 2 files, 52 insertions, **22 deletions** | 1 file, 50 insertions — deletions **hidden** | +| passes | 1 file, 50 insertions | 1 file, 50 insertions — identical | + +So run `--is-ancestor` first and treat a failure as the finding. Reaching for the diff alone is how the +trap survives a check: on the branch you are most likely to test, it agrees with itself. + The fix is to **merge `origin/main` into the branch**, not to rebase: the conflicting files are work that already landed via the squash, so main's side is authoritative and taking it drops nothing. Then re-run both checks — the acceptance test is that the two-dot diff shows only your own change. From 0056ebf7d3e0b667f8219f5bd8d8192d72ac278b Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 2 Aug 2026 18:17:41 -0500 Subject: [PATCH 6/6] =?UTF-8?q?docs(worktrees):=20correct=20the=20merge-ba?= =?UTF-8?q?se=20section=20=E2=80=94=20the=20numbers=20were=20real,=20the?= =?UTF-8?q?=20pairing=20was=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (d6cb23b7 / 714432fc): 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 002be182, 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. --- docs/WORKTREES.md | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 6fe6a865..be0463c9 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -255,21 +255,39 @@ original edit — conflict fixup is exactly when a sweep gets re-run carelessly. `316` across `CHANGELOG.md` will happily turn `cp1252` into `cp1316`, in a file nobody re-reads. Scope replacements to the anchored forms (`BACKLOG #252`, `## 252.`), never the bare number. -### A branch cut from a pre-squash commit hides a revert behind a clean-looking diff +### A branch cut from a pre-squash commit hides its staleness behind a clean-looking diff `main` squash-merges, so a branch's own commits never become ancestors of `main` even after its PR lands — their *content* arrives as one new commit. Branch again from one of those commits (a trailing commit pushed after the PR merged, say, or an old branch you are rescuing work from) and the new branch inherits a merge base from *before* the squash. Everything that landed in between is missing -from it, and the PR proposes deleting all of it. +from it. -Measured 2026-08-02, rescuing an ADR from a commit pushed 1h37m after its own PR had squash-merged: -the branch differed from `main` by **58 files and 5,726 deletions** and conflicted on five. Its PR -would have reverted a dozen merged PRs. +Measured 2026-08-02, rescuing an ADR from a commit pushed 1h37m after its own PR had squash-merged. +Merge base `002be182` — **11 squash-merged PRs behind `main`**. A two-dot diff reported **58 files, +959 insertions and 5,726 deletions**, and `git merge-tree` conflicted on **five** 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`). -**A three-dot diff cannot see this, which is the trap.** `git diff origin/main...HEAD` and GitHub's -"Files changed" tab both resolve the merge base, and the merge base is exactly what is stale — so the -diff shows the two files you added and nothing else. Two checks that do see it: +**What that does *not* mean.** The PR did **not** propose deleting `main`'s work, and nothing was +about to be reverted. A three-way merge keeps `main`'s side of every file the branch never touched, +so the change actually on offer was **13 files, 2,967 insertions and 19 deletions**. Those 5,726 +deletions are an artefact of *how you asked*, not a change anyone proposed. **The hazard is the five +conflicts and the chance of resolving one wrongly — not reversion.** That is a smaller claim than +"this would have reverted a dozen merged PRs", and it is the one the measurements support. + +**Two questions, two diffs. Asking one and reading its answer as the other is the trap:** + +| Question | What answers it | +|---|---| +| *Does merging this revert anything?* | **three-dot** — `git diff origin/main...HEAD` | +| *Is this branch missing `main`'s work?* | **two-dot** — `git diff origin/main HEAD` — or `git merge-base --is-ancestor` | + +**A three-dot diff cannot see staleness, which is the trap.** `git diff origin/main...HEAD` and +GitHub's "Files changed" tab both resolve the merge base, and the merge base is exactly what is +stale — so the diff describes your branch against a `main` from 11 PRs ago. On the branch above it +reported 13 files / 2,967 insertions / 19 deletions: an accurate account of what the PR *adds*, and +no indication whatever that five files would conflict. Two checks that do see it: ```powershell git merge-base --is-ancestor origin/main HEAD # exit 0 = your branch CONTAINS main @@ -278,6 +296,15 @@ git diff --stat origin/main HEAD # two-dot: tree vs tree, no merg A non-zero deletion count from the second, on a branch that only adds files, is the signal. +> **This section was wrong when first written, and the way it was wrong is the lesson.** Every number +> it published was real. It paired a **post-merge** three-dot reading ("the two files you added") with +> a **pre-merge** two-dot reading (58 files, 5,726 deletions) and presented them as a single +> comparison — so a diff that never proposed a revert was described as proposing one. Each figure +> checked out individually; only the *join* between them was false, and the join carried the argument. +> It survived its author's review, a coordinator's review, an independent verification and a green CI +> run, because nothing anywhere checks joins. **Before two numbers share a sentence, confirm they +> describe the same commit at the same moment.** + **The first check is the load-bearing one; the second only confirms it.** Once `--is-ancestor` passes, the merge base *is* `origin/main`, so two-dot and three-dot are computing the same thing and cannot disagree — a matching diff at that point proves nothing you did not already know. The trap only exists