v1.2.0 to v1.3.0
173 added, 97 removed. Audit A to A.
---
name: pr-from-stale-branch-silently-reverts-newer-main-files
description: |
- Trap: opening/merging a PR from a branch that was created a while ago can
- SILENTLY DELETE (revert) files that landed on main AFTER your branch point —
- with NO merge conflict to warn you. Use when: (1) about to `gh pr create` or
- squash-merge from a long-lived / earlier-branched branch; (2) `git diff
- origin/main..HEAD --stat` shows DELETIONS of files you never touched; (3) a PR
- diff is unexpectedly large or removes another session's/teammate's work; (4)
- the repo has many parallel branches + a squash-merge flow (each squash makes
- older branches progressively staler). The fix is to merge origin/main INTO the
- branch first, then re-verify the diff shows only your additions. Distinct from
- merge-CONFLICT skills — this is the no-conflict, clean-merge silent-regression
- case. See also: pr-conflict-from-mid-flight-merges,
- large-redesign-parallel-branch-collision-audit, merge-conflict-generated-files.
- ALSO covers the variant where the branch is NOT behind main: its parent IS
- current main and `git merge origin/main` says "Already up to date", because
- only the POINTER moved while the TREE stayed old. Every behindness check
- passes and the diff still deletes what landed in between. Use when a PR
- deletes files you never touched AND the branch looks perfectly rebased.
- Auditing as a VICTIM: source every content needle from the merged diff
- (`git show <squash-sha> -- <file>`), never from memory, and check the state AT
- the suspect commit rather than at origin/main.
+ Trap: merging a PR whose branch carries an OLD TREE silently DELETES
+ (reverts) files that landed on main after that tree was built — with NO
+ merge conflict to warn you, because a deletion your own commit records is
+ not a conflict. Use when: (1) about to `gh pr create` or squash-merge from
+ a long-lived / earlier-branched branch, from a worktree committed with
+ `git add -A`, or after a `git reset --soft`; (2) `git diff --diff-filter=D
+ --name-only origin/main...HEAD` lists files you never touched; (3) a PR
+ diff is unexpectedly large or removes another
+ session's/teammate's work; (4) the repo has many parallel branches + a
+ squash-merge flow. Check DELETIONS, not behindness: the variant that does
+ the damage is NOT behind main at all — its parent IS current main,
+ `git merge origin/main` says "Already up to date" and every ancestor check
+ passes, because only the POINTER moved while the TREE stayed old. Use when
+ a PR deletes files you never touched AND the branch looks perfectly
+ rebased. Merging main in is worth doing but does NOT prevent this. As a
+ VICTIM: source every needle from the merged diff, never from memory, and
+ check the state AT the suspect commit, not at origin/main. No-conflict
+ silent regression, not a merge-CONFLICT skill. See also:
+ git-diff-2dot-vs-3dot-merge-safety (the 2-dot FALSE alarm: the deletions
+ are an artifact), pr-conflict-from-mid-flight-merges,
+ large-redesign-parallel-branch-collision-audit,
+ merge-conflict-generated-files.
author: Claude Code
- version: 1.2.0
+ version: 1.3.0
date: 2026-06-17
disable-model-invocation: true
---
# A PR from a stale branch can silently revert newer main files (no conflict)
## Problem
- Your branch was cut from main at commit X. Since then, other PRs added files
- B, C, D to main. Your branch never had B/C/D. When you open a PR (or merge),
- git computes the diff as `origin/main..HEAD` — and because your branch *lacks*
- B/C/D, the diff shows them as **DELETIONS**. Merging the PR removes B/C/D from
- main, reverting work you never touched. **There is no merge conflict** (your
- branch simply doesn't mention those files), so nothing warns you — the PR looks
- "clean" and the deletions hide in the diff stat.
+ **Your branch's commit records a DELETION of files that are on main.** Merging it
+ applies that deletion, and **there is no merge conflict** — a removal your own
+ side committed is not a conflict, it is a change git carries out. The PR looks
+ "clean", `mergeable: MERGEABLE`, CI green, and the removals sit in the diff stat
+ where nobody reads them.
+
+ Nobody decides to delete anything. The tree gets that way by accident:
+
+ - **`git reset --soft origin/main`** (or `--mixed` then `git add -A`) run from a
+ branch whose base was old. The POINTER lands on current main; the index still
+ holds the OLD tree; the commit therefore records *"delete everything added
+ since"* as part of your change.
+ - **Committing a stale worktree with `git add -A`.** Same result — the tree you
+ stage predates main, so everything main gained is staged as removed.
+ - **A branch re-created or re-pointed onto a newer base while its working tree
+ stayed old.**
+
+ ### What does NOT cause it: simply being behind main
+
+ A branch that merely *lacks* files B/C/D — never had them, never recorded
+ deleting them — **does not delete them on merge.** GitHub computes the
+ **merge-base** diff (`git diff origin/main...HEAD`, three dots) and performs a
+ three-way merge, which preserves B/C/D.
+
+ The thing that *looks* like this trap and is not: `git diff origin/main..HEAD`
+ (**two** dots) compares tip to tip, so every file main gained after your branch
+ point renders as a deletion your branch appears to be making. That is an
+ artifact of the wrong command, not a pending revert. Verified: for one PR,
+ GitHub reported 5 files / 219 insertions / 16 deletions and three-dot matched
+ exactly, while two-dot reported 4 files / 13 insertions / 22 deletions — a
+ different file list and deletions that did not exist. Full treatment:
+ `git-diff-2dot-vs-3dot-merge-safety`.
+
+ Three-dot is not the lax option, either — a branch that genuinely deletes another
+ session's 418-line file still shows `418 deletions` under three-dot. It reports
+ real removals and drops invented ones.
+
+ **So the question is never "am I behind?" It is "does my branch record a
+ deletion?"** Every behindness check passes on the variant that does the damage.
+
## Context / Trigger Conditions
- - About to `gh pr create` / `gh pr merge --squash` from a branch that's been
- around for more than a session or two, or in a repo with many parallel branches.
- - `git diff --stat origin/main..HEAD` lists files being **removed** that you have
+ - About to `gh pr create` / `gh pr merge --squash`, especially after a
+ `git reset --soft`, a rebase, or a `git add -A` in a worktree you had left
+ sitting for hours.
+ - `git diff --diff-filter=D --name-only origin/main...HEAD` lists files you have
no memory of touching (e.g. someone else's analysis/handoff/docs from a sibling
session that merged while you worked).
- The PR's deletion count is suspiciously high for the work you did.
- - Squash-merge workflows make this WORSE over time: each squash rewrites main's
- history, so a branch that "was only a bit behind" reverts more with each sibling merge.
+ - Many parallel branches + a squash-merge flow, so sibling work lands on main
+ continuously while your tree sits still.
## Solution
- 1. **Before** creating/merging the PR, always sanity-check the full diff stat:
+
+ 1. **Before** creating/merging the PR, ask the only question that matters —
+ what does this branch DELETE, measured from the merge-base:
```sh
git fetch origin main
- git diff --stat origin/main..HEAD
+ git diff --diff-filter=D --name-only origin/main...HEAD # MUST be only files you meant to delete
+ git diff --stat origin/main...HEAD # and only files you meant to touch
```
- Scan for `---` / deletion lines on files outside your scope.
- 2. If you see spurious deletions, **merge origin/main into your branch first**
- (do NOT just merge the PR):
+ Three dots, and `origin/main` rather than `main` — a stale *local* `main` ref
+ makes even the three-dot diff over-report
+ (`worktree-stale-local-main-ref-inflates-pr-diff`).
+ 2. **If the deletion list is empty, you are done.** There is nothing to fix, and
+ nothing to rebase — whatever the two-dot stat showed.
+ 3. **If it is not empty**, confirm it is a stale tree before touching anything.
+ Every behindness check will pass, so use arithmetic instead: compare the diff
+ size against several candidate bases. If your branch differs from an OLD
+ commit by FEWER files than from its own parent, the tree predates the parent:
```sh
- git merge origin/main --no-edit # clean if your changes are file-disjoint
+ for base in <parent> <a few older main shas>; do
+ printf "%s %s files\n" "$base" "$(git diff --name-only $base HEAD | wc -l)"
+ done
```
- This re-adds B/C/D to your branch so they're no longer "deleted" in the diff.
- 3. **Re-verify**: `git diff --stat origin/main..HEAD` should now show ONLY your
- own additions/edits. Then push + PR.
- 4. If the merge DOES conflict, you've crossed into merge-conflict territory →
- hand off to `pr-conflict-from-mid-flight-merges` /
+ 4. **Fix it by restoring the paths, not by merging main in** — main is usually
+ already an ancestor, so the merge is a no-op:
+ ```sh
+ git checkout origin/main -- <each deleted path> # surgical
+ # or: branch from origin/main and re-apply only your own edits
+ ```
+ Then re-run step 1. The deletion list must come back empty.
+ 5. If a merge you do run DOES conflict, you've crossed into merge-conflict
+ territory → hand off to `pr-conflict-from-mid-flight-merges` /
`merge-conflict-generated-files` (generated-file union playbooks).
- ## The harder variant: the branch is NOT behind main, and every check says so
-
- Everything above assumes the branch is *behind* main, so "am I behind?" catches
- it and "merge main in" fixes it. **Both fail on the variant that does the most
- damage**, because only the branch POINTER was moved onto current main while the
- working TREE stayed old:
-
- - `git log --oneline -2` shows your commit sitting directly on top of the newest
- main commit. Perfectly linear. Looks freshly rebased.
- - `git merge-base --is-ancestor origin/main HEAD` → **true**. You are not behind.
- - `git merge origin/main` → **"Already up to date."** The documented fix is a
- no-op.
- - No conflict, `mergeable: MERGEABLE`, CI green.
+ ### On the old advice: "always `git merge origin/main` before every PR"
- And the diff still deletes every file that landed between the old tree and the
- new parent. How it happens: `git reset --soft origin/main` (or `--mixed` then
- `git add -A`) run from a branch whose base was old — the index still holds the
- OLD tree, so committing records "delete everything added since" as part of your
- change. A stale worktree committed with `git add -A` does the same.
+ Worth doing, for reasons that are real — it surfaces conflicts on your own time
+ instead of at merge time, and it lets you run the tests on the tree that will
+ actually land. **But it does not prevent a revert**, and it was never the
+ mechanism:
- **The check that survives this** — deletions, not behindness:
+ - Against the *stale tree* case it is a **no-op**: main is already an ancestor,
+ so `git merge origin/main` prints "Already up to date" and the recorded
+ deletions survive it untouched.
+ - Against the *behind but clean* case there was nothing to prevent: a three-way
+ merge already preserved those files. What merging main in changes is the
+ **measurement** — it makes a two-dot diff stop showing phantom deletions.
+ Making a wrong command give the right answer is not the same as fixing a risk.
- ```sh
- git fetch origin main
- git diff --diff-filter=D --name-only origin/main...HEAD # MUST be only files you meant to delete
- ```
+ Keep it as hygiene. Do not count it as the check. The check is step 1.
- **And the tell that identifies it as a stale tree**, when the deletion list is
- long enough to argue about — compare the diff size against several candidate
- bases. If your branch differs from an OLD commit by FEWER files than from its
- own parent, the tree predates the parent:
+ ## Why every behindness check passes on the stale-tree variant
- ```sh
- for base in <parent> <a few older main shas>; do
- printf "%s %s files\n" "$base" "$(git diff --name-only $base HEAD | wc -l)"
- done
- ```
+ Worth spelling out, because each of these individually reads as "you are fine":
- **The fix is NOT to merge main in** (it is already an ancestor). Restore the
- paths, or rebuild the work on a fresh checkout of current main:
+ - `git log --oneline -2` shows your commit sitting directly on top of the newest
+ main commit. Perfectly linear. Looks freshly rebased.
+ - `git merge-base --is-ancestor origin/main HEAD` → **true**. You are not behind.
+ - `git rev-list --count HEAD..origin/main` → **0**.
+ - `git merge origin/main` → **"Already up to date."**
+ - No conflict, `mergeable: MERGEABLE`, CI green.
- ```sh
- git checkout origin/main -- <each deleted path> # surgical
- # or: branch from origin/main and re-apply only your own edits
- ```
+ Reproduced in a controlled repo: the ancestor gate exits 0, the behind-count is
+ 0, and the merge still deletes three files. Only
+ `git diff --diff-filter=D --name-only origin/main...HEAD` reports them.
## If one of these has already merged: splice, never revert
`git revert` on the offending squash commit re-deletes everything that has
merged *since* — the same failure, aimed the other way. Recover each lost object
individually from the last commit where it was intact and re-insert it into
*current* main:
```sh
git checkout <last-good-sha> -- <path> # files
git show <last-good-sha>:<ledger> | ... # hand-edited ledgers: splice ONE object
```
**Check the hand-maintained ledger separately from the files.** A tracker entry
can be gone while its files are fine, and vice versa — and a text field can be
rolled back to its earlier wording while the record still exists, which no
id-presence check catches. Diff the field, not just the key.
### Checking as a victim: scope the check to what you TOUCHED, not what you MADE
The natural check — and the one a broadcast asks for — enumerates *the things I
created*. **The revert's scope is *the things I touched*, which is larger**, and
the gap between them is where a clean-looking check goes wrong. Three ways it
did, all in one session, each after an earlier check had reported clean:
- **In-place edits to records you did not create.** Amending someone else's
standing ruling, appending a "DONE" paragraph to a pre-existing task, editing
a caveat block — none of those produce a new id, so an id/status/PR-number
sweep passes while the prose underneath has reverted. One standing ruling was
left publishing a superseded range with no marker on it, and the sweep that
was supposed to have verified it had looked only at `status` and `prs`.
- **A PARTIAL rollback inside one record.** Half of an appended edit survived
and half reverted, so a single entry said the question was *settled* and, two
sentences later, quoted the range the settlement replaced. **A half-reverted
record reads self-consistent** — worse than a clean revert, which at least
looks obviously old.
- **A moved number is indistinguishable from a reverted one by grep.** A README
count read 560 where the session had written 546; both a file-existence check
and a content-needle check flagged it lost, and it was not — sibling sessions
had added tests. **Re-derive the value; do not compare the string.** The
needle check is right for prose and wrong for anything computed.
The executable form is a needle per *claim*, not per file — one distinctive
phrase for every paragraph you amended, every ledger field you edited, and every
computed figure re-derived rather than matched. **Save it as a file** (called
`needle-audit.sh` throughout this section), because you will run it more than
once with different values of `REF`:
```sh
REF=${REF:-origin/main} # override to check a BRANCH pre-merge
f() { git show "$REF:$2" | grep -qF -- "$3" && echo "OK $1" || echo "LOST $1"; }
f "ruling: the amendment" path/to/ledger.js "AMENDED 2026-08-07 BY"
f "caveat: pinned, not open" path/to/ledger.js "PINNED 2026-08-07 at"
# ...and for anything computed, re-measure instead of grepping:
test "$(pytest docs -q --co 2>&1 | tail -1 | awk '{print $1}')" = "$(grep -oE '[0-9]+ collected' README.md | head -1 | cut -d' ' -f1)"
```
Keep the `REF` override: the same script is the pre-merge gate for *your* PR and
the post-merge audit for someone else's, and only the second one is usually
written.
#### Where the needle comes from — and what it costs to guess one
**Take every needle out of the merged artifact, never out of recall.** Paste the
line you took it from next to the check:
```sh
git show <squash-sha> -- path/to/file.py | grep '^+' | grep -i finalist
# → + finalist_km = ... ← THIS line is the needle
```
The audit of PR #853 (DoodleRun, 2026-08-07, tracked under #863) grepped for
`route_km_finalist` — a
symbol name recalled from the session that had written it. The real symbol was
**`finalist_km`**. The grep found nothing, and for a minute the session believed
an entire merged PR had been deleted.
**The failure is two-sided, and only one side is intuitive:**
| A guessed needle that… | …manufactures |
|---|---|
| MISSES | a phantom deletion — alarming, but self-correcting: somebody goes and looks |
| happens to MATCH | a **false all-clear** — and nobody re-checks a clean audit |
The matching case is the worse one and produces no symptom whatsoever. That is
why the provenance rule is absolute rather than a nicety: a needle you cannot
point at a line for is not evidence in *either* direction.
#### Check the state AT the suspect commit, not at `origin/main`
Current state cannot distinguish **"never hit"** from **"hit, and restored by
someone else"**. If a sibling session already noticed the revert and pushed a
recovery PR, `origin/main` holds your content again — and an audit run against
main reads that as proof you were never affected, which is exactly backwards when
the question is what a particular merge did.
```sh
REF=<suspect-squash-sha> sh needle-audit.sh # "did THAT merge drop it?" ← the real question
REF=origin/main sh needle-audit.sh # "is it there right now?" ← a different one
```
This is what the `REF` override above is for, and it is worth running both ways:
the first says whether you were a victim, the second says whether anything is
still outstanding.
**Then tell the other sessions.** Losses are per-session and nobody else can see
yours; a broadcast with a copy-pasteable `git cat-file -e origin/main:<path>`
loop is the only thing that finds the ones whose owners have already wrapped.
## Verification
- Post-merge-of-main, `git diff --stat origin/main..HEAD` lists only files you
- intended to change (no foreign deletions). The PR's "files changed" on GitHub
- matches your mental model of the work.
+ `git diff --diff-filter=D --name-only origin/main...HEAD` is **empty** (or lists
+ only files you meant to delete), and `git diff --stat origin/main...HEAD` lists
+ only files you intended to change. The PR's "files changed" on GitHub matches
+ that same list — it is computed the same way.
- ## Example (this repo, S258, 2026-06-17)
- Branch `docs/s258-prompt-update` was cut before S257b's anomaly-alignment docs
- merged to main. The first `git diff --stat origin/main..HEAD` showed 5 unrelated
- files being **deleted** (−384/−93/−65/−40/−19 lines: the anomaly HTML, a content
- snapshot, two S257b handoffs, a legend edit) alongside the intended S258 additions
- — a clean merge would have reverted all of S257b's work. `git merge origin/main
- --no-edit` was conflict-free (docs were disjoint) and the diff then showed only the
- 7 S258 files. PR opened safely.
+ ## Example 1 — RETRACTED: this was the two-dot false alarm, not the trap
- ## Example 2 — the variant, and it cost four sessions (DoodleRun, 2026-08-07)
+ *Published in 1.0.0–1.2.0 as a worked case. It was not one. Corrected in 1.3.0;
+ kept here because the artifact is common and worth recognising.*
+ Branch `docs/s258-prompt-update` (this repo, S258, 2026-06-17) was cut before
+ S257b's anomaly-alignment docs merged to main. `git diff --stat
+ origin/main..HEAD` — **two dots** — showed 5 unrelated files being "deleted"
+ (−384/−93/−65/−40/−19 lines: the anomaly HTML, a content snapshot, two S257b
+ handoffs, a legend edit).
+
+ **Nothing was ever at risk.** The branch never had those files and never
+ recorded deleting them; three-dot would have reported no deletions and the
+ three-way merge would have preserved every one of them. The original entry
+ concluded *"a clean merge would have reverted all of S257b's work"* — **that
+ claim was false**, and it is retracted. `git merge origin/main --no-edit` did
+ run and was conflict-free, but what it fixed was the diff display, not a
+ pending revert.
+
+ The lesson that survives: a two-dot stat full of red on files you never touched
+ is the *expected* output of the wrong command. Re-run it with three dots before
+ you rebase, block, or escalate anything.
+
+ ## Example 2 — REAL, and it cost four sessions (DoodleRun, 2026-08-07)
+
PR #853 (`claude/floor500-rulings`, squash `6c79ff26`) shipped three ticked
owner rulings **and deleted 11 files plus 15 hand-maintained ledger entries**
belonging to four other sessions — 59 files, 5,081 deletions.
+ **These deletions were genuine, not a two-dot artifact.** The branch's parent
+ *was* main's tip, so the merge base was that same commit and the two-dot and
+ three-dot diffs were identical — the 5,081 deletions are what GitHub merged.
+
**Its parent was the newest commit on main.** `git log` showed it directly on
top; nothing was behind. The tree, however, was ~8 hours old: the branch tip
differed from its own parent by **59** files but from an 8-hour-old commit by
only **39**. Everything merged in that window was recorded as a deletion.
**Nothing gated it.** The PR was **created at 09:30:12Z and merged at
09:30:23Z — eleven seconds** — so no reviewer and no CI run ever saw the diff.
**It was found 3½ hours later, by accident**, during an unrelated wrap-up check
that happened to re-read the ledger. Two follow-up PRs restored some casualties;
one session's four files and six ledger entries were still missing hours after
that, because that session had already finished and nobody was coming back.
**The two habits that would have caught it**, in order of cost: run
`git diff --diff-filter=D` before merging, and — in a solo repo where no human
reviews — re-verify your own merged work still exists on main *after* the last
sibling merge, not at the moment you write the ledger entry.
## Notes
- - The tell is **deletions in the diff stat**, not a conflict marker — conflict-only
- habits (rely on git to yell) miss this entirely.
+ - The tell is **deletions under a THREE-dot diff**, not a conflict marker —
+ conflict-only habits (rely on git to yell) miss this entirely, and two-dot
+ habits raise the alarm on branches where nothing is wrong.
- Same root cause as `large-redesign-parallel-branch-collision-audit` (branches
drift from main), but that skill is a *pre-plan* collision audit across many
- branches; this is the *at-PR-time* per-branch check + the merge-main-in fix.
+ branches; this is the *at-PR-time* per-branch deletion check.
- `gh pr merge --delete-branch` may then fail with "main is already used by
worktree at ..." when you run from a worktree — the merge still succeeded;
delete the remote ref with `gh api -X DELETE repos/<owner>/<repo>/git/refs/heads/<branch>`.
+
+ ## See also
+
+ - **`git-diff-2dot-vs-3dot-merge-safety` — the canonical statement of the
+ operator rule this skill depends on.** Read it first when the deletions you
+ are looking at came out of a two-dot diff: `A..B` compares tip to tip and
+ invents deletions; `A...B` measures from the merge base and is what GitHub
+ shows. Versions 1.0.0–1.2.0 of this skill contradicted it without citing it.
+ - `worktree-stale-local-main-ref-inflates-pr-diff` — the other direction: a
+ correct three-dot diff still over-reports when the LOCAL `main` ref is stale,
+ because the merge base is taken against an ancient ref (one case: 92 files /
+ 8,381 insertions for a 6-file commit). Fetch first; name `origin/main`, not
+ `main`. Its `merge-base --is-ancestor` check is necessary but not sufficient —
+ the stale-tree variant above satisfies it and still deletes files.
+ - `stale-base-pr-silently-reverts-upstream-content` — the line-level sibling
+ (your hunks overwrite a merged upstream edit inside a file both sides touched)
+ rather than this whole-file case.
+ - `pr-conflict-from-mid-flight-merges` / `merge-conflict-generated-files` — where
+ to go once git does raise a conflict.