Git Safety Net
Prevent losing work in a tangle of branches/stashes/rebases, and recover it forensically
when something already went sideways. The commands here are all non-destructive or additive
until a step is explicitly labeled destructive — recovery must never make the loss worse.
Outcome contract — keep the safety net subordinate to the user's job
Before Mode B/E or any command that writes a ref or backup, state the following in the conversation
(do not create another file):
- Outcome: the user-visible end state, in the user's words.
- Current phase: what is authorized now. "Later" work is not authorized in this phase.
- Authorized targets: named objects this phase may inspect, plus the subset it may change.
- Stop condition: observable facts that end the task.
Then enforce these boundaries:
- Evidence scope is not action scope. A read-only audit may discover another clone, ref,
repository, or dangling object. That discovery may widen the report; it does not authorize
preserving, uploading, merging, deleting, or otherwise changing the newly found object.
- Authorization is object-specific, not repository-wide by implication. "Take over",
"continue", or "finish the cleanup" applies only to the checkout/ref/PR the user identified.
A collaborator-owned worktree, branch, or PR discovered later stays report-only until the user
names it as a change target. If the user says to leave it alone, record that exclusion and do
not inspect its working-tree contents, back it up, merge it, unlock/remove it, or mutate its refs.
- Preserve the smallest set threatened by the next authorized destructive action. If the
current phase is only commit/push/verify and no deletion, reset, gc, history rewrite, or
worktree removal is authorized, do not create an all-refs bundle or pin every dangler.
- Classify an artifact before choosing its transport. Durable project source follows the
repository's normal Git/LFS policy. A temporary recovery artifact (bundle, working-tree diff,
snapshot, transport chunk) belongs in a repository-external backup directory. Do not stage,
commit, push, or route it through Git LFS merely to make the backup remote; Git LFS is for
durable versioned project binaries, not a fallback transport for temporary recovery material.
- Keep preservation and retirement as separate phases. A verified recovery artifact proves
recoverability; it does not prove the business code landed. Keep it through the destructive
action and the content-level postconditions. Retire an exact task-owned temporary artifact only
when every payload is landed or proven superseded and the artifact itself has explicit cleanup
authorization. Keep backup-only, unresolved, and mixed artifacts.
- Treat a new storage or execution surface as a scope change. A second repository, new
remote, cloud upload, Git LFS, or full-history export requires re-planning and explicit authority
when the stated outcome actually depends on it. Do not solve a transport problem the user did
not ask to create.
- Prove completion in the user's world. A remote containing the intended commit, preserved
WIP, and the requested branch/worktree state are outcomes. Bundle counts, checksums, upload
receipts, and audit breadth are supporting evidence, never substitutes for that outcome. Stop
when the contract is satisfied; record unrelated findings separately without acting on them.
Entry router — pick the mode from what the user is worried about
| The user says / needs… |
Go to |
| "I think I lost a commit / branch / stash", "recover the deleted X", "git reflog" |
Mode A — Recover |
| "did I lose anything?", "what worktrees/stashes/branches remain?", after a messy session |
Mode B — Audit & preserve |
| "is everything merged?", "what's still not on main?", before deleting old branches |
Mode C — Verify merged |
| "so this never happens again", starting parallel/multi-branch work |
Mode D — Prevent |
| "clean up worktrees/stashes/branches", "converge everything onto main", "only keep one main branch" |
Mode E — Retire safely |
| "an audit already said it's clean, but is anything else lost?", "check again" |
Mode B, starting at Step 0 — a repeat request usually means the first pass had the wrong scope, not that it looked carelessly |
When in doubt, run the smallest read-only probe that selects a mode. Use Mode B Step 0's
machine-wide discovery only when the outcome is an exhaustive loss audit or the target checkout is
unknown. A named repository/branch/worktree task stays named; findings outside that target are
report-only until the user expands the authorized targets.
Load-bearing rules (internalize these; the modes apply them)
- Get the EVIDENCE SCOPE right before you trust any verdict, without silently expanding the
work scope: every instrument here only sees the repository it runs in.
git worktree list,
git branch -a, git fsck, git stash list,
git log --not --remotes — all of them are structurally blind to an independent clone of
the same repository elsewhere on the machine. A linked worktree (git worktree add) has a
gitlink file pointing home, so it shows up; a second git clone has its own complete .git
and no back-reference, so it shows up in nothing. Run git_find_all_checkouts.sh first only
for an exhaustive audit or unknown target; otherwise audit the named target. Real incident: a
repository audited clean, every branch pushed, while 440 lines of a working feature sat as
untracked files in a sibling clone one rm -rf from gone.
Scope has a second axis: TIME. Every origin/* ref is a cached snapshot from your last
fetch, not the remote — so git fetch --all --prune before you trust any verdict that depends
on one. Read a stale cache in the right direction: for "what would be lost" it errs safe
(it can over-report unpushed work, never hide it), which is why the scripts here still run
offline. For "is this already upstream?" it fails the other way — work the remote already
has reads as unique, so you re-ship it, and if the remote improved it meanwhile your "restore"
silently reverts those improvements while looking like a rescue. Real incident: a
comparison base one day old made an already-merged change look unshipped; the rescue PR would
have reverted three fixes a later review added on top, one of them a security fix.
Scope has a third axis: the REF SET itself moves. A branch inventory and a verified bundle
prove what existed at one instant; they do not authorize deletion five minutes later. Immediately
before deleting, re-enumerate local refs and hosting-service branches, then require every target
ref to still equal the object recorded in the bundle. A new branch, a moved tip, or a new parallel
PR reopens classification and requires a new bundle. Do not delete against a stale inventory.
Scope has a fourth axis: OWNERSHIP. Repository visibility does not make every visible object
part of this task. Partition discovered refs/worktrees/PRs into change-authorized, inspect-only,
and explicitly excluded sets before acting; compute cleanup success over the authorized set.
- Run
git_loss_audit.sh for the authoritative "what would be lost" check within a
checkout. It compares the current HEAD, every linked-worktree HEAD, local branches, and tags
against every remote, then inspects each worktree for tracked/untracked changes plus stashes and
dangling commits. The shorter git log HEAD --branches --tags --not --remotes misses a detached
HEAD in a different worktree and all uncommitted files. Ahead/behind counts do not answer
this. Run it in the named checkout, and in additional Step 0 checkouts only after each is
explicitly change-authorized. Run this repository-wide script only when every surface it
enumerates—linked worktrees, local refs/tags, stashes, and dangling commits—is inside the
declared evidence scope. It has no exclusion flags. Otherwise limit the claim to the authorized
checkout/ref and use its own status, HEAD, upstream/remote identity, and git log HEAD --not --remotes as scoped evidence; report the other surfaces as not audited.
git reflog is the first move for "I lost a commit," not fsck. Reflog records every
HEAD position (commits, checkouts, resets, rebases) for ~90 days and the lost commit is
usually in its top few lines. git fsck is the deeper net for commits reflog can't reach.
- Preserve before you clean up — and know which backup tool can actually reach the work.
Pin at-risk/dangling commits somewhere garbage collection can't reach them before deleting a
branch, running
gc, or force-pushing. Cleanup is reversible only while a ref (or the reflog
window) still points at the work. Critical asymmetry: bundle, archive, and format-patch
can only reach objects git already knows about. An untracked file that was never git added
and never stash -ued is invisible to those formats — the copy on disk is the only copy, so
preserving it means literally copying the file out. Backing up "the repository" and believing
untracked work came along is how a clean-looking backup silently omits the only thing at risk.
- Verify "merged" by CONTENT, never by commit count — and know that most content checks are
also unsound. After a squash-merge,
main..branch shows the branch's original commits as
"unmerged" even though their content is on main — often 100+ phantom commits. But swapping
counts for the nearest content check is not enough: in one audit, three successive
"surely this is content-level now" instruments each returned a wrong answer — git cherry
(squash rewrites patch-ids → false UNMERGED), a three-dot diff base...ref used to ask
"what does base lack" (three-dot answers a different question and under-reported missing
files by 5×), and a file-level existence check (a file present on base can still be missing
the ref's lines). Only the trial merge (git merge-tree, what git_verify_branch_merged.sh
runs) was right every time. Diff-form and rung-by-rung reliability: references/merge_verification.md.
- For a high-stakes exhaustive "is everything merged?" call that will authorize deletion,
verify adversarially. One independent reviewer is the default. Use multiple reviewers only
when distinct repositories or evidence axes cannot be covered by one pass and the user has
authorized that fan-out. Make one pass try to falsify the declared evidence scope (rule 1), but
keep any newly found target report-only under the Outcome contract.
Mode A — Recover lost work
A commit/branch/stash that "disappeared" is almost always still in the object store for ~90 days —
why that is true, and what ends it, is references/recovery_playbook.md § Mental model: nothing is gone
until gc runs. The ladder there is indexed by symptom, so go straight to your rung —
§ Ladder step 1 — git reflog (where most recoveries end),
§ Ladder step 2 — dropped stashes,
§ Ladder step 3 — detached-HEAD work,
§ Ladder step 4 — git fsck for true orphans. The 30-second version:
git reflog --date=iso | head -40 # find the lost HEAD position (most recoveries are here)
git show <sha> # CONFIRM it's the right commit before acting
git switch -c rescue/<name> <sha> # recover onto a NEW branch — never reset onto live work
If reflog doesn't show it, fall through by symptom rather than reaching for fsck first: a
dropped stash has its own recovery route (references/recovery_playbook.md § Ladder step 2), work
abandoned on a detached HEAD has another (§ Ladder step 3), and only a true orphan — from a rebase, say —
needs git fsck --dangling (§ Ladder step 4).
Mode B — Audit what's at risk, then preserve it
Step 0 — establish the evidence scope (rule 1). What "at risk" covers, and the checkout kinds
that hide it, are references/recovery_playbook.md § The authoritative "is anything at risk" check and
§ Linked worktrees and detached worktree HEADs.
Run machine-wide checkout discovery only when
the Outcome contract calls for an exhaustive audit or the target checkout is unknown. For a named
target, record that checkout and continue to Step 1 without turning an unrelated clone into work.
When exhaustive discovery is warranted, find every checkout of this repository on the machine,
including the independent clones no in-repo command can see:
scripts/git_find_all_checkouts.sh # defaults to this repo's parent + grandparent
DEPTH=6 scripts/git_find_all_checkouts.sh ~ # widen when clones live far from each other
It matches sibling checkouts by normalized remote URL (so the SSH and HTTPS forms of one
repository compare equal), falling back to any shared commit history whenever either the current
or a candidate checkout has no origin. That history check works for shallow clones that cannot
see the repository's true root. It never matches by directory name, because an independent clone
is usually named differently from the original (repo vs repo-hotfix), which is exactly when
name matching fails. It canonicalizes path aliases before identifying the current checkout,
disables repository-provided fsmonitor commands while inspecting candidates, and treats commits
reachable from any locally known remote-tracking ref as pushed even when a branch has no upstream.
Exit is 1 when any other checkout holds uncommitted, untracked, unpushed, or uninspectable work.
For an inspect-only checkout, stop at discovery: Step 1 fetches and changes its remote-tracking
refs. Run Step 1 only after that checkout is change-authorized; apply Step 2 only to authorized
items. A "nothing at risk" claim covers only the checkouts actually audited.
Maintainer verification
Run the isolated regression suite after changing checkout discovery:
uv run python -m unittest discover -s tests -p 'test_*.py'
Step 1 — audit (non-destructive). What, if anything, is at risk of loss right now:
When every worktree/ref/tag/stash/dangler the script enumerates is inside the declared evidence
scope:
scripts/git_loss_audit.sh # defaults to remote "origin"; pass a remote name to override
When any surface listed above is excluded, skip that script and collect only checkout/ref-scoped
evidence:
git status --porcelain=v1 --untracked-files=all
git rev-parse HEAD
git log --oneline HEAD --not --remotes
git ls-remote <remote> <authorized-remote-ref>
For the full audit, expected output is every worktree with branch/detached state and cleanliness,
plus counts of
local-only commits, dirty/unavailable worktrees, stashes, and dangling commits.
Exit is 1 when commits exist on no remote or a worktree is dirty/uninspectable; stashes and
danglers remain visible but do not alone make the audit fail. Exit 0 is therefore not permission
to delete a visible stash/dangler: triage or preserve every reported item. Do not claim cleanup is
safe until the named worktree is clean and its HEAD is proven contained or deliberately preserved.
The scoped path proves only the authorized checkout/ref; it says nothing about excluded worktrees,
other local refs, stashes, or danglers, which must remain listed as not audited.
Step 2 — preserve only what the next authorized destructive action threatens (additive,
gc-proof). A finding alone does not need a backup. If deletion, gc, or history rewriting can make
a reported commit unreachable, preserve that exact commit before the action. Use the whole-set
helper only when every reported dangler is actually in the authorized target set:
scripts/git_preserve_danglers.sh --patch-dir ~/git-danglers # pin + export patches
Why pinning survives gc, and the targeted single-ref form when the whole set is not in scope:
references/recovery_playbook.md § Preserve: pin authorized danglers so gc can never take them.
This pins every dangling commit under refs/dangling-backup/<sha> (garbage collection can never
reach a referenced commit) without cluttering git branch, and optionally writes a .patch per
non-stash commit. For a specific important commit, also give it the full treatment — local
branch and a pushed remote branch and a git format-patch file — so a single disk or a
single git gc can't take it. Details + why triple-backup: references/recovery_playbook.md
§ Triple-backup a critical commit.
Untracked files need a different tool — plain copying (rule 4). Put <backup> outside the
target repository and every checkout being retired. Everything above moves git
objects; a file git was never told about is not one. Preserve those explicitly, and keep the
channels separate so a later reader knows what each restores:
git -C <checkout> status --porcelain | grep '^??' # what is untracked
cp <each-untracked-path> <backup>/ # the ONLY copy — plain cp
git -C <checkout> diff > <backup>/uncommitted.diff # tracked-but-uncommitted
git -C <checkout> bundle create <backup>/history.bundle origin/main..HEAD # unpushed commits
git bundle verify <backup>/history.bundle # prove it restores
Write a one-paragraph README beside them saying where they came from, which branch, and when the
session stopped. A backup nobody can interpret six weeks later is only slightly better than none —
and the person reading it will not be the person who made it.
Mode C — Verify everything is merged (without being fooled by counts)
The trap: a stale branch shows "173 commits ahead of main" yet every line is already on main
(squash-merge artifact) — the mechanism is references/merge_verification.md § Why commit counts lie.
Never conclude "unmerged" from counts. Per-branch content check (procedure and output reading:
§ Per-branch verdict procedure):
scripts/git_verify_branch_merged.sh <branch> [<base>] # base defaults to origin/main
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. 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 — the sole writer must refresh authority first, and two-dot vs three-dot
answers different questions (references/merge_verification.md § Pick the diff FORM from the question
you're asking). Signals that may inform a human but must never auto-decide are fenced off in
§ Manual-only investigation hints.
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 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
(references/merge_verification.md
§ Why safety-biased). Why the trial merge is sound rather than a
heuristic, and why --find-object/blob comparison is not: § The sound content check. For a whole
repo of branches, the read-only fan-out pattern — one agent per batch, each told to falsify
"everything is merged," every finding independently re-checked — is
§ Adversarial multi-agent verification, with the constraints those agents must be given in
§ Rules for the verification agents.
Mode D — Prevent the disaster
The habits that keep a branch tangle from ever stranding work:
references/prevention_practices.md. Each bullet below carries the § name of its full
treatment there — follow the one that matches your situation rather than reading the whole file.
The load-bearing few:
- 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.
(§ Choose topology from current authority.)
- 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.
(§ Shared checkout and concurrent sessions: one writer — also governs the two "parallel session"
bullets below.)
- 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.
(§ Parallel / multi-branch work; § Push work-in-progress branches early.)
- 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.
(§ Audit every authorized worktree before retirement.)
- 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. (§ Known automated writers are not session-owned WIP.)
- 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.
(§ Confirm the branch before every commit — including why removal from the wrong branch waits
for Mode E.)
- 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: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 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 § Recover stranded work after a parallel session
switched the shared tree: 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.
- The inverse case: another session's commit lands on your branch, and every check you already
run stays green. In a shared checkout, a commit a sibling session makes while
HEAD sits on
your branch becomes a parent of yours and ships inside your PR. git branch --show-current names
your branch, the tree is clean, and git diff --cached --name-status shows exactly your paths —
all true, all blind, because their work left the index the moment they committed. It appears only
in the branch's cumulative range against the base you branched from. Detection is read-only, so
run it before every push and before opening any PR:base=<the base SHA you recorded when you created the branch>
git rev-parse --verify "$base^{commit}" # must print a SHA — see below before trusting the rest
git log --oneline "$base"..HEAD # every commit here must be yours
git diff --name-only "$base" HEAD # every path here must be yours
The verify line is load-bearing. An empty $base turns "$base"..HEAD into HEAD..HEAD:
no output at exit 0, indistinguishable from "no foreign commits". That silent case is the one the
guard exists for; § A foreign commit adopted onto your branch has it and the louder one measured.
And "yours" is not derivable from Git: in a shared checkout both sessions write the same
author and committer, so no flag separates them. It comes from the SHAs you recorded as you
committed. If you cannot say which commits are yours, stop and ask — the repair deletes a commit,
so a guess here is the loss this skill exists to prevent.
Record that base SHA when you branch — deriving it later reads a cached remote ref, and the fetch
that would refresh it is itself ownership-gated § A foreign commit adopted onto your branch.
A foreign commit in that range is evidence another writer was in this checkout, so repair is
not yours to start. Stop; the ownership rules above apply unchanged. Once ownership has
transferred, repair is a history rewrite of your branch — git rebase --onto checks out the
branch it rewrites — so it runs the existing sequence rather than a shortcut: the applicable Mode B
evidence path, then git branch backup/pre-rewrite <your-branch> (Snapshot before any history rewrite
— this is what makes the rebase reversible), then git branch rescue/foreign-<short-sha> <foreign-sha>
(this preserves their work, a separate obligation and a different ref), then
git rebase --onto "<foreign-sha>^" "<foreign-sha>" <your-branch> — onto the foreign commit's
parent, never onto the base, because --onto "$base" discards everything before the foreign
commit, your own earlier commits included, and exits 0. Then re-run the detection above: the
rebase's exit code does not tell you whether it took something of yours with it. Retiring either
ref afterwards requires Mode C/E deletion-grade evidence, not a guess. Full procedure,
and why the two obvious "did their work survive?" probes return the wrong answer, in A foreign
commit adopted onto your branch in
references/prevention_practices.md. Real incident: a
sibling session committed while HEAD sat on a freshly created branch; the PR carried that
session's in-progress work, and the only signal was a repo validator reporting two changed
components when the author had touched one.
- 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:candidate_ref=<immutable-candidate-commit-oid>
candidate_path=path/to/file
candidate_entry=$(git ls-tree "$candidate_ref" -- "$candidate_path")
candidate_mode=$(printf '%s\n' "$candidate_entry" | awk 'NR == 1 { print $1 }')
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/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"
tree=$(git write-tree)
commit=$(git commit-tree "$tree" -p origin/main -m "…") # HEAD does not move
unset GIT_INDEX_FILE
rm "$candidate_index"
git push origin "$commit":refs/heads/<branch> # open the PR from here
For an owned temporary regular file that is not yet in an immutable commit, derive its intended
mode explicitly (100755 when executable, otherwise 100644) and hash its bytes; fail instead of
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. 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. 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
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
git commit: that commit snapshots the entire index, turning the phantom deletions real —
delivered files vanish from HEAD while the working tree looks untouched. Real incident:
a 24-file delivered directory sat in that window after a temporary-index commit; one bare commit
by a parallel session would have deleted it from the branch tip, and the only sign anywhere was
D lines in git status. Two obligations follow. Whoever advanced the branch past the index
re-syncs immediately — git diff --cached --name-status, then git restore --staged -- <the paths the commit touched> until those paths no longer appear in the diff (a parallel
session's own staged entries are theirs, not yours to clear). And before any bare commit on a
shared tree, read that same diff as your blast radius — every entry, D lines included, must
be one you intended; an entry you don't recognize means stop, not commit.
(§ Commit-scope hygiene.)
- Before any rebase or branch-delete, run the applicable Mode B evidence path. Use the full
loss audit only when every worktree/ref/tag/stash/dangler it enumerates is in evidence scope;
otherwise use the authorized checkout/ref's scoped checks and limit the safety claim accordingly.
- Before bumping a shared version/lockfile, check the base's current value so two parallel
branches don't both claim the same bump (a silent collision that blocks the later change from
shipping). (§ Version / lockfile collisions between parallel branches.)
Mode E — Retire worktrees, stashes, and branches safely
The opposite worry from Mode A: not "I lost something" but "these leftovers are piling up —
which can I destroy?" Deleting is trivial; proving each item is superseded is the work.
Start from the Outcome contract. For an exhaustive audit or unknown target, run checkout discovery;
for one named worktree/branch, stay in its owning repository. Run git_loss_audit.sh only when all
worktrees/refs/tags/stashes/danglers it enumerates are inside evidence scope; treat inspect-only
objects as report-only, keep explicitly excluded collaborator resources out of both the retirement
plan and its terminal counts, then retire only the named targets. If any enumerated surface is
excluded, do not run the full loss audit or an --all-refs export; use checkout/ref-scoped checks
and targeted exports instead:
Step 1 — classify each leftover: live WIP, or superseded draft? Evidence ladder, strongest first:
- Fresh authority plus trial merge — refresh the base and exact branch tip, then run
scripts/git_verify_branch_merged.sh. An ancestor/content-contained verdict is deletion-grade
evidence. If it returns NEEDS REVIEW, continue down this ladder; do not convert uncertainty to
MERGED with a weaker heuristic.
git cherry <base> <branch> is a hint, not a verdict. A - proves that one patch-id is
upstream; a + does not prove missing work because squash merges deliberately create a new
patch-id. Never rescue or delete a whole branch from this output alone.
- Same-file supersession check — for a stash or
+ commit touching files that were later
reworked on the base: extract its version of the file and compare with the base's current
version (git show <ref>:<path> | wc -l vs git show <base>:<path> | wc -l, then spot-diff).
If the base's version is a superset (has everything the leftover has, plus later work),
the leftover is a superseded draft. Real case: a stash labeled "unfinished dev" held a 1128-line
renderer; main's version was 1151 lines — the same functions plus a later feature parameter.
Restoring that stash would have been a regression, not a recovery.
- Function/marker-level probe — grep the base for the leftover's distinctive additions
(
def new_helper, a constant, an error string). All present on the base → superseded.
This catches "absorbed into a refactor" cases where file shapes changed too much for rung 2.
Anything you cannot prove superseded stays alive (same safety bias as Mode C: a false "superseded"
loses work; a false "still live" costs a branch name). One warning that changes verdicts: the
leftover's label is not evidence — a stash named "unfinished development" can be a fully-landed
early draft; judge content against the current base, never the name. Worked examples of the
rungs (including the squash-artifact and absorbed-into-refactor cases):
references/merge_verification.md § Supersession triage.
Step 2 — after deletion authority exists and immediately before deletion, preserve exactly what
that deletion threatens:
# Targeted branch cleanup: prefer the narrow export.
scripts/git_export_before_drop.sh --branch <branch> --out <external-backup-dir>
# Pin only an authorized dangling SHA; leave unrelated danglers report-only.
git update-ref refs/dangling-backup/<sha> <sha>
# Full ref topology: only when every captured ref is explicitly authorized.
scripts/git_export_before_drop.sh --all-refs --out <external-backup-dir>
scripts/git_export_before_drop.sh --verify-current <external-backup-dir>/all-refs.bundle
The targeted update-ref reaches only the authorized dangling commit. If every reported dangler is
in scope, the whole-set git_preserve_danglers.sh may replace it. Prefer repeated --branch options
for named branch/worktree retirement. --all-refs captures branches, tags, stashes, hidden backup
refs, and linked-worktree HEAD refs, so it is valid only when that whole captured set is authorized;
add --all-stashes only when stashes are also deletion targets. --verify-current is the final
compare-and-swap gate: it exits 1 if any recorded ref moved or disappeared. Refresh remote authority
before it, and rebuild the bundle on any mismatch. Keep backups outside the repository; never turn
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 § Converging many branches
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:
- Stashes: drop from the highest index down (
drop stash@{2} before stash@{1}) — indices
shift as you drop, and top-down keeps every number meaning what your backup filenames say.
- Linked worktrees: require an empty
git -C <path> status --porcelain=v1 --untracked-files=all,
then inventory ignored paths separately with --ignored. A normal clean status hides !!
files, and no bundle can preserve them; copy out anything not proven reproducible, preserve its
relative pat
…(truncated)
1---2name: git-safety-net3description: 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.4---5
6# Git Safety Net
7
8Prevent losing work in a tangle of branches/stashes/rebases, and recover it forensically
9when something already went sideways. The commands here are all **non-destructive or additive**
10until a step is explicitly labeled destructive — recovery must never make the loss worse.
11
12## Outcome contract — keep the safety net subordinate to the user's job
13
14Before Mode B/E or any command that writes a ref or backup, state the following in the conversation
15(do not create another file):
16
17- **Outcome:** the user-visible end state, in the user's words.
18- **Current phase:** what is authorized now. "Later" work is not authorized in this phase.
19- **Authorized targets:** named objects this phase may inspect, plus the subset it may change.
20- **Stop condition:** observable facts that end the task.
21
22Then enforce these boundaries:
23
24- **Evidence scope is not action scope.** A read-only audit may discover another clone, ref,
25 repository, or dangling object. That discovery may widen the report; it does not authorize
26 preserving, uploading, merging, deleting, or otherwise changing the newly found object.
27- **Authorization is object-specific, not repository-wide by implication.** "Take over",
28 "continue", or "finish the cleanup" applies only to the checkout/ref/PR the user identified.
29 A collaborator-owned worktree, branch, or PR discovered later stays report-only until the user
30 names it as a change target. If the user says to leave it alone, record that exclusion and do
31 not inspect its working-tree contents, back it up, merge it, unlock/remove it, or mutate its refs.
32- **Preserve the smallest set threatened by the next authorized destructive action.** If the
33 current phase is only commit/push/verify and no deletion, reset, gc, history rewrite, or
34 worktree removal is authorized, do not create an all-refs bundle or pin every dangler.
35- **Classify an artifact before choosing its transport.** Durable project source follows the
36 repository's normal Git/LFS policy. A temporary recovery artifact (bundle, working-tree diff,
37 snapshot, transport chunk) belongs in a repository-external backup directory. Do not stage,
38 commit, push, or route it through Git LFS merely to make the backup remote; Git LFS is for
39 durable versioned project binaries, not a fallback transport for temporary recovery material.
40- **Keep preservation and retirement as separate phases.** A verified recovery artifact proves
41 recoverability; it does not prove the business code landed. Keep it through the destructive
42 action and the content-level postconditions. Retire an exact task-owned temporary artifact only
43 when every payload is landed or proven superseded and the artifact itself has explicit cleanup
44 authorization. Keep backup-only, unresolved, and mixed artifacts.
45- **Treat a new storage or execution surface as a scope change.** A second repository, new
46 remote, cloud upload, Git LFS, or full-history export requires re-planning and explicit authority
47 when the stated outcome actually depends on it. Do not solve a transport problem the user did
48 not ask to create.
49- **Prove completion in the user's world.** A remote containing the intended commit, preserved
50 WIP, and the requested branch/worktree state are outcomes. Bundle counts, checksums, upload
51 receipts, and audit breadth are supporting evidence, never substitutes for that outcome. Stop
52 when the contract is satisfied; record unrelated findings separately without acting on them.
53
54## Entry router — pick the mode from what the user is worried about
55
56| The user says / needs… | Go to |
57|---|---|
58| "I think I lost a commit / branch / stash", "recover the deleted X", "git reflog" | **Mode A — Recover** |
59| "did I lose anything?", "what worktrees/stashes/branches remain?", after a messy session | **Mode B — Audit & preserve** |
60| "is everything merged?", "what's still not on main?", before deleting old branches | **Mode C — Verify merged** |
61| "so this never happens again", starting parallel/multi-branch work | **Mode D — Prevent** |
62| "clean up worktrees/stashes/branches", "converge everything onto main", "only keep one main branch" | **Mode E — Retire safely** |
63| "an audit already said it's clean, but is anything *else* lost?", "check again" | **Mode B, starting at Step 0** — a repeat request usually means the first pass had the wrong scope, not that it looked carelessly |
64
65When in doubt, run the **smallest read-only probe that selects a mode**. Use Mode B Step 0's
66machine-wide discovery only when the outcome is an exhaustive loss audit or the target checkout is
67unknown. A named repository/branch/worktree task stays named; findings outside that target are
68report-only until the user expands the authorized targets.
69
70## Load-bearing rules (internalize these; the modes apply them)
71
721. **Get the EVIDENCE SCOPE right before you trust any verdict, without silently expanding the
73 work scope: every instrument here only sees the repository it runs in.** `git worktree list`,
74 `git branch -a`, `git fsck`, `git stash list`,
75 `git log --not --remotes` — all of them are structurally blind to an **independent clone** of
76 the same repository elsewhere on the machine. A linked worktree (`git worktree add`) has a
77 gitlink *file* pointing home, so it shows up; a second `git clone` has its own complete `.git`
78 and no back-reference, so it shows up in **nothing**. Run `git_find_all_checkouts.sh` first only
79 for an exhaustive audit or unknown target; otherwise audit the named target. Real incident: a
80 repository audited clean, every branch pushed, while 440 lines of a working feature sat as
81 untracked files in a sibling clone one `rm -rf` from gone.
82 **Scope has a second axis: TIME.** Every `origin/*` ref is a cached snapshot from your last
83 fetch, not the remote — so `git fetch --all --prune` before you trust any verdict that depends
84 on one. Read a stale cache in the right direction: for *"what would be lost"* it errs safe
85 (it can over-report unpushed work, never hide it), which is why the scripts here still run
86 offline. For *"is this already upstream?"* it fails the other way — work the remote already
87 has reads as unique, so you re-ship it, and if the remote improved it meanwhile your "restore"
88 silently **reverts** those improvements while looking like a rescue. Real incident: a
89 comparison base one day old made an already-merged change look unshipped; the rescue PR would
90 have reverted three fixes a later review added on top, one of them a security fix.
91 **Scope has a third axis: the REF SET itself moves.** A branch inventory and a verified bundle
92 prove what existed at one instant; they do not authorize deletion five minutes later. Immediately
93 before deleting, re-enumerate local refs and hosting-service branches, then require every target
94 ref to still equal the object recorded in the bundle. A new branch, a moved tip, or a new parallel
95 PR reopens classification and requires a new bundle. Do not delete against a stale inventory.
96 **Scope has a fourth axis: OWNERSHIP.** Repository visibility does not make every visible object
97 part of this task. Partition discovered refs/worktrees/PRs into change-authorized, inspect-only,
98 and explicitly excluded sets before acting; compute cleanup success over the authorized set.
992. **Run `git_loss_audit.sh` for the authoritative "what would be lost" check *within a
100 checkout*.** It compares the current HEAD, every linked-worktree HEAD, local branches, and tags
101 against every remote, then inspects each worktree for tracked/untracked changes plus stashes and
102 dangling commits. The shorter `git log HEAD --branches --tags --not --remotes` misses a detached
103 HEAD in a different worktree and all uncommitted files. Ahead/behind counts do **not** answer
104 this. Run it in the named checkout, and in additional Step 0 checkouts only after each is
105 explicitly change-authorized. Run this repository-wide script only when every surface it
106 enumerates—linked worktrees, local refs/tags, stashes, and dangling commits—is inside the
107 declared evidence scope. It has no exclusion flags. Otherwise limit the claim to the authorized
108 checkout/ref and use its own `status`, `HEAD`, upstream/remote identity, and `git log HEAD --not
109 --remotes` as scoped evidence; report the other surfaces as not audited.
1103. **`git reflog` is the first move for "I lost a commit," not `fsck`.** Reflog records every
111 HEAD position (commits, checkouts, resets, rebases) for ~90 days and the lost commit is
112 usually in its top few lines. `git fsck` is the deeper net for commits reflog can't reach.
1134. **Preserve before you clean up — and know which backup tool can actually reach the work.**
114 Pin at-risk/dangling commits somewhere garbage collection can't reach them *before* deleting a
115 branch, running `gc`, or force-pushing. Cleanup is reversible only while a ref (or the reflog
116 window) still points at the work. **Critical asymmetry: `bundle`, `archive`, and `format-patch`
117 can only reach objects git already knows about.** An untracked file that was never `git add`ed
118 and never `stash -u`ed is invisible to those formats — the copy on disk is the only copy, so
119 preserving it means literally copying the file out. Backing up "the repository" and believing
120 untracked work came along is how a clean-looking backup silently omits the only thing at risk.
1215. **Verify "merged" by CONTENT, never by commit count — and know that most content checks are
122 also unsound.** After a squash-merge, `main..branch` shows the branch's original commits as
123 "unmerged" even though their content is on main — often 100+ phantom commits. But swapping
124 counts for the *nearest* content check is not enough: in one audit, three successive
125 "surely this is content-level now" instruments each returned a wrong answer — `git cherry`
126 (squash rewrites patch-ids → false UNMERGED), a **three-dot** `diff base...ref` used to ask
127 "what does base lack" (three-dot answers a different question and **under-reported missing
128 files by 5×**), and a file-level existence check (a file present on base can still be missing
129 the ref's lines). Only the trial merge (`git merge-tree`, what `git_verify_branch_merged.sh`
130 runs) was right every time. Diff-form and rung-by-rung reliability: **[references/merge_verification.md](references/merge_verification.md)**.
1316. **For a high-stakes exhaustive "is everything merged?" call that will authorize deletion,
132 verify adversarially.** One independent reviewer is the default. Use multiple reviewers only
133 when distinct repositories or evidence axes cannot be covered by one pass and the user has
134 authorized that fan-out. Make one pass try to falsify the declared evidence scope (rule 1), but
135 keep any newly found target report-only under the Outcome contract.
136
137## Mode A — Recover lost work
138
139A commit/branch/stash that "disappeared" is almost always still in the object store for ~90 days —
140why that is true, and what ends it, is **[references/recovery_playbook.md](references/recovery_playbook.md)** § Mental model: nothing is gone
141until gc runs. The ladder there is indexed by symptom, so go straight to your rung —
142§ Ladder step 1 — `git reflog` (where most recoveries end),
143§ Ladder step 2 — dropped stashes,
144§ Ladder step 3 — detached-HEAD work,
145§ Ladder step 4 — `git fsck` for true orphans. The 30-second version:
146
147```bash
148git reflog --date=iso | head -40 # find the lost HEAD position (most recoveries are here)
149git show <sha> # CONFIRM it's the right commit before acting
150git switch -c rescue/<name> <sha> # recover onto a NEW branch — never reset onto live work
151```
152
153If reflog doesn't show it, fall through by symptom rather than reaching for `fsck` first: a
154dropped stash has its own recovery route (**[references/recovery_playbook.md](references/recovery_playbook.md)** § Ladder step 2), work
155abandoned on a detached HEAD has another (§ Ladder step 3), and only a true orphan — from a rebase, say —
156needs `git fsck --dangling` (§ Ladder step 4).
157
158## Mode B — Audit what's at risk, then preserve it
159
160**Step 0 — establish the evidence scope (rule 1).** What "at risk" covers, and the checkout kinds
161that hide it, are **[references/recovery_playbook.md](references/recovery_playbook.md)** § The authoritative "is anything at risk" check and
162§ Linked worktrees and detached worktree HEADs.
163Run machine-wide checkout discovery only when
164the Outcome contract calls for an exhaustive audit or the target checkout is unknown. For a named
165target, record that checkout and continue to Step 1 without turning an unrelated clone into work.
166When exhaustive discovery is warranted, find every checkout of this repository on the machine,
167including the independent clones no in-repo command can see:
168
169```bash
170scripts/git_find_all_checkouts.sh # defaults to this repo's parent + grandparent
171DEPTH=6 scripts/git_find_all_checkouts.sh ~ # widen when clones live far from each other
172```
173
174It matches sibling checkouts by normalized remote URL (so the SSH and HTTPS forms of one
175repository compare equal), falling back to **any shared commit history** whenever either the current
176or a candidate checkout has no `origin`. That history check works for shallow clones that cannot
177see the repository's true root. It never matches by directory name, because an independent clone
178is usually named differently from the original (`repo` vs `repo-hotfix`), which is exactly when
179name matching fails. It canonicalizes path aliases before identifying the current checkout,
180disables repository-provided fsmonitor commands while inspecting candidates, and treats commits
181reachable from any locally known remote-tracking ref as pushed even when a branch has no upstream.
182Exit is 1 when any *other* checkout holds uncommitted, untracked, unpushed, or uninspectable work.
183For an inspect-only checkout, stop at discovery: Step 1 fetches and changes its remote-tracking
184refs. Run Step 1 only after that checkout is change-authorized; apply Step 2 only to authorized
185items. A "nothing at risk" claim covers only the checkouts actually audited.
186
187### Maintainer verification
188
189Run the isolated regression suite after changing checkout discovery:
190
191```bash
192uv run python -m unittest discover -s tests -p 'test_*.py'
193```
194
195**Step 1 — audit (non-destructive).** What, if anything, is at risk of loss right now:
196
197When every worktree/ref/tag/stash/dangler the script enumerates is inside the declared evidence
198scope:
199
200```bash
201scripts/git_loss_audit.sh # defaults to remote "origin"; pass a remote name to override
202```
203
204When any surface listed above is excluded, skip that script and collect only checkout/ref-scoped
205evidence:
206
207```bash
208git status --porcelain=v1 --untracked-files=all
209git rev-parse HEAD
210git log --oneline HEAD --not --remotes
211git ls-remote <remote> <authorized-remote-ref>
212```
213
214For the full audit, expected output is every worktree with branch/detached state and cleanliness,
215plus counts of
216**local-only commits**, **dirty/unavailable worktrees**, **stashes**, and **dangling commits**.
217Exit is 1 when commits exist on no remote or a worktree is dirty/uninspectable; stashes and
218danglers remain visible but do not alone make the audit fail. Exit 0 is therefore not permission
219to delete a visible stash/dangler: triage or preserve every reported item. Do not claim cleanup is
220safe until the named worktree is clean and its HEAD is proven contained or deliberately preserved.
221The scoped path proves only the authorized checkout/ref; it says nothing about excluded worktrees,
222other local refs, stashes, or danglers, which must remain listed as not audited.
223
224**Step 2 — preserve only what the next authorized destructive action threatens (additive,
225gc-proof).** A finding alone does not need a backup. If deletion, gc, or history rewriting can make
226a reported commit unreachable, preserve that exact commit before the action. Use the whole-set
227helper only when every reported dangler is actually in the authorized target set:
228
229```bash
230scripts/git_preserve_danglers.sh --patch-dir ~/git-danglers # pin + export patches
231```
232
233Why pinning survives `gc`, and the targeted single-ref form when the whole set is not in scope:
234**[references/recovery_playbook.md](references/recovery_playbook.md)** § Preserve: pin authorized danglers so gc can never take them.
235
236This pins every dangling commit under `refs/dangling-backup/<sha>` (garbage collection can never
237reach a referenced commit) without cluttering `git branch`, and optionally writes a `.patch` per
238non-stash commit. For a *specific* important commit, also give it the full treatment — local
239branch **and** a pushed remote branch **and** a `git format-patch` file — so a single disk or a
240single `git gc` can't take it. Details + why triple-backup: **[references/recovery_playbook.md](references/recovery_playbook.md)**
241§ Triple-backup a critical commit.
242
243**Untracked files need a different tool — plain copying (rule 4).** Put `<backup>` outside the
244target repository and every checkout being retired. Everything above moves *git
245objects*; a file git was never told about is not one. Preserve those explicitly, and keep the
246channels separate so a later reader knows what each restores:
247
248```bash
249git -C <checkout> status --porcelain | grep '^??' # what is untracked
250cp <each-untracked-path> <backup>/ # the ONLY copy — plain cp
251git -C <checkout> diff > <backup>/uncommitted.diff # tracked-but-uncommitted
252git -C <checkout> bundle create <backup>/history.bundle origin/main..HEAD # unpushed commits
253git bundle verify <backup>/history.bundle # prove it restores
254```
255
256Write a one-paragraph `README` beside them saying where they came from, which branch, and when the
257session stopped. A backup nobody can interpret six weeks later is only slightly better than none —
258and the person reading it will not be the person who made it.
259
260## Mode C — Verify everything is merged (without being fooled by counts)
261
262The trap: a stale branch shows "173 commits ahead of main" yet every line is already on main
263(squash-merge artifact) — the mechanism is **[references/merge_verification.md](references/merge_verification.md)** § Why commit counts lie.
264Never conclude "unmerged" from counts. Per-branch content check (procedure and output reading:
265§ Per-branch verdict procedure):
266
267```bash
268scripts/git_verify_branch_merged.sh <branch> [<base>] # base defaults to origin/main
269```
270
271This mode is the one direction where a stale base is *unsafe* (rule 1): judged against yesterday's
272`origin/main`, a branch whose content landed hours ago still reads UNMERGED, and "rescuing" it
273re-applies an older version over whatever was built on top. The script fetches first for exactly
274that reason. Because fetch moves remote-tracking refs, run it only after existing coordination has
275quiesced every checkout writer and transferred exclusive ownership. If that cannot happen, stay
276read-only and report that the merge verdict is unavailable. If the fetch itself fails after
277ownership transfer, the script falls back to cached refs and says so **on stderr only**.
278Treat that line as a blocker, not a footnote: rerun once the network is back before acting on the
279verdict. Comparing by hand (`git diff origin/main <branch>`, `git log origin/main..<branch>`) has
280no such safety net at all — the sole writer must refresh authority first, and two-dot vs three-dot
281answers different questions (**[references/merge_verification.md](references/merge_verification.md)** § Pick the diff FORM from the question
282you're asking). Signals that may inform a human but must never auto-decide are fenced off in
283§ Manual-only investigation hints.
284
285It reports **MERGED (ancestor)** or **MERGED (content contained)** — content-safe for a separately
286authorized Mode E deletion gate — versus
287**UNMERGED / NEEDS REVIEW**, listing the files the branch would still change. The verdict is sound,
288not heuristic: it does a trial 3-way merge of the branch *into* the base with `git merge-tree`
289(in memory, no checkout) and only reports content containment when that merge changes nothing — so a
290squash-merged branch reads MERGED despite a nonzero commit count, while a revert/edit/new-file the
291base lacks reads UNMERGED. It is **safety-biased**: anything it can't prove contained is reported
292for review, because a false "merged" loses work while a false "unmerged" only costs a look
293(**[references/merge_verification.md](references/merge_verification.md)**
294§ Why safety-biased). Why the trial merge is sound rather than a
295heuristic, and why `--find-object`/blob comparison is not: § The sound content check. For a whole
296repo of branches, the read-only fan-out pattern — one agent per batch, each told to *falsify*
297"everything is merged," every finding independently re-checked — is
298§ Adversarial multi-agent verification, with the constraints those agents must be given in
299§ Rules for the verification agents.
300
301## Mode D — Prevent the disaster
302
303The habits that keep a branch tangle from ever stranding work:
304**[references/prevention_practices.md](references/prevention_practices.md)**. Each bullet below carries the `§` name of its full
305treatment there — follow the one that matches your situation rather than reading the whole file.
306The load-bearing few:
307
308- **Read the current collaboration contract before prescribing topology.** An explicit user or
309 project decision about shared checkouts, worktrees, branches, or contribution flow outranks this
310 generic guidance. Do not turn one messy audit into a permanent "one worktree per session" rule.
311 (§ Choose topology from current authority.)
312- **One physical checkout gets one writer; parallel agents and sessions stay read-only.** A topic
313 branch inside the same checkout does not isolate the shared working files, current branch, or
314 index. Writer ownership comes from the repository's task/coordination contract, not a guessed
315 file list. If ownership is unclear or another writer is active, do not mutate the checkout.
316 (§ Shared checkout and concurrent sessions: one writer — also governs the two "parallel session"
317 bullets below.)
318- **Commit before switching and push WIP early.** Prefer a remote-backed commit over stash
319 juggling, but preserve a higher-authority narrow stash exception; never use an unscoped stash
320 to make a dirty checkout look ready.
321 (§ Parallel / multi-branch work; § Push work-in-progress branches early.)
322- **Worktrees are explicitly authorized, named exceptions — not the standing default.** They
323 isolate working files, `HEAD`, and index but still share refs, stashes, object storage, config,
324 and hooks, and do not copy ignored dependencies. When approved, a linked worktree is safer than
325 an invisible independent clone but remains a separately audited retirement target.
326 (§ Audit every authorized worktree before retirement.)
327- **Handoff and merge by exact commit, then finish with an AND gate.** Record branch, local `HEAD`,
328 and fresh remote tip; require them to equal the handoff SHA. Direct merges name that SHA, not the
329 branch. Hosted merges use an expected-head-SHA precondition when available, or an immediately
330 preceding hosted head readback that must still equal the handoff SHA. Every session-owned byte
331 must be in that remote-backed commit, and every residual path must be enumerated and attributed.
332- **A process snapshot is not a lock, and a merge is not cleanup authority.** Any scheduler that
333 can write this checkout counts as a writer even when its paths are disjoint. Before Git mutation,
334 the project's existing coordination must prove it quiescent and transfer exclusive ownership;
335 without that mechanism, stay read-only and report the gap. Do not stop, reconfigure, or invent a
336 lease for automation under this generic Skill. Retire refs or checkouts only through separately
337 authorized Mode E evidence. (§ Known automated writers are not session-owned WIP.)
338- **Confirm the current branch before committing** (`git branch --show-current`) — a fix committed
339 onto the wrong feature branch is invisible to its real PR and easy to lose on cleanup.
340 (§ Confirm the branch before every commit — including why removal from the wrong branch waits
341 for Mode E.)
342- **Never race another writer with checkout-relative mutation.** If another writer is active, stop
343 until the repository's coordination system transfers exclusive write ownership. After transfer,
344 name the exact ref and object when repairing or advancing state; do not rely on whichever branch
345 happens to be checked out. `reset --hard`, `merge`, and `rebase` all act on *whatever is checked
346 out at the instant they run*. Use checkout-independent forms for ref repair when they match the
347 authorized outcome:
348 ```bash
349 git branch -f <branch> <target> # instead of: switch <branch> && reset --hard <target>
350 git fetch origin <branch>:<branch> # fast-forward a branch you are not on
351 git push origin <sha>:refs/heads/<branch>
352 ```
353 Real incident: a `reset --hard origin/main` issued while another session still owned the checkout
354 landed on that session's feature branch and moved it back two commits. The correct first action is
355 to stop and transfer ownership; once transferred, an explicitly targeted ref repair avoids making
356 checkout position part of the operation.
357- **If a parallel session previously switched the shared tree and stranded your uncommitted work,**
358 do not mutate it until that session is quiescent and exclusive ownership has transferred. Then
359 follow the incident-only relocation procedure in § Recover stranded work after a parallel session
360 switched the shared tree: prove your files match
361 across bases, commit only explicit paths, and restore the prior branch before handing ownership
362 back. Branch deletion remains a separately authorized Mode E action.
363- **The inverse case: another session's *commit* lands on your branch, and every check you already
364 run stays green.** In a shared checkout, a commit a sibling session makes while `HEAD` sits on
365 your branch becomes a parent of yours and ships inside your PR. `git branch --show-current` names
366 your branch, the tree is clean, and `git diff --cached --name-status` shows exactly your paths —
367 all true, all blind, because their work left the index the moment they committed. It appears only
368 in the branch's cumulative range against the base you branched from. Detection is read-only, so
369 run it before every push and before opening any PR:
370 ```bash
371 base=<the base SHA you recorded when you created the branch>
372 git rev-parse --verify "$base^{commit}" # must print a SHA — see below before trusting the rest
373 git log --oneline "$base"..HEAD # every commit here must be yours
374 git diff --name-only "$base" HEAD # every path here must be yours
375 ```
376 **The verify line is load-bearing.** An **empty** `$base` turns `"$base"..HEAD` into `HEAD..HEAD`:
377 no output at exit 0, indistinguishable from "no foreign commits". That silent case is the one the
378 guard exists for; § A foreign commit adopted onto your branch has it and the louder one measured.
379 And **"yours" is not derivable from Git**: in a shared checkout both sessions write the same
380 author and committer, so no flag separates them. It comes from the SHAs you recorded as you
381 committed. If you cannot say which commits are yours, stop and ask — the repair deletes a commit,
382 so a guess here is the loss this skill exists to prevent.
383 Record that base SHA when you branch — deriving it later reads a cached remote ref, and the fetch
384 that would refresh it is itself ownership-gated § A foreign commit adopted onto your branch.
385 **A foreign commit in that range is evidence another writer was in this checkout, so repair is
386 not yours to start.** Stop; the ownership rules above apply unchanged. Once ownership has
387 transferred, repair is a history rewrite of *your* branch — `git rebase --onto` checks out the
388 branch it rewrites — so it runs the existing sequence rather than a shortcut: the applicable Mode B
389 evidence path, then `git branch backup/pre-rewrite <your-branch>` (**Snapshot before any history rewrite**
390 — this is what makes the rebase reversible), then `git branch rescue/foreign-<short-sha> <foreign-sha>`
391 (this preserves *their* work, a separate obligation and a different ref), then
392 `git rebase --onto "<foreign-sha>^" "<foreign-sha>" <your-branch>` — **onto the foreign commit's
393 parent, never onto the base**, because `--onto "$base"` discards everything before the foreign
394 commit, your own earlier commits included, and exits 0. Then re-run the detection above: the
395 rebase's exit code does not tell you whether it took something of yours with it. Retiring either
396 ref afterwards requires Mode C/E deletion-grade evidence, not a guess. Full procedure,
397 and why the two obvious "did their work survive?" probes return the wrong answer, in **A foreign
398 commit adopted onto your branch** in
399 [references/prevention_practices.md](references/prevention_practices.md). Real incident: a
400 sibling session committed while `HEAD` sat on a freshly created branch; the PR carried that
401 session's in-progress work, and the only signal was a repo validator reporting two changed
402 components when the author had touched one.
403- **If a parallel session is *actively* writing the shared tree, all repository mutation stops.**
404 Do not `switch`, `add`, `reset`, create commits with a temporary index, update refs, or push. Use
405 the repository's coordination system to quiesce that writer and transfer exclusive ownership; if
406 none exists, report the gap and preserve the current evidence. Once you are the sole writer, an
407 object-store-only commit can keep attributable foreign WIP out of the shared index and working
408 tree. Freeze every candidate as the exact Git entry tuple `(mode, object ID, path)` — bytes alone
409 are insufficient because `100755`, `120000`, and `160000` carry executable, symlink, and gitlink
410 behavior. The safest source is an immutable candidate commit:
411 ```bash
412 candidate_ref=<immutable-candidate-commit-oid>
413 candidate_path=path/to/file
414 candidate_entry=$(git ls-tree "$candidate_ref" -- "$candidate_path")
415 candidate_mode=$(printf '%s\n' "$candidate_entry" | awk 'NR == 1 { print $1 }')
416 candidate_oid=$(printf '%s\n' "$candidate_entry" | awk 'NR == 1 { print $3 }')
417 test -n "$candidate_mode" && test -n "$candidate_oid" || exit 1
418
419 candidate_index=$(mktemp /tmp/git_safety_candidate_index.XXXXXX)
420 export GIT_INDEX_FILE="$candidate_index" # the tree's real index is untouched
421 git read-tree origin/main # start from the pushed base, not the dirty tree
422 git update-index --add --cacheinfo "$candidate_mode,$candidate_oid,$candidate_path"
423 tree=$(git write-tree)
424 commit=$(git commit-tree "$tree" -p origin/main -m "…") # HEAD does not move
425 unset GIT_INDEX_FILE
426 rm "$candidate_index"
427 git push origin "$commit":refs/heads/<branch> # open the PR from here
428 ```
429 For an owned temporary **regular file** that is not yet in an immutable commit, derive its intended
430 mode explicitly (`100755` when executable, otherwise `100644`) and hash its bytes; fail instead of
431 applying that route to a symlink or submodule. For those entry types, first freeze an immutable
432 candidate commit and copy its mode/object tuple as above. Never source an entry from a shared path
433 that another session is editing. The sequence reads and writes only the object store and a
434 throwaway index, so `git status` in the shared tree is byte-for-byte unchanged. It is a
435 sole-writer preservation technique, not permission to mutate while someone else owns the repo.
436 `commit-tree`
437 does not run the normal `git commit` hook path: execute the repository's exact pre-commit/security
438 gates against the candidate before push, and still let pre-push run. Use it only after ownership
439 transfer, when preserved foreign WIP makes checkout switching or shared-index staging unsuitable.
440- **A bare `git commit` snapshots the *whole* index, not just what you staged — and a commit that
441 bypassed the index leaves a trap in it.** Moving the **current** branch without updating the
442 shared index advances HEAD while the index stays on its old baseline — via `commit-tree` +
443 `update-ref` on that branch, or a `git commit` through a temporary `GIT_INDEX_FILE`. (The
444 sole-writer push-to-another-branch path above moves no *local* ref, so it leaves no drift.) Every
445 file the new commit introduced then shows as a *staged deletion* (`git status` prints `D `
446 lines plus matching `??` untracked entries). `git commit -- <path>` neither creates nor repairs
447 this drift — it only updates its own paths. The drift detonates on anyone's next bare
448 `git commit`: that commit snapshots the entire index, turning the phantom deletions real —
449 delivered files vanish from HEAD while the working tree looks untouched. Real incident:
450 a 24-file delivered directory sat in that window after a temporary-index commit; one bare commit
451 by a parallel session would have deleted it from the branch tip, and the only sign anywhere was
452 `D ` lines in `git status`. Two obligations follow. **Whoever advanced the branch past the index
453 re-syncs immediately** — `git diff --cached --name-status`, then `git restore --staged --
454 <the paths the commit touched>` until those paths no longer appear in the diff (a parallel
455 session's own staged entries are theirs, not yours to clear). **And before any bare commit on a
456 shared tree, read that same diff as your blast radius** — every entry, `D` lines included, must
457 be one you intended; an entry you don't recognize means stop, not commit.
458 (§ Commit-scope hygiene.)
459- **Before any rebase or branch-delete, run the applicable Mode B evidence path.** Use the full
460 loss audit only when every worktree/ref/tag/stash/dangler it enumerates is in evidence scope;
461 otherwise use the authorized checkout/ref's scoped checks and limit the safety claim accordingly.
462- **Before bumping a shared version/lockfile, check the base's current value** so two parallel
463 branches don't both claim the same bump (a silent collision that blocks the later change from
464 shipping). (§ Version / lockfile collisions between parallel branches.)
465
466## Mode E — Retire worktrees, stashes, and branches safely
467
468The opposite worry from Mode A: not "I lost something" but "these leftovers are piling up —
469which can I destroy?" Deleting is trivial; **proving each item is superseded is the work**.
470Start from the Outcome contract. For an exhaustive audit or unknown target, run checkout discovery;
471for one named worktree/branch, stay in its owning repository. Run `git_loss_audit.sh` only when all
472worktrees/refs/tags/stashes/danglers it enumerates are inside evidence scope; treat inspect-only
473objects as report-only, keep explicitly excluded collaborator resources out of both the retirement
474plan and its terminal counts, then retire only the named targets. If any enumerated surface is
475excluded, do not run the full loss audit or an `--all-refs` export; use checkout/ref-scoped checks
476and targeted exports instead:
477
478**Step 1 — classify each leftover: live WIP, or superseded draft?** Evidence ladder, strongest first:
479
4801. **Fresh authority plus trial merge** — refresh the base and exact branch tip, then run
481 `scripts/git_verify_branch_merged.sh`. An ancestor/content-contained verdict is deletion-grade
482 evidence. If it returns NEEDS REVIEW, continue down this ladder; do not convert uncertainty to
483 MERGED with a weaker heuristic.
4842. **`git cherry <base> <branch>` is a hint, not a verdict.** A `-` proves that one patch-id is
485 upstream; a `+` does not prove missing work because squash merges deliberately create a new
486 patch-id. Never rescue or delete a whole branch from this output alone.
4873. **Same-file supersession check** — for a stash or `+` commit touching files that were later
488 reworked on the base: extract its version of the file and compare with the base's current
489 version (`git show <ref>:<path> | wc -l` vs `git show <base>:<path> | wc -l`, then spot-diff).
490 If the base's version is a **superset** (has everything the leftover has, plus later work),
491 the leftover is a superseded draft. Real case: a stash labeled "unfinished dev" held a 1128-line
492 renderer; main's version was 1151 lines — the same functions *plus* a later feature parameter.
493 Restoring that stash would have been a regression, not a recovery.
4944. **Function/marker-level probe** — grep the base for the leftover's distinctive additions
495 (`def new_helper`, a constant, an error string). All present on the base → superseded.
496 This catches "absorbed into a refactor" cases where file shapes changed too much for rung 2.
497
498Anything you cannot prove superseded stays alive (same safety bias as Mode C: a false "superseded"
499loses work; a false "still live" costs a branch name). One warning that changes verdicts: **the
500leftover's label is not evidence** — a stash named "unfinished development" can be a fully-landed
501early draft; judge content against the current base, never the name. Worked examples of the
502rungs (including the squash-artifact and absorbed-into-refactor cases):
503**[references/merge_verification.md](references/merge_verification.md)** § Supersession triage.
504
505**Step 2 — after deletion authority exists and immediately before deletion, preserve exactly what
506that deletion threatens:**
507
508```bash
509# Targeted branch cleanup: prefer the narrow export.
510scripts/git_export_before_drop.sh --branch <branch> --out <external-backup-dir>
511# Pin only an authorized dangling SHA; leave unrelated danglers report-only.
512git update-ref refs/dangling-backup/<sha> <sha>
513# Full ref topology: only when every captured ref is explicitly authorized.
514scripts/git_export_before_drop.sh --all-refs --out <external-backup-dir>
515scripts/git_export_before_drop.sh --verify-current <external-backup-dir>/all-refs.bundle
516```
517
518The targeted `update-ref` reaches only the authorized dangling commit. If every reported dangler is
519in scope, the whole-set `git_preserve_danglers.sh` may replace it. Prefer repeated `--branch` options
520for named branch/worktree retirement. `--all-refs` captures branches, tags, stashes, hidden backup
521refs, and linked-worktree HEAD refs, so it is valid only when that whole captured set is authorized;
522add `--all-stashes` only when stashes are also deletion targets. `--verify-current` is the final
523compare-and-swap gate: it exits 1 if any recorded ref moved or disappeared. Refresh remote authority
524before it, and rebuild the bundle on any mismatch. Keep backups outside the repository; never turn
525one branch into a repo export.
526
527For a multi-branch "only one main" cleanup while other sessions may still commit or open PRs, read
528**[references/merge_verification.md](references/merge_verification.md)** § Converging many branches
529to one main through single-writer windows before Step 3. While another writer is active, that route
530is read-only: fetch, object/ref creation, bundle export, push/PR, and deletion wait for existing
531coordination to prove quiescence and transfer exclusive ownership through final readback. The
532reference adds the moving-ref inventory, dirty-WIP preservation, immutable-candidate, duplicate-PR,
533and final branch-count gates that a single-branch retirement does not need.
534
535**Step 3 — destroy, in the safe order:**
536
537- Stashes: drop from the **highest index down** (`drop stash@{2}` before `stash@{1}`) — indices
538 shift as you drop, and top-down keeps every number meaning what your backup filenames say.
539- Linked worktrees: require an empty `git -C <path> status --porcelain=v1 --untracked-files=all`,
540 then inventory ignored paths separately with `--ignored`. A normal clean status hides `!!`
541 files, and no bundle can preserve them; copy out anything not proven reproducible, preserve its
542 relative pat
543
544…(truncated)