- Mental model: nothing is gone until gc runs
- The authoritative "is anything at risk" check
- Linked worktrees and detached worktree HEADs
- Ladder step 1 —
git reflog(first move, ~90% of recoveries) - Ladder step 2 — dropped stashes
- Ladder step 3 — detached-HEAD work
- Ladder step 4 —
git fsckfor true orphans - Preserve: pin danglers so gc can never take them
- Triple-backup a critical commit
- Widen the safety window (config)
- Destructive-operation safety (reset/force-push/rewrite)
- Sources
A commit is an immutable object in .git/objects. Deleting a branch, reset --hard, a bad
rebase, or stash drop only removes a reference to the commit — the object itself survives
until git gc prunes unreachable objects. The reflog keeps a ref alive for ~90 days
(reachable) / ~30 days (already unreachable), which is why same-day recovery almost always
works. So the recovery mindset is: find the dangling object, point a ref at it, done. The
one thing that permanently loses work is gc running while nothing references it — which is why
"preserve before cleanup" (below) matters.
Before anything else, answer the only question that matters — is any committed or uncommitted state present only locally? When every worktree/ref/tag/stash/dangler enumerated by the full audit is inside the declared evidence scope, check every place local work hides:
scripts/git_loss_audit.shIf any enumerated surface is excluded, the script is out of scope because it has no exclusion flag.
Use the authorized checkout/ref's own status, HEAD, git log HEAD --not --remotes, and exact
remote-ref lookup instead, and state that other worktrees/refs/tags/stashes/danglers were not audited.
- A completely empty verdict = no reported local state is stranded off-remote. That means no local-only commits, dirty/unavailable worktrees, stashes, or dangling commits.
- Any reported item = inspect it before cleanup. Local-only commits and dirty/unavailable worktrees make the script exit 1; stashes/danglers stay exit 0 but still need triage or preservation before branch, stash, or worktree deletion.
Why the script instead of only git log HEAD --branches --tags --not --remotes: that command
catches the current detached HEAD and tag-only commits, but misses a detached HEAD in a different
linked worktree and every uncommitted tracked/untracked file. The script enumerates all worktree
HEADs and inspects each checkout directly.
Do not substitute git status or ahead/behind counts for this — they answer different
questions. To confirm a single commit is safe on a remote:
git branch -r --contains <sha> # lists remote branches that contain it (empty = local-only)Treat each authorized path from git worktree list --porcelain as a separate working tree. Run
status with git -C <path> only against paths inside the evidence scope. A detached linked worktree
can carry a unique commit even when every named branch is on a remote; the full loss audit includes
those HEADs explicitly, so do not run it when another linked path is excluded.
Before removal, require empty tracked/untracked status and a separate ignored-file inventory,
exact HEAD capture, content containment proof against a fresh base, a verified targeted bundle of
the worktree's branch or collision-checked recovery ref, and current-session deletion authority.
Only then use non-forced git worktree remove, followed by path/registration/ref postcondition
checks. The bundle preserves Git objects, not ignored files; copy any ignored item that is not
proven reproducible and verify it against a recorded pre-removal
content hash. Freeze the complete ignored path/type/hash-or-link-target manifest, including
disposable entries. After authority, repeat both status checks and rebuild that manifest; any
added, missing, or changed entry aborts, and the removal command must come next. Recheck every
surviving copy afterward. The complete gate lives in
merge_verification.md § Worktree retirement.
Any "I lost a commit / reset too far / bad rebase" starts here:
git reflog --date=iso | head -40Each line is a past HEAD position with its sha and what moved it (commit, checkout, reset,
rebase -i (finish), …). Find the sha of the state you want back, confirm it, then recover
onto a new branch (never reset your live branch onto it):
git show <sha> # CONFIRM: is this the content you want?
git switch -c rescue/<name> <sha> # or: git branch rescue/<name> <sha>Branch-specific reflogs exist too: git reflog show <branch> recovers where a specific branch
tip used to point (e.g. before a force-push clobbered it).
git stash drop / git stash pop (which drops on success) removes the stash ref but leaves the
underlying commit dangling. git stash list won't show it; find it via fsck:
git fsck --no-reflogs --unreachable | grep commit # or: git fsck --dangling
git show <stash-sha> # verify it's the stash you lost
git stash apply <stash-sha> # re-apply it, or:
git branch rescue/stash <stash-sha> # park it on a branchStash commits have a distinctive message (WIP on <branch>: …), which helps identify them among
fsck output.
The third parent — untracked files a stash silently carries. A stash made with
git stash -u (or -a) stores untracked files in a third parent commit (stash@{N}^3),
and git stash show -p does not display them — it only shows the tracked diff. Two
consequences that bite in real recoveries:
-
Inspecting: judging a stash by
stash show -palone under-reports what it holds. Check for the third parent explicitly, and list what's inside:git rev-parse -q --verify 'stash@{0}^3' && git ls-tree -r --name-only 'stash@{0}^3'
-
Exporting: a
.patchbackup of the stash loses the untracked half. Export both parts — patch for the tracked diff,git archivefor the third-parent tree:git stash show -p --binary 'stash@{0}' > stash0.patch git archive 'stash@{0}^3' -o stash0-untracked.tar # only if ^3 exists
scripts/git_export_before_drop.shdoes both automatically for every stash it exports. (Real case: a "finish later" stash carried 10 untracked files — 929 insertions including a 545-line test file — that a patch-only backup would have dropped without a word.)
Index-shift trap when dropping several stashes: indices renumber on every drop —
after drop stash@{0}, the old stash@{1} becomes stash@{0}. Drop from the highest
index down so each name still means what your backups say it means.
Committing while on a detached HEAD (after git checkout <sha>), then switching away, orphans
those commits — they belong to no branch. Reflog remembers them:
git reflog | grep -i 'HEAD@' # find the detached commits you made
git switch -c saved-work <sha> # give them a homePrevent the loss entirely: the moment you make a commit you care about on a detached HEAD,
git switch -c <branch> HEAD before doing anything else.
When reflog doesn't reach it (reflog expired, or the commit was never HEAD on this clone), fsck walks the object store directly:
git fsck --dangling # dangling commits/blobs/trees not reachable from any refInspect candidates with git show <sha>. Dangling blobs can be a single lost file:
git show <blob-sha> > recovered_file.
Dangling commits are recoverable only until gc runs. For a specific inspected commit that is inside the preservation scope, pin exactly that SHA under a hidden ref namespace—a referenced object is never collected:
git update-ref refs/dangling-backup/<sha> <sha> 0000000000000000000000000000000000000000
git show refs/dangling-backup/<sha> # confirm the exact object that was pinnedUse scripts/git_preserve_danglers.sh only when the Outcome contract explicitly includes every
dangling commit it will enumerate; it is a whole-set helper, not the default. Why a hidden
refs/dangling-backup/* and not git stash store? These refs don't appear in git branch or
git stash list, so they protect the authorized objects without turning branch/stash lists into
noise, and you can delete them once their content is verified safe elsewhere.
For a specific commit you must not lose (e.g. real unpushed work found by the at-risk check), one
copy isn't enough — a single disk failure or a single gc shouldn't be able to take it. Give it
three independent homes:
git branch backup/<name> <sha> # 1) local branch
git push origin backup/<name> # 2) remote branch (survives disk loss)
git format-patch -1 <sha> --stdout > <name>.patch # 3) patch file (survives repo loss)Now the commit survives losing any one of: the working tree, the remote, or the whole repo.
The default reflog window is generous but finite. For repos where recovery matters, extend it:
git config --global gc.reflogExpire "180 days"
git config --global gc.reflogExpireUnreachable "90 days"Recovery is easiest when the operation that "lost" the work was itself reversible. Prefer:
- On already-pushed history,
git revert, notgit reset. Revert adds an inverse commit; reset abandons commits that teammates may have based work on. - If you must force-push, use
git push --force-with-lease, not--force.--force-with-leaserefuses the push if the remote moved since you last fetched — it won't silently clobber a teammate's (or another agent's) commits. - Before any history rewrite (
rebase,filter-repo,reset --hard), snapshot first:git branch backup/pre-rewrite(and run the at-risk check). Ten seconds; fully reversible.
- Pro Git — Data Recovery (reflog / fsck / dangling objects): https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery
- Git
worktreedocumentation: https://git-scm.com/docs/git-worktree - Git
bundledocumentation: https://git-scm.com/docs/git-bundle - Git
merge-treedocumentation: https://git-scm.com/docs/git-merge-tree