fix(git-safety-net): enforce single-writer SHA handoff (#437)

This commit is contained in:
daymade
2026-09-02 01:15:08 +08:00
committed by GitHub
parent 1b7f7deed3
commit bfffd24818
6 changed files with 291 additions and 107 deletions
+1 -1
View File
@@ -491,7 +491,7 @@
"description": "Audits, preserves, recovers, and safely retires local Git state: unpushed or wrong-branch commits, dirty or detached worktrees, forgotten duplicate clones of the same repo, untracked work no bundle can back up, orphaned stashes, dangling commits, stale branches, and squash/rebase merge uncertainty. Use when the user fears work was lost; asks to recover a commit or branch; asks whether a worktree, clone, or scratch directory can be deleted; wants everything converged onto one main branch; or needs proof that cleanup will not drop work. Use it even after an audit reported clean — the usual gap is scope: every in-repo command is blind to a second clone elsewhere on disk. Triggers on \"did I lose work\", \"is everything merged\", \"is anything else lost\", \"safe to delete this clone\", \"clean up old branches/stashes\", \"only keep one main branch\", \"git reflog\", \"dangling commits\", \"分支灾难\", \"误删分支/commit\", \"worktree 能删吗\", \"还有没有丢的东西\", \"只保留一个主分支\". Covers local-Git forensics, not GitHub PR/API operations or routine sync.",
"source": "./git-safety-net",
"strict": false,
"version": "1.13.0",
"version": "1.14.0",
"category": "developer-tools",
"keywords": [
"git",
+15
View File
@@ -48,6 +48,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
an order-inverted stitched quote — both fixed and re-verified pre-ship.
### Fixed
- **git-safety-net** v1.13.0 → v1.14.0: replace the over-broad “one worktree per
concurrent session” prescription with an authority-first, single-writer shared-checkout
contract. Mode D now treats worktrees as explicitly authorized named exceptions, preserves
narrow higher-authority stash contracts instead of declaring a universal ban, stops every
repository mutation while another writer is active, freezes handoffs and merges by exact
local/remote SHA, makes completion an AND gate over session-owned bytes,
remote containment, and attributed residuals, and keeps cleanup in separately authorized
Mode E. The prevention reference now counts any scheduled job that can write the checkout as a
writer even when paths are disjoint: an idle process snapshot is not a lock, so existing
coordination must quiesce it and transfer exclusive ownership before Git mutation; stopping,
leasing, or rescheduling automation remains a separate design decision. The reachable Mode E
convergence workflow now applies the same gate to alternate-index commits, ref/bundle writes,
push/PR, and fetch; parallel verification agents receive frozen SHAs and never move
remote-tracking refs. Bundle-relative helpers are no longer presented as commands assumed to
exist on `PATH`.
- **peer-message** v1.1.0 → v1.1.1: stop Claude receiver-evidence verification from collapsing unreadable transcripts or malformed matching JSONL into ordinary `unverified`. The verifier now keeps scanning other candidates and later lines for valid enqueue evidence, but fails loudly if no valid match exists and any evidence read/parse error occurred; a clean, fully readable miss remains `unverified`. Four deterministic regression cases cover read failure, malformed matching JSON, later valid evidence, and the healthy-miss control.
- **peer-message** v1.0.0 → v1.0.1: make documentation ownership executable instead of duplicative. `SKILL.md` keeps only routing, stable prerequisites, safety, and owner pointers; `peer.py --help` owns CLI syntax; the protocol reference owns addressing, envelopes, receipt, exit, transport, and verification semantics; the official-feature reference owns volatile product interfaces and inbound mechanics. README/README.zh-CN and `CLAUDE.md` point to those owners, while the changelog and private review stop persisting derived test, session, reachability, and file totals.
- **prior-work-retrieval / claude-switch-models-setup** (daymade-claude-code v3.7.9 → v3.7.13): remove `uv run` from the synchronous Claude/Codex prior-work hook entrypoint and require the profile-convergence SessionStart hook to use an absolute direct-Python command. The prior-work wrapper now also fails closed when that runtime is missing or a relative override is supplied, rather than falling back to PATH `python3`. Repository and Skill contracts distinguish package-manager-free hook launch from explicit `uv` retrieval, validation, and test lifecycles. Shared UV cache cleanup or lock contention can no longer stall every PreToolUse decision or prevent profile repair.
+2 -2
View File
@@ -1,4 +1,4 @@
Security scan passed
Scanned at: 2026-08-30T16:16:34.478783+00:00
Scanned at: 2026-09-01T17:08:37.265263+00:00
Tool: gitleaks + pattern-based validation
Content hash: 2ea4e41633c9c742214641d281c779b59c8f6a51433de58845394b96d073df49
Content hash: 6f1f1aa202f59021bfa7cf1415aab6b57dcff8434b393e6415da9c8bee52e93a
+77 -55
View File
@@ -264,15 +264,19 @@ scripts/git_verify_branch_merged.sh <branch> [<base>] # base defaults to origi
This mode is the one direction where a stale base is *unsafe* (rule 1): judged against yesterday's
`origin/main`, a branch whose content landed hours ago still reads UNMERGED, and "rescuing" it
re-applies an older version over whatever was built on top. The script fetches first for exactly
that reason — but if the fetch fails it falls back to cached refs and says so **on stderr only**.
that reason. Because fetch moves remote-tracking refs, run it only after existing coordination has
quiesced every checkout writer and transferred exclusive ownership. If that cannot happen, stay
read-only and report that the merge verdict is unavailable. If the fetch itself fails after
ownership transfer, the script falls back to cached refs and says so **on stderr only**.
Treat that line as a blocker, not a footnote: rerun once the network is back before acting on the
verdict. Comparing by hand (`git diff origin/main <branch>`, `git log origin/main..<branch>`) has
no such safety net at all — fetch yourself first, every time.
no such safety net at all — the sole writer must refresh authority first.
It reports **MERGED (ancestor)** or **MERGED (content contained)** — safe to delete — versus
It reports **MERGED (ancestor)** or **MERGED (content contained)** — content-safe for a separately
authorized Mode E deletion gate — versus
**UNMERGED / NEEDS REVIEW**, listing the files the branch would still change. The verdict is sound,
not heuristic: it does a trial 3-way merge of the branch *into* the base with `git merge-tree`
(in memory, no checkout) and only says "safe to delete" when that merge changes nothing — so a
(in memory, no checkout) and only reports content containment when that merge changes nothing — so a
squash-merged branch reads MERGED despite a nonzero commit count, while a revert/edit/new-file the
base lacks reads UNMERGED. It is **safety-biased**: anything it can't prove contained is reported
for review, because a false "merged" loses work while a false "unmerged" only costs a look. Full
@@ -286,52 +290,61 @@ re-checked): **[references/merge_verification.md](references/merge_verification.
The habits that keep a branch tangle from ever stranding work:
**[references/prevention_practices.md](references/prevention_practices.md)**. The load-bearing few:
- **Commit before you switch — neither `git stash` nor `git worktree`.** Uncommitted work is what
gets stranded: a `git stash` you later can't find, or edits a `switch` buries. Commit each line
of work to its own branch and push it early (a committed, pushed branch can't be orphaned), then
bring it where you need it *live* by merging — not by stashing, and not by spinning up a second
`git worktree` checkout (which is one more place to forget work and won't even have your
gitignored deps). A shared working tree with commit-then-switch discipline is the safe default.
- **If you truly need a second checkout, make it a worktree — never a second `git clone`.** Both
are extra places to forget work, which is why commit-then-switch above is still the default. But
the failure modes are not equal: a linked worktree announces itself in `git worktree list`, so
every audit finds it, while an independent clone is invisible to every command run from the
original repository. Choosing `clone` for a few days of parallel work quietly opts out of all
the safety tooling. When a clone already exists (a colleague made it, a script made it, you
inherited it), register it somewhere the team actually reads and retire it the day it's done —
and until then, treat it as an audit target in its own right, not as a scratch directory.
- **Push a work-in-progress branch to a remote early.** The one commit only on a local branch is
the only commit that a dead laptop actually loses.
- **Read the current collaboration contract before prescribing topology.** An explicit user or
project decision about shared checkouts, worktrees, branches, or contribution flow outranks this
generic guidance. Do not turn one messy audit into a permanent "one worktree per session" rule.
- **One physical checkout gets one writer; parallel agents and sessions stay read-only.** A topic
branch inside the same checkout does not isolate the shared working files, current branch, or
index. Writer ownership comes from the repository's task/coordination contract, not a guessed
file list. If ownership is unclear or another writer is active, do not mutate the checkout.
- **Commit before switching and push WIP early.** Prefer a remote-backed commit over stash
juggling, but preserve a higher-authority narrow stash exception; never use an unscoped stash
to make a dirty checkout look ready.
- **Worktrees are explicitly authorized, named exceptions — not the standing default.** They
isolate working files, `HEAD`, and index but still share refs, stashes, object storage, config,
and hooks, and do not copy ignored dependencies. When approved, a linked worktree is safer than
an invisible independent clone but remains a separately audited retirement target.
- **Handoff and merge by exact commit, then finish with an AND gate.** Record branch, local `HEAD`,
and fresh remote tip; require them to equal the handoff SHA. Direct merges name that SHA, not the
branch. Hosted merges use an expected-head-SHA precondition when available, or an immediately
preceding hosted head readback that must still equal the handoff SHA. Every session-owned byte
must be in that remote-backed commit, and every residual path must be enumerated and attributed.
- **A process snapshot is not a lock, and a merge is not cleanup authority.** Any scheduler that
can write this checkout counts as a writer even when its paths are disjoint. Before Git mutation,
the project's existing coordination must prove it quiescent and transfer exclusive ownership;
without that mechanism, stay read-only and report the gap. Do not stop, reconfigure, or invent a
lease for automation under this generic Skill. Retire refs or checkouts only through separately
authorized Mode E evidence.
- **Confirm the current branch before committing** (`git branch --show-current`) — a fix committed
onto the wrong feature branch is invisible to its real PR and easy to lose on cleanup.
- **In a shared tree, never aim a destructive command at "the current branch" — name the branch
explicitly.** `reset --hard`, `merge`, and `rebase` all act on *whatever is checked out at the
instant they run*, so a branch check is stale the moment it returns: a parallel session can
`switch` in between, and your command lands on **their** branch. This is the inverse of the
bullet below (that one protects *your* work from *their* switch; this one protects *theirs*
from *your* command), and re-checking harder does not fix it — the race is inherent. Use the
checkout-independent forms instead, which name their target and never touch the working tree:
- **Never race another writer with checkout-relative mutation.** If another writer is active, stop
until the repository's coordination system transfers exclusive write ownership. After transfer,
name the exact ref and object when repairing or advancing state; do not rely on whichever branch
happens to be checked out. `reset --hard`, `merge`, and `rebase` all act on *whatever is checked
out at the instant they run*. Use checkout-independent forms for ref repair when they match the
authorized outcome:
```bash
git branch -f <branch> <target> # instead of: switch <branch> && reset --hard <target>
git fetch origin <branch>:<branch> # fast-forward a branch you are not on
git push origin <sha>:refs/heads/<branch>
```
Real incident: a `reset --hard origin/main` issued seconds after `git branch --show-current`
said `main` landed on a parallel session's feature branch and moved it back two commits; the
follow-up "repair" then missed *again* because the tree had been switched a second time.
`git branch -f` fixed both in one shot precisely because it never consults the checkout.
- **If a parallel session switched the shared tree onto its branch** and stranded your uncommitted
work there, don't commit onto their branch — carry your edits to a branch off the base
(`git checkout origin/main -b …`, after `git diff --quiet` proves your files match across bases),
commit only your explicit paths, then switch the tree back to their branch to restore their state.
- **If a parallel session is *actively* writing the shared tree** — files keep appearing while you
work — don't `switch`, `add`, or `reset` at all: each would either strand their uncommitted work
or trip a worktree guard. When your own change is self-contained (new files, or edits that belong
on `origin/main` rather than on their in-progress tree), build the commit with plumbing that never
touches the working tree, then push it to a branch and open a PR. Freeze every candidate as the
exact Git entry tuple `(mode, object ID, path)` — bytes alone are insufficient because `100755`,
`120000`, and `160000` carry executable, symlink, and gitlink behavior. The safest source is an
immutable candidate commit:
Real incident: a `reset --hard origin/main` issued while another session still owned the checkout
landed on that session's feature branch and moved it back two commits. The correct first action is
to stop and transfer ownership; once transferred, an explicitly targeted ref repair avoids making
checkout position part of the operation.
- **If a parallel session previously switched the shared tree and stranded your uncommitted work,**
do not mutate it until that session is quiescent and exclusive ownership has transferred. Then
follow the incident-only relocation procedure in the prevention reference: prove your files match
across bases, commit only explicit paths, and restore the prior branch before handing ownership
back. Branch deletion remains a separately authorized Mode E action.
- **If a parallel session is *actively* writing the shared tree, all repository mutation stops.**
Do not `switch`, `add`, `reset`, create commits with a temporary index, update refs, or push. Use
the repository's coordination system to quiesce that writer and transfer exclusive ownership; if
none exists, report the gap and preserve the current evidence. Once you are the sole writer, an
object-store-only commit can keep attributable foreign WIP out of the shared index and working
tree. Freeze every candidate as the exact Git entry tuple `(mode, object ID, path)` — bytes alone
are insufficient because `100755`, `120000`, and `160000` carry executable, symlink, and gitlink
behavior. The safest source is an immutable candidate commit:
```bash
candidate_ref=<immutable-candidate-commit-oid>
candidate_path=path/to/file
@@ -340,7 +353,7 @@ The habits that keep a branch tangle from ever stranding work:
candidate_oid=$(printf '%s\n' "$candidate_entry" | awk 'NR == 1 { print $3 }')
test -n "$candidate_mode" && test -n "$candidate_oid" || exit 1
candidate_index=$(mktemp /tmp/tinkle_git_index.XXXXXX)
candidate_index=$(mktemp /tmp/git_safety_candidate_index.XXXXXX)
export GIT_INDEX_FILE="$candidate_index" # the tree's real index is untouched
git read-tree origin/main # start from the pushed base, not the dirty tree
git update-index --add --cacheinfo "$candidate_mode,$candidate_oid,$candidate_path"
@@ -355,15 +368,17 @@ The habits that keep a branch tangle from ever stranding work:
applying that route to a symlink or submodule. For those entry types, first freeze an immutable
candidate commit and copy its mode/object tuple as above. Never source an entry from a shared path
that another session is editing. The sequence reads and writes only the object store and a
throwaway index, so `git status` in the shared tree is byte-for-byte unchanged. `commit-tree`
throwaway index, so `git status` in the shared tree is byte-for-byte unchanged. It is a
sole-writer preservation technique, not permission to mutate while someone else owns the repo.
`commit-tree`
does not run the normal `git commit` hook path: execute the repository's exact pre-commit/security
gates against the candidate before push, and still let pre-push run. This is the escape hatch for
when commit-then-switch is off the table because someone else holds the tree.
gates against the candidate before push, and still let pre-push run. Use it only after ownership
transfer, when preserved foreign WIP makes checkout switching or shared-index staging unsuitable.
- **A bare `git commit` snapshots the *whole* index, not just what you staged — and a commit that
bypassed the index leaves a trap in it.** Moving the **current** branch without updating the
shared index advances HEAD while the index stays on its old baseline — via `commit-tree` +
`update-ref` on that branch, or a `git commit` through a temporary `GIT_INDEX_FILE`. (The
push-to-another-branch escape hatch above moves no *local* ref, so it leaves no drift.) Every
sole-writer push-to-another-branch path above moves no *local* ref, so it leaves no drift.) Every
file the new commit introduced then shows as a *staged deletion* (`git status` prints `D `
lines plus matching `??` untracked entries). `git commit -- <path>` neither creates nor repairs
this drift — it only updates its own paths. The drift detonates on anyone's next bare
@@ -447,9 +462,11 @@ one branch into a repo export.
For a multi-branch "only one main" cleanup while other sessions may still commit or open PRs, read
**[references/merge_verification.md](references/merge_verification.md)** § Converging many branches
to one main under active concurrency before Step 3. It adds the moving-ref inventory, dirty-WIP
preservation, immutable-candidate, duplicate-PR, and final branch-count gates that a single-branch
retirement does not need.
to one main through single-writer windows before Step 3. While another writer is active, that route
is read-only: fetch, object/ref creation, bundle export, push/PR, and deletion wait for existing
coordination to prove quiescence and transfer exclusive ownership through final readback. The
reference adds the moving-ref inventory, dirty-WIP preservation, immutable-candidate, duplicate-PR,
and final branch-count gates that a single-branch retirement does not need.
**Step 3 — destroy, in the safe order:**
@@ -521,6 +538,10 @@ in place; the bundle restores full history via `git fetch <file>.bundle <branch>
## Scripts (execute these; they are non-destructive unless noted)
Every `scripts/...` path below is relative to this Skill's bundle root, not a command promised on
`PATH` or in the target repository. Resolve the loaded Skill directory and invoke the bundled path;
never tell a user to run bare `git_verify_branch_merged.sh` unless `command -v` actually finds it.
| Script | Does | Mutates? |
|---|---|---|
| `scripts/git_find_all_checkouts.sh [root ...]` | Find every checkout of this repo on the machine — including independent clones invisible to `git worktree list` — and flag uncommitted/untracked/unpushed work, remote-cache age, and borrowed alternates object stores | Nothing (read-only, no fetch) |
@@ -534,10 +555,11 @@ in place; the bundle restores full history via `git fetch <file>.bundle <branch>
All six run from the repository root. They use read-only enumeration/configuration commands such as
`find`, `config`, `symbolic-ref`, `submodule status`, `status`, `cat-file`, `rev-list`, `rev-parse`,
`fsck`, `for-each-ref`, and `remote get-url`; plus scoped `fetch`, `archive`, `bundle create/verify`,
metadata hashing/archive, and (preserve only) `update-ref` where each script's table row says so — never
`checkout`, `reset`, `push`, `stash drop`, `branch -d`, or `gc`, so they are safe to run in a
dirty tree or alongside other agents. `git_find_all_checkouts.sh` additionally never fetches, so
it works offline and behind a proxy.
metadata hashing/archive, and (preserve only) `update-ref` where each script's table row says so.
Only `git_find_all_checkouts.sh` is repository-read-only and safe beside read-only agents; it never
fetches, so it also works offline and behind a proxy. Any helper that fetches, writes backup state,
or adds refs runs only after existing coordination transfers exclusive writer ownership. None of
the helpers authorizes `checkout`, `reset`, `push`, `stash drop`, `branch -d`, or `gc`.
## Troubleshooting
+46 -28
View File
@@ -7,7 +7,7 @@
- Pick the diff FORM from the question you're asking (two-dot vs three-dot)
- Why safety-biased: a false "merged" loses work, a false "unmerged" only costs a look
- Manual-only investigation hints (do NOT auto-decide on these)
- Converging many branches to one main under active concurrency
- Converging many branches to one main through single-writer windows
- Independent clone retirement — preserve refs, metadata, and borrowed objects
- Worktree retirement — prove the checkout is disposable before removal
- Adversarial multi-agent verification (for a whole repo of branches)
@@ -75,9 +75,10 @@ base's parallel work is the point. It is the wrong form for "what does the base
For each branch, the script decides among three outcomes:
- **MERGED (ancestor)** — in the base's history. Delete freely.
- **MERGED (ancestor)** — in the base's history. Content containment is proven; deletion still
requires the separately authorized Mode E target, current ref equality, and preservation gate.
- **MERGED (content contained)** — a trial merge into the base changes nothing; the "commits
ahead" count is a squash/rebase artifact. Delete freely.
ahead" count is a squash/rebase artifact. This proves content containment, not deletion authority.
- **UNMERGED / NEEDS REVIEW** — a trial merge *would* change the base, so the branch carries
content the base does not already have (a genuinely new/edited/reverted/deleted file). Review
the listed contribution before deleting.
@@ -214,12 +215,15 @@ most likely to be restored on instinct.
Same safety bias as everywhere else in this skill: prove supersession per item, or keep the item.
## Converging many branches to one main under active concurrency
## Converging many branches to one main through single-writer windows
Use this READ-DO sequence when the outcome is not one deletion but a repository-wide convergence:
keep every unique behavior, preserve current WIP, and leave exactly one maintained `main`. A branch
list is a moving snapshot while other sessions are alive, so the start-of-task audit cannot double
as the deletion gate.
as the deletion gate. Another active writer makes this sequence read-only: do not fetch, create
objects, update refs, export bundles, push/open PRs, or delete until the repository's existing
coordination proves quiescence and transfers exclusive writer ownership. Hold that ownership
through commit and final readback. If it is lost, stop and restart from a fresh authority snapshot.
The executing agent owns only the explicitly authorized slice of this sequence, not every object
the inventory reveals. `--verify-current` mechanically decides only whether exact ref tips stayed
@@ -239,9 +243,14 @@ Before interpreting the inventory, partition objects into three sets:
Generic phrases such as "take over", "continue", or "finish this" do not move an object between
sets. The user must name the additional object or otherwise make the expansion unambiguous.
Record the exact local and remote-tracking refs, then query the hosting service for its current
branch list and PR heads. Keep the two inventories separate: remote-tracking refs are a Git cache;
the hosting API is authority for branches that exist on the server. Record every exact tip SHA.
Before spawning reviewers or interpreting refs, the sole writer performs one authority refresh,
including any required fetch, then freezes the exact local and remote-tracking refs and queries the
hosting service for its current branch list and PR heads. Keep the two inventories separate:
remote-tracking refs are a Git cache; the hosting API is authority for branches that exist on the
server. Record every exact tip SHA and give reviewers those immutable SHAs. If another writer is
active and ownership cannot transfer, hosting/API and existing immutable-object reads may inventory
what is already known, but the inability to refresh authority is a reported gap, not permission to
fetch concurrently.
Classify each change-authorized non-main ref by content. Use the trial-merge verdict first. For
NEEDS REVIEW refs, walk the supersession ladder above and open distinctive code/tests at authority.
@@ -250,17 +259,19 @@ Merge or adapt the smallest unique behavior; never merge an old whole branch mer
many `+` commits or a compelling name. Report inspect-only and excluded refs separately without
turning their existence into an action item.
### 2. Build keeper commits without touching a shared writer
### 2. Build keeper commits after exclusive ownership transfers
When another session is actively changing files, do not switch the shared checkout or use its real
index. Build from the freshly fetched base with Mode D's alternate-index plumbing. Preserve each
candidate as an exact `(mode, object ID, path)` tuple from an immutable commit; copying only blob
bytes can silently strip executable (`100755`), symlink (`120000`), or gitlink (`160000`) behavior.
An owned temporary regular file may be hashed only after its intended `100644`/`100755` mode is
verified explicitly; symlinks and submodules must use the immutable-entry route. Never hash the
shared worktree, which can silently capture someone else's in-progress bytes. Run the candidate's
When another session or scheduler is actively changing files, do not switch, use the real index,
create objects with an alternate index, update refs, or publish a PR. Wait for the existing
coordination mechanism to quiesce that writer and transfer exclusive ownership. Once transferred,
build from the freshly fetched frozen base with Mode D's sole-writer alternate-index technique.
Preserve each candidate as an exact `(mode, object ID, path)` tuple from an immutable commit;
copying only blob bytes can silently strip executable (`100755`), symlink (`120000`), or gitlink
(`160000`) behavior. An owned temporary regular file may be hashed only after its intended
`100644`/`100755` mode is verified explicitly; symlinks and submodules must use the immutable-entry
route. Never hash a shared-worktree path attributed to someone else's WIP. Run the candidate's
deterministic tests and the exact hook/security gates that a normal commit would have run before
opening the PR.
opening the PR, and retain exclusive ownership through push and final readback.
After a squash merge, do not compare commit SHAs: GitHub creates a new base-branch commit. If the
base did not otherwise move, equal tree IDs prove byte-identical landing. If it did move, compare
@@ -269,6 +280,10 @@ GitHub-side duplicate/superseded PR handling belongs to the `github-ops` skill.
### 3. Preserve refs and dirty WIP through different channels
This section creates refs, objects, external backups, or hosted state. Run it only while the
exclusive writer window from Step 1 remains valid. If another writer resumes, stop before the next
mutation; the frozen evidence remains useful, but it does not authorize continuing.
Create a repository-external bundle containing only the branches/refs whose deletion is authorized,
then verify it. The bundle is the ref manifest: immediately before deletion run:
@@ -305,14 +320,15 @@ with checkout materialization. Move that exact path to the verified external bac
clean base, then restore the saved bytes; it should naturally become a tracked modification. Never
drop it because "main now has a file with that name."
If another writer is still active, leave the real HEAD/index/worktree alone and postpone local
branch convergence. Publishing an isolated PR is safe; switching the shared checkout is not. When
an exclusive window exists, update only paths proven clean, or restore the complete verified WIP
set after materializing the new base.
If another writer is active, leave the real HEAD/index/worktree, object store, refs, and hosted
branches unchanged and postpone both local convergence and PR publication. When the existing
coordination restores an exclusive window, restart at Step 1, update only paths proven clean, or
restore the complete verified WIP set after materializing the new base.
### 4. Re-freeze immediately before deletion
Fetch again, re-query hosting branches/PRs, and re-enumerate local refs. Compare the result with the
While retaining exclusive writer ownership, fetch again, re-query hosting branches/PRs, and
re-enumerate local refs. Compare the result with the
bundle heads. A new branch, changed tip, or late PR inside the change-authorized set is new evidence:
stop, classify its unique behavior, and rebuild the bundle. A newly discovered collaborator object
does not silently join that set; record it as inspect-only or excluded and rebuild only if the next
@@ -516,6 +532,7 @@ high-stakes "is *everything* merged?" verdict, fan out:
never by commit count. Report per branch: MERGED / **UNMERGED / NEEDS REVIEW (with the file(s)
the trial merge would change)**."
3. **Lock them read-only** (see rules below) so concurrent agents don't corrupt each other's tree.
The sole writer refreshes authority first and hands them frozen SHAs; reviewers never fetch.
4. **Counter-review every finding yourself.** An agent's "UNMERGED" is a *hypothesis*: re-run the
trial-merge / inspect the specific files before believing it (agents produce false positives
too). An agent's "all merged" is only as good as its method — spot-check that it judged by
@@ -530,12 +547,13 @@ a subagent cannot spawn subagents.
Put these in every agent's prompt — they are what make parallel verification safe and correct:
- **Read-only, always.** Only `fetch --quiet`, `merge-base`, `merge-tree`, `diff`, `log`, `show`,
`cat-file`, `rev-list`, `rev-parse`, `ls-tree`, `for-each-ref`, `branch -r --contains`. **Never**
`checkout`, `switch`, `reset`, `rebase`, `commit`, `push`, `update-ref`, or `gc`. Multiple agents
share one working tree; a single `checkout` corrupts everyone else's run.
- **Explicit refs only** (`origin/main`, `origin/<branch>`) so nothing depends on the current
checkout.
- **Read-only, always.** Only `merge-base`, `merge-tree`, `diff`, `log`, `show`, `cat-file`,
`rev-list`, `rev-parse`, `ls-tree`, `for-each-ref`, `branch -r --contains`. **Never** `fetch`,
`checkout`, `switch`, `reset`, `rebase`, `commit`, `push`, `update-ref`, or `gc`. The sole writer
refreshes once before fan-out and supplies immutable SHAs; a reviewer does not move even a
remote-tracking ref.
- **Explicit frozen SHAs first.** Use the supplied immutable object IDs for conclusions. Named refs
(`origin/main`, `origin/<branch>`) may be displayed for attribution but are not merge authority.
- **Judge by content via the trial merge, not by counts** — restate the check in the prompt.
- **Return structured per-branch verdicts with the file(s) the trial merge would change for any
UNMERGED**, not prose.
+150 -21
View File
@@ -4,10 +4,14 @@ Each practice below maps to a specific way work actually gets lost. They are che
ceremony; adopt the ones whose failure mode you're exposed to.
## Contents
- Parallel / multi-branch work: commit before you switch (not stash, not worktree)
- Choose topology from current authority
- Shared checkout and concurrent sessions: one writer
- Exact-SHA handoff and scoped completion
- Known automated writers are not session-owned WIP
- Parallel / multi-branch work: commit before switching; exceptions follow current authority
- Push work-in-progress branches early
- Confirm the branch before every commit
- Relocate your work when a parallel session switched the shared tree under you
- Recover stranded work after a parallel session switched the shared tree
- Audit before rebase / branch-delete
- Audit every authorized worktree before retirement
- Snapshot before any history rewrite
@@ -15,7 +19,117 @@ ceremony; adopt the ones whose failure mode you're exposed to.
- Commit-scope hygiene (don't sweep unrelated staged work)
- Set a wider reflog safety window once
## Parallel / multi-branch work: commit before you switch (not stash, not worktree)
## Choose topology from current authority
Before recommending branches, worktrees, or a second checkout, read the current user and project
instructions. Their explicit collaboration and contribution model wins. A generic post-incident
lesson cannot silently replace a user's existing no-worktree decision, a repository's PR-only
flow, or a task registry's single-writer rule.
Default here: keep one maintained checkout and commit before switching. Do **not** turn worktrees
into a standing rule for every concurrent session. When the current authority explicitly approves
a named second checkout and simultaneous writing truly requires it, prefer a linked worktree over
an independent clone because audits can discover it. That exception does not make it independent:
linked worktrees separate working files, `HEAD`, and index, but share refs, stashes, object storage,
config, and hooks; ignored dependencies and local-only assets are not copied.
## Shared checkout and concurrent sessions: one writer
**Failure mode:** separate sessions edit different files and assume they are independent, but they
share the checkout's current branch and index. One session can switch the branch under another;
one bare commit snapshots every staged entry, including another session's work or a phantom `D`
left by an index-bypassing commit. File ownership alone cannot close that race.
**Prevention:** one physical checkout has one writer. Parallel agents or sibling sessions may do
read-only investigation, but they do not mutate files, refs, index, stash, or working-tree state.
Writer ownership comes from the repository's existing task/coordination system. If that authority
is unavailable, another writer is active, or foreign dirty paths cannot be attributed, stop the
write path; do not create a branch, stash the tree, or "just stage your files" as a workaround.
When you hold write ownership, keep the stage-to-commit interval bounded and inspect the complete
index, not merely the paths you just added:
```bash
git -C <absolute-repo> branch --show-current
git -C <absolute-repo> add -- <exact-path-1> <exact-path-2>
git -C <absolute-repo> diff --cached --name-status
git -C <absolute-repo> diff --cached --stat
git -C <absolute-repo> commit -m "<message>"
```
Every staged status letter must match the intended change. In particular, an unfamiliar `D` is a
stop signal, not a harmless leftover. Follow the repository's contribution policy for push/PR;
this prevention Skill does not widen push or merge authority.
Prefer a WIP commit and early remote copy to stash juggling. Do not rewrite an existing narrow
stash exception as an absolute prohibition: if the current contract permits it, only the single
writer may use its exact absolute-repository and explicit-file form, such as
`git -C <absolute-repo> stash push [options] -- <exact-file>...`. An unscoped stash remains invalid.
## Exact-SHA handoff and scoped completion
A branch name is a routing label, not a frozen deliverable. The writer can add another commit after
handoff, and linked worktrees share that ref. A later `merge <topic>` may therefore merge bytes the
integrator never reviewed.
Freeze and read back the handoff:
```bash
topic_branch=$(git -C <absolute-repo> branch --show-current)
topic_sha=$(git -C <absolute-repo> rev-parse HEAD)
remote_sha=$(git -C <absolute-repo> ls-remote origin "refs/heads/$topic_branch" | awk 'NR == 1 {print $1}')
test -n "$remote_sha" && test "$topic_sha" = "$remote_sha"
```
Report the absolute checkout path, branch, and `topic_sha`. Immediately before the merge, the
integrator must re-read the intended remote tip and require it to equal `topic_sha`. A direct merge
names the frozen object, not the branch:
```bash
current_remote_sha=$(git -C <absolute-repo> ls-remote origin "refs/heads/$topic_branch" | awk 'NR == 1 {print $1}')
test -n "$current_remote_sha" && test "$current_remote_sha" = "$topic_sha"
git -C <absolute-repo> merge "$topic_sha"
```
For a hosted PR, use the platform's expected-head-SHA precondition when it exists. Otherwise make a
fresh hosted head-SHA readback the immediately preceding step and abort instead of merging if it no
longer equals `topic_sha`. A moved ref means "handoff expired," not "take whatever is newest."
Completion requires all of these, never an OR between them:
1. every session-owned tracked and untracked byte is present in the handed-off commit;
2. the exact commit is present on the intended remote, proven by independent readback;
3. every residual path in the checkout is enumerated and attributed;
4. no branch, stash, worktree, or remote ref is deleted as an implicit completion step.
The claim is intentionally scoped. A checkout can remain dirty because an authorized automation
writer or another named owner left unrelated paths. That does not make the session incomplete, but
it forbids claiming the **whole repository** is clean or sweeping the residuals into this commit.
Retirement is a separate Mode E task with new authority and fresh evidence.
## Known automated writers are not session-owned WIP
**Failure mode:** an integration session checks that a scheduled writer is idle, then treats the
next few commands as exclusive. The job starts after the check and writes during stage, merge, or
final verification. A process snapshot is an observation, not a lock.
Do not stop, disable, or reconfigure an authorized job merely to make the generic Git routine easy;
that is a new operations decision. A scheduler that can write anywhere in this physical checkout is
still a writer even when its usual paths are disjoint from the current task. Before any Git mutation,
the project's existing coordination mechanism must prove it quiescent and transfer exclusive writer
ownership through commit and final readback. A process snapshot alone cannot do that. Then:
- read the project's owner contract for the generated paths;
- do not manually edit generated files or co-stage them with an unrelated task;
- when the current task explicitly owns one generated batch and exclusive ownership has transferred,
stage only its exact paths, complete the Git operation, then re-read the working tree after handing
ownership back because the next batch may already have arrived;
- if no existing mechanism can prove quiescence and transfer ownership, stay read-only, enumerate
the residual paths, and report the gap rather than treating disjoint paths or an idle check as
mutual exclusion;
- treat a new lock, lease, pause, or schedule change as a separately authorized design.
## Parallel / multi-branch work: commit before switching; exceptions follow current authority
**Failure mode:** the classic disaster is `git stash` → switch branch → work → `git stash` again →
rebase → switch back. Each `stash` that gets superseded or dropped orphans a commit; after a busy
@@ -30,27 +144,32 @@ stashed away or stranded:
```bash
# instead of `git stash` before switching:
git switch -c <branch-for-this-work> # a branch for this line of work
git add <the paths for THIS work> && git commit -m "wip: ..."
git add -- <the paths for THIS work>
git diff --cached --name-status # inspect the whole shared index
git diff --cached --stat
git commit -m "wip: ..."
git push -u origin <branch-for-this-work> # early; re-push as you go
git switch <other-branch> # nothing left behind — no stash to drop
```
Then bring the work back to wherever you need it **live in the working tree** by merging — not by
fishing it out of a stash and not from a second checkout:
After sole-writer ownership has transferred to the integrator, bring the frozen work back wherever
it is needed **live in the working tree** by merging the reviewed SHA — not by fishing it out of a
stash or following a movable branch name:
```bash
git switch <target-branch>
git merge <branch-for-this-work> # the work is now in THIS working tree too
git merge "$topic_sha" # exact reviewed object, not a movable ref
```
**Deliberately avoided here — two tempting shortcuts that both cause the loss this skill exists to prevent:**
**Deliberately avoided here — shortcuts that cause the loss this skill exists to prevent:**
- **`git stash` + switch juggling** — orphans stashes (the failure mode above). Commit instead; a
commit on a branch never silently disappears from `git stash list`.
- **`git worktree`** — a second checkout is one more place to leave work in and forget, it does
**not** copy gitignored dependencies (`node_modules`, `.venv`), so tools/tests run there fail on
the missing deps, and it can hand back a stale checkout of an older commit. A shared working tree
with disciplined *commit-then-switch* is safer and simpler than juggling worktrees.
- **Defaulting every concurrent session to `git worktree`** — a second checkout is one more place
to leave work in and forget, it does **not** copy gitignored dependencies (`node_modules`,
`.venv`), and it still shares refs/stashes/config/hooks. A shared checkout with one writer and
disciplined *commit-then-switch* is safer unless current authority explicitly approves the named
worktree exception described above.
- **`git clone --shared` as temporary isolation** — the clone's refs and object ownership split:
`.git/objects/info/alternates` borrows objects from the source while the clone owns its refs.
Git's official documentation warns that source maintenance can prune those borrowed objects and
@@ -91,10 +210,11 @@ to, and gets deleted along with the wrong branch during cleanup.
git branch --show-current # is this where this change belongs?
```
If you commit to the wrong branch anyway, it's recoverable: `git log` the sha, `git branch
correct-branch <sha>`, then remove it from the wrong branch — but confirming up front is free.
If you commit to the wrong branch anyway, it's recoverable: `git log` the SHA, then create a
preserving ref with `git branch correct-branch <sha>`. Leave removal from the wrong branch to
separately authorized Mode E retirement; confirming up front is free.
## Relocate your work when a parallel session switched the shared tree under you
## Recover stranded work after a parallel session switched the shared tree
**Failure mode:** two agents share one working tree. While you were editing, a *parallel* session
ran `git switch` and moved the shared tree onto **its** feature branch — so your uncommitted changes
@@ -103,8 +223,13 @@ switched, so "commit before you switch" never got a chance to fire. A naive `git
commit` here buries your work inside the other branch's PR (wrong attribution, wrong review) and can
sweep in their file; committing onto their branch also couples your change to their merge.
**Fix — carry your uncommitted work onto a branch off the base, commit only your paths, then put the
tree back exactly where the other session left it:**
**Incident-only recovery:** do not run the sequence below while the other writer is active. First
use the repository's coordination system to quiesce that writer, freeze the observed dirty paths,
and transfer exclusive write ownership. If no such authority exists, stop with the evidence intact;
do not treat checkout plumbing as a concurrency loophole.
Once exclusive ownership is established, carry your uncommitted work onto a branch off the base,
commit only your paths, then put the tree back exactly where the other session left it:
```bash
# 1. See what the hijacked branch is, and prove YOUR files are safe to carry across the switch.
@@ -119,11 +244,10 @@ git checkout origin/main -b fix/your-work
# 3. Commit ONLY your explicit paths — never `git add -A`; the other session's file is still here.
git add <your-path-1> <your-path-2>
git diff --cached --name-only # verify: only yours, not their file
git commit -m "…" # then push / PR / merge as normal
git commit -m "…" # freeze/push this SHA; never merge the branch name
# 4. Restore the other session's state: put the shared tree back on their branch.
# 4. Restore the other session's state before handing write ownership back.
git checkout <their-branch> # their uncommitted file carries back untouched
git branch -d fix/your-work # safe once merged (the branch tracked origin/main)
```
**Why this and not the alternatives:** `git stash` to move your edits risks the orphaned-stash loss
@@ -133,6 +257,11 @@ branch's tip and the base, which is exactly the condition under which `checkout
uncommitted edits with no conflict (if it reports a difference, stop and resolve it deliberately
rather than forcing the switch). Step 4 is correctness, not just courtesy: the parallel session
expects to find its own branch checked out with its work intact, exactly as it left it.
Branch deletion is not part of this recovery; it requires separately authorized Mode E evidence.
After Step 3, return to **Exact-SHA handoff and scoped completion** above: record the commit as
`topic_sha`, push and read back that exact object, use `git merge "$topic_sha"` for a direct merge,
and require the hosted expected-head-SHA gate (or immediately preceding hosted head readback) for a
PR merge. Any branch-tip drift expires the handoff.
## Audit before rebase / branch-delete
@@ -180,7 +309,7 @@ the pre-rewrite commits.
**Prevention:** a throwaway backup branch makes the whole operation reversible:
```bash
git branch backup/pre-rewrite # points at the current tip; delete once you're happy
git branch backup/pre-rewrite # points at the current tip; retire later through Mode E
```
If the rewrite goes wrong, `git reset --hard backup/pre-rewrite` restores it exactly.