Everyone who works with Git carries one reassuring sentence in their head: "it'll be in the reflog anyway." That sentence is as correct as it is incomplete. A reflog records where the tips of refs have moved — in other words, everything that happens after a piece of work becomes a commit. Work that disappears before it becomes a commit sits in a place the reflog never looks at, and how long it sits there depends on an entirely different setting.
So this week I sat down and put numbers on it, because there is a garbage collector running on my machine every morning at 04:30, and that collector deletes half-finished working directories. I wanted to know the exact distance between "it deletes them" and "data is gone."
Here is the current inventory in the blog repository:
$ /usr/bin/git count-objects -v count: 1179 size: 30640 in-pack: 49845 packs: 21 size-pack: 336528 prune-packable: 34 garbage: 0 size-garbage: 0 49,845 packed objects and 3,675 commits on origin/main. Now the interesting part — how many of those objects are visible from no branch tip and no tag:
$ /usr/bin/git fsck --unreachable --no-progress | wc -l 2089 $ /usr/bin/git fsck --unreachable --no-reflogs --no-progress | wc -l 2161 One flag is the only difference between those two commands, and the gap between them is 72 objects. Those 72 are the work still counted as "protected" thanks to the reflog. Behind the remaining 2,089, there is nothing at all. By type:
reflogs as roots with --no-reflogs 1723 blob 1729 blob 103 commit 118 commit 263 tree 314 tree 1,723 blobs. Rejected drafts, earlier states of --amended commits, leftovers from deleted working directories. All of them on my disk, all of them readable, none of them with a name.
What the collector deletes, and what it doesn't
Some context: Claude Code agents open a git worktree under the repository for each task, and those pile up until the disk fills. At the end of September that pileup took my Mac's data volume to 100%. The collector I wrote that day runs under launchd every day at 04:30 and deletes a worktree that passes three gates: no tracked changes, not newer than two days, and no commits on HEAD that never reached a remote.
Its running total, from its own log:
$ grep -c 'SILINDI' ~/.claude/scripts/worktree-gc.log 176 $ grep 'SILINDI' ~/.claude/scripts/worktree-gc.log | grep -c mustafaerbay 29 $ grep OZET ~/.claude/scripts/worktree-gc.log | tail -1 2026-11-15 04:30:16 OZET silindi=4 korundu=29 kazanc=404MB bosalan=20GB dry=0 176 working directories deleted, 29 of them in this blog repository. In the comment at the top of the script I had written, in my own hand: "deleting is not data loss — the branch ref and the objects stay in the main repo." I did not want to run it for another year without testing how much of that sentence is true.
Three loss modes, three different answers
I set up three loss scenarios in a clean lab. Same repository, same minute, same Git version: /usr/bin/git 2.54.0 (Apple Git-157). To keep stray settings out of the results I pointed GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at /dev/null — a line in my own ~/.gitconfig had no business skewing the numbers.
The scenarios: (1) write a file, never git add it, delete it. (2) git add it, don't commit, then take a git reset --hard. (3) commit it, then step back with git reset --hard HEAD~1.
A1 untracked : expected blob 9d8e786752d6084b87d0712679878e243c8f8f47 -> object present? NO A2 staged : blob 756d8d4457346ad95d7fb3d51222ece2352462b3 -> object present? YES A3 commit : commit d8c76e02b01d09bd2ab78033af99fd98e9fcea17 -> object present? YES The first line settles the whole argument. A file you never git added leaves no trace on the Git side — no blob, no name, no size. git hash-object can compute what its name would be, but there is no object behind that name. The real form of the recovery question in Git is not "do I have a backup," it is "did this work ever become an object."
I had bumped into that sentence once before: when I set the assume-unchanged bit and found seven of thirteen commands ignoring it, I noted in a single line that the lost change had nothing in the reflog either. That was an aside then; this article is that aside, measured.
In the second and third scenarios the object is there. But does the reflog see them?
$ git reflog --date=iso 2c996a2 HEAD@{...}: reset: moving to HEAD~1 d8c76e0 HEAD@{...}: commit: silinecek commit 2c996a2 HEAD@{...}: reset: moving to HEAD 2c996a2 HEAD@{...}: commit (initial): ilk commit The third scenario's commit is right there on the second line, by name. The second scenario's blob appears on no line at all — git reflog | grep <blob> returns zero. That is the documented behaviour: the reflog docs say reflogs record when "the tips of branches and other references were updated." The index is not a reference. git add is not a ref movement. So it has no place in the reflog.
What follows from this is that the two tools are not backups for each other:
$ git fsck --unreachable unreachable blob 756d8d4457346ad95d7fb3d51222ece2352462b3 $ git fsck --unreachable --no-reflogs | sort unreachable blob 65271b600c21b07fa5711bec44cc7fc13077b0ad unreachable blob 756d8d4457346ad95d7fb3d51222ece2352462b3 unreachable commit d8c76e02b01d09bd2ab78033af99fd98e9fcea17 unreachable tree 1bd47fcd374c27eda598a1ea6c1c86faaa831e5d (fsck does not sort its output; I piped it through sort to make it readable.)
One line, four lines. The default fsck counts the reflog as a root too — its own documentation puts it as "all reflogs (unless --no-reflogs is given) as heads." So a commit the reflog protects is not even lost in fsck's eyes. That is usually why people are surprised that "fsck found nothing": what they are looking for is still in the reflog and will come back from git reflog in a single line.
The reverse holds too, and that is where it bites. Work that never became a commit lives where git reflog will never show it; only fsck finds it.
To see that the content really comes back, cat-file is enough:
$ git cat-file -p 756d8d4457346ad95d7fb3d51222ece2352462b3 staged taslak If you'd rather not copy SHAs by hand, fsck has a mode that writes to disk:
$ git fsck --lost-found $ find .git/lost-found -type f .git/lost-found/commit/d8c76e02b01d09bd2ab78033af99fd98e9fcea17 .git/lost-found/other/756d8d4457346ad95d7fb3d51222ece2352462b3 Commits land under commit/, everything else under other/. For blobs the file's name is the SHA and its content is the blob itself — in the docs' words, "the contents are written into the file, rather than its object name." Which means you can walk into other/ and skim your drafts with head. Staring at a directory full of nameless files is not a pleasant experience, but it works.
A careful reader will stumble here: --unreachable gave one line above, yet --lost-found wrote the reflog-protected commit to disk too. Not a contradiction — a separate behaviour, and one the docs don't mention. I separated the two in a clean repository:
$ git fsck --unreachable --no-progress (no output) $ git fsck --lost-found --no-progress dangling commit 6a9eca0b9f4a103672772c567ac949f53682b5f4 $ find .git/lost-found -type f .git/lost-found/commit/6a9eca0b9f4a103672772c567ac949f53682b5f4 --unreachable finds nothing, because the commit is reachable from the reflog. --lost-found looks at dangling objects and does not treat reflogs as roots; it both lists that commit and writes it out. In practice this helps: a commit you know is in the reflog but cannot find the right line for, --lost-found hands you as a file.
A deleted worktree: prune takes the reflog with it
Now to my own scenario. A main repository, one linked worktree, and inside that worktree a commit, a staged file, and a file that was never added. Then I removed the directory with rm -rf and ran git worktree prune — exactly what my collector does.
### produced inside the worktree where are the objects? objects dir: .../ana/.git/objects per-worktree HEAD reflog: .../ana/.git/worktrees/is/logs/HEAD ### worktree directory DELETED (rm -rf) + git worktree prune worktrees/ admin dir left: 0 entries per-worktree reflog file: GONE ### looking from the main repo, are the objects there? commit 15edfb1... : PRESENT staged blob d0b5675... : PRESENT untracked 42a97a7... : GONE does branch 'is' still exist: YES Three things at once. First: the object database is shared. A worktree has no objects directory of its own; it uses the main repository's — the repository-layout doc says the objects entry is redirected to the common directory when $GIT_COMMON_DIR is set. So every object you produce in a worktree stays in the main repo even after the worktree is gone. On that point the sentence in my script's comment was right.
Second: git worktree prune deletes that worktree's HEAD reflog. The git-worktree doc does not say so; it only says it removes the information under $GIT_DIR/worktrees for worktrees whose working trees are missing. The reflog file lives inside that directory (worktrees/is/logs/HEAD), so it goes along with it.
I wondered which of the two steps actually does the deleting, so I separated them — the reflog file sits inside the main repository, not inside the directory being removed:
1) while the worktree is alive : PRESENT 2) after rm -rf, before prune : PRESENT $ git worktree list .../ana 9599e0e [main] .../is b667770 [is] prunable 3) after prune : GONE rm -rf doesn't touch the reflog; at that stage worktree list still lists the worktree and marks it prunable. It is prune that takes the reflog. That's something the docs don't state, learned by testing; behaviour like this can shift between versions, so it is worth a single check on your own.
Third: the branch ref survives. prune only collects administrative data, and refs/heads/is stays put. So the committed work in a worktree my collector deleted is still reachable by branch name — that isn't even recovery, git checkout is does it.
So what happens when the branch goes too? I ran git branch -D is and looked again:
--- fsck --unreachable --- unreachable blob 64ce9a3... unreachable blob d0b5675... unreachable tree b20221c... unreachable commit 15edfb1... --- fsck --unreachable --no-reflogs --- (the same four lines) The two outputs are identical. This is the exact opposite of the first half of the lab: there is no reflog left anywhere, so --no-reflogs changes nothing. prune took the per-worktree reflog, branch -D took the branch reflog; the main repo's HEAD reflog has no trace of that branch, because those commits were made under a different HEAD. This is the scenario where git reflog is of no use whatsoever and fsck is the only road. The rule in one sentence: without a ref the object is invisible, but it goes on existing.
stash drop: ref and reflog both go, the commit stays
There is a fourth loss mode, and it is the most common one: git stash drop. Dropping the wrong stash takes a second, and stash list never shows it again. Here too, measurement changes the picture.
First, what a stash actually is. With git stash push -u I set aside both a tracked change and a file that was never added:
stash commit: 49b98c1dcca45babfa71e4136bc55fc483ecd48e stash list: stash@{0}: On main: yarim is refs/stash reflog file: PRESENT files in the stash tree: t.txt third parent of the stash (untracked files): 39b709e2d270d395e3eae459a149283c1ad861c6 That last line is the most delightful detail in this article. A stash is a commit, and when invoked with -u it gets a third parent: a separate commit carrying the untracked files. The DISCUSSION section of the git-stash doc describes two parents — the working-directory tree, the first parent HEAD, the second parent the index — and never mentions a third. Like the worktree prune behaviour, this is something the docs don't state and a lab does. In other words git stash push -u is the one command that turns the "never became an object" file from scenario A1 into an object. If you want a SHA to exist for a file you never git added, that is the way.
Now drop it:
### stash drop stash list: '' refs/stash ref: GONE refs/stash reflog file: GONE stash commit object: PRESENT Three "gone", one "present". drop deletes both the refs/stash ref and its reflog file — the same situation as the deleted worktree: git reflog has nothing for you, but the object is in place. fsck shows three commits at once: the stash itself, the commit holding the index state, and the commit holding the untracked files.
Getting it back needs no stash list, because apply accepts a raw SHA:
$ git stash apply 49b98c1dcca45babfa71e4136bc55fc483ecd48e On branch main Changes not staged for commit: modified: t.txt Untracked files: yeni.txt Both the tracked change and the untracked file came back. t.txt's last line is in place, yeni.txt is in the directory. A dropped stash commit reopens in full — on one condition, that gc has not pruned it yet.
The practical upshot: if you dropped the wrong stash, don't panic. List the commits with git fsck --unreachable | grep commit, find the right one with git stash show -p <SHA>, then apply it. "I deleted the stash, it's gone" is wrong most of the time.
The window: two weeks, and it doesn't close unless gc runs
An object being present is not yet a reason to relax. The lifetime of what fsck finds depends on gc. The defaults are written down in config/gc.adoc, and they are all different from each other:
| Setting | Default | What it governs |
|---|---|---|
gc.pruneExpire | 2 weeks | age at which an unreachable object is pruned |
gc.reflogExpire | 90 days | lifetime of reachable reflog entries |
gc.reflogExpireUnreachable | 30 days | reflog entries pointing at unreachable commits |
gc.worktreePruneExpire | 3 months | administrative data of dead worktrees |
gc.cruftPacks | true | unreachable objects go into a separate pack |
I ran git gc in the lab and looked at what happened:
--- BEFORE gc --- --- AFTER gc --- count: 7 count: 0 in-pack: 0 in-pack: 7 packs: 0 packs: 2 pack-0d7c747...idx pack-0d7c747...mtimes <-- cruft pack marker pack-0d7c747...pack pack-0d7c747...rev pack-76f69dc...idx pack-76f69dc...pack pack-76f69dc...rev A2 blob after gc : PRESENT A3 commit after gc : PRESENT Two packs came out, one of them with a .mtimes file. That one is a cruft pack: unreachable objects are placed into a separate pack instead of being left as loose files, and each object's modification time is kept in the .mtimes file beside it, so the pruning decision can be made against that time. The mechanism arrived in 2.37; its becoming the default was announced in the 2.41 release notes: "Now the use of cruft packs has been made the default and no longer considered an experimental feature." After gc, fsck --unreachable still lists the same blob; being packed is not being lost.
Let me underline that one sentence, because it matters shortly: the pruning decision reads the object's modification time. Not the commit's date.
Close the window and the table changes:
$ git gc --prune=now A2 blob : PRUNED A3 commit : PRESENT reflog line count: 4 That asymmetry is the summary of this article. Same command, same moment: the work that never became a commit is gone, the work that did is still here. The only difference between them is those four reflog lines. The NOTES section of the git gc doc, listing what pruning leaves alone, names "the index, remote-tracking branches, reflogs" — a reflog entry is a root of reachability, so the commit survived while the blob had nobody. (The doc's other rule, that objects newer than the prune date are kept, cannot explain it: under now no object is newer than the prune date. What saved the commit was not its age but the reflog.)
In my real repository the trace looks like this:
$ stat -f '%Sm %N' -t '%Y-%m-%d %H:%M' .git/objects/pack/*.mtimes 2026-10-03 09:29 .git/objects/pack/pack-5b5e6d6...mtimes $ git reflog | wc -l 89 $ git reflog --date=iso | tail -1 6998be3b HEAD@{2026-08-11 08:45:28 +0300}: reset: moving to HEAD The oldest reflog entry is from 11 August, 96 days ago. gc.reflogExpire defaults to 90 days, and the first instinct is to say the entry should have been gone. Resist it — the 90-day clock is only consulted while gc runs. The repository's cruft pack file is dated 3 October, the last trace gc left here. From 11 August to 3 October is 53 days, so at that run the entry had not come close to 90. gc has not run since, nobody has looked at the clock again, and the entry sits there at 96 days old.
The lesson isn't in the number: when a deadline passes, nothing happens. These defaults are not an alarm on a timer, they are a criterion applied when gc runs. That's good news for me — the window doesn't close without my noticing. For the same reason there's bad news too: my .git directory has been carrying 2,089 unreachable objects for months, and some of them are the contents of rejected drafts.
I repeated the measurement with two Git versions: /usr/bin/git 2.54.0 and /opt/homebrew/bin/git 2.48.1. The one-versus-four line difference, the cruft pack, and the --prune=now asymmetry came out identical in both. Having two Gits on the machine changes nothing here; even so I called them by full path, because whichever git the shell resolves is the one doing the measuring.
Finding what you want among 1,723 blobs
Everything up to here is mechanism. In practice the problem is this: fsck hands you 2,089 lines of SHA and you are looking for one draft. I tried three approaches, on the real repository, with a word that appears inside a draft that genuinely got lost. The timings come from zsh's built-in time, the total column, run back to back on the same repository.
The first is the obvious one — stream them all and count with grep -c:
$ awk '$2=="blob"{print $3}' u.txt | git cat-file --batch | grep -c 'LaunchServices' 223 0.35 seconds, and it answers the wrong question. cat-file --batch pours every blob into a single stream; grep -c counts matching lines, not blobs. 223 does not mean "223 drafts," it means "this word appears on 223 lines." On top of that, blob boundaries are lost, so it can't tell you which object it was in. Speed buys nothing here.
The second is correct but slow — scan each blob separately:
$ awk '$2=="blob"{print $3}' u.txt | while read sha; do git cat-file -p "$sha" | grep -q 'LaunchServices' && echo "MATCH $sha" done | wc -l 94 94 blobs, 17.7 seconds. The right answer, but it spawns two processes per blob (cat-file and grep) — over three thousand of them for 1,723 objects.
The third uses a git grep capability most people don't know about — it takes blob SHAs directly as arguments:
$ awk '$2=="blob"{print $3}' u.txt | xargs git grep -l 'LaunchServices' 7801e0eb00c5c6b7a0192ecb6581c8ac1d00c580 6606d42ef25c403b992e418e73162c8099894f39 480ad0940c1c3fc7aeb56ea67a169dd2958a129b ... The same 94 objects, in 0.27 seconds. 65 times faster than the loop, and its output is directly usable: one object name per line. I'd reach for this first — except I didn't; the first thing I tried was the fast one that returns the wrong number. The scanning script itself produces findings, and whoever doesn't check what the finding counts puts it straight into the article.
I took a look at one of the objects it found:
$ git cat-file -p 7801e0eb00c5c6b7a0192ecb6581c8ac1d00c580 | head -4 --- title: "I Deleted 18 Apps; 661 MiB of Data Stayed on Disk" description: "I went looking for free space and found 79 leftovers..." category: "life" An article draft, on no branch tip. Its size comes from git cat-file -s: 22,462 bytes. Its date comes from inside the blob — the draft's own frontmatter carries publishDate: 2026-10-12. A blob has no date of its own; an object is just the hash of its content, and you cannot ask Git when a file was written. This is an intermediate version of a published piece, most likely the remains of a commit that got --amended after a review round. Still readable, and its name known only to fsck.
Lining up 103 unreachable commits by date
The blob side is solved by content search. On the commit side there's something better: a commit object carries its date and subject line, which means those 103 unreachable commits can be turned into a chronological list.
$ awk '$2=="commit"{print $3}' u.txt \ | xargs -n1 git show -s --format='%ci|%h|%s' | sort -r 1.1 seconds, 103 lines. The six most recent in my real repository:
2026-11-11 17:47:43 |24b21836| feat: yeni makale — tar'ın Yuttuğu Dosyayı pax... 2026-11-11 17:27:33 |105d570d| feat: yeni makale — Dosyayı tar Atladı, pax... 2026-11-11 17:05:22 |2931125d| feat: yeni makale — Arşivimde 3.214 Üye Saydım... 2026-11-11 17:04:11 |0b99f9c6| feat: yeni makale — Arşivimde 3.214 Üye Saydım... 2026-11-04 19:30:46 |c5c78013| feat: yeni makale — Kuralı Belgeye Yazdım... 2026-11-02 14:14:03 |3b948f14| feat: yeni makale — 499 Bayt İstedim... That list reads like the negative of my own publishing history. There are four commits on 11 November and two of them carry the same title: that day I changed an article's title twice and rewrote the commit twice. The published version became tar'ın Yuttuğu Dosyayı pax Aynı Arşivden Çıkardı; the Dosyayı tar Atladı attempt is waiting right here, unreachable. This is exactly the trail rebase and --amend leave behind.
The monthly distribution says more:
2 2026-05 60 2026-06 2 2026-08 22 2026-09 11 2026-10 6 2026-11 The oldest unreachable commit is dated 29 May 2026; today is 15 November, 170 days later. The temptation here is to say "so the two-week window has been exceeded twelvefold." I wrote exactly that. It is wrong.
I'm leaving the note in because this is precisely the kind of mistake the article warns about. Pruning does not look at a commit's date; it looks at the object's modification time. The two are independent. I tested it: I made a commit dated 29 May, rendered it unreachable, expired every reflog, then ran the prune:
commit date : 2026-05-29 10:00:00 +0300 object file mtime : 2026-11-15 after git gc --prune=2.weeks.ago : PRESENT A 170-day-old commit date survived a two-week prune, because the object itself had been written today. The list above tells me what was authored when, not what will be pruned when. A rebased or repacked object can carry an mtime months newer than its commit date.
The real record of prune ages lives in the .mtimes file beside the cruft pack. I looked into the repository's 3 October cruft pack:
$ git verify-pack -v .git/objects/pack/pack-5b5e6d6....idx | awk '/^[0-9a-f]{40} /{print $2}' | sort | uniq -c 450 blob 86 commit 183 tree 719 objects. So the gc on 3 October did not delete those unreachable objects; it moved them into a separate pack and kept them along with their mtimes — which is the entire reason cruft packs exist. Of today's 2,089 unreachable objects, 719 are waiting in there; the rest came into being after 3 October.
The practical reading: "two weeks" is not a timeout, it is a criterion applied to object mtimes whenever gc runs. No gc, no criterion; and when it does run, it reads file ages, not calendar dates. Leaving the recovery job for tomorrow on the grounds that "it'll still be there" is a gamble that is holding for now.
The gap in my own collector
The measurement produced a by-product. Here is my collector's first gate:
if [[ -n $(git -C "$wt" status --porcelain | grep -v '^??') ]]; then log "KORU/is $rel"; ((kept++)); continue fi The grep -v '^??' drops untracked files from the list. So if the only work in a worktree is a file that was never git added, the gate calls that worktree "clean" and lets it through:
$ git status --porcelain ?? yeni-taslak.txt $ git status --porcelain | grep -v '^??' (empty) -> gate 1 PASSES That is scenario A1 of the lab, exactly: since no object was ever created, neither fsck nor the reflog nor a branch ref helps once it's deleted. The collector's other two gates — the two-day age check and the unpushed-commit check — catch most cases in practice; an agent going two days without either committing or adding a file is a rare combination. But "rare" is not "never." Tightening the gate to look at the raw status --porcelain output instead of grep -v '^??' is a one-line change; the cost is that clean worktrees holding a single scratch file get protected too, which means a little more disk.
I'll leave that here as a lesson: the place where you noted "this is not data loss" in an automation deserves one measurement after you write the note. Mine was right for two of three cases.
This is not a backup: a clone brings none of it
Everything so far holds on a single machine. To test that, I cloned the repository on my own disk — but with local shortcuts disabled, forcing it to behave like a real network clone:
$ git clone --no-local --bare file:///.../mustafaerbay /tmp/klontest 4.7 seconds $ git -C /tmp/klontest count-objects -v count: 0 in-pack: 48661 packs: 1 $ git -C /tmp/klontest fsck --unreachable | wc -l 0 $ git -C /tmp/klontest reflog | wc -l 0 $ git -C /tmp/klontest cat-file -e 7801e0eb... ABSENT Zero unreachable objects, zero reflog entries. The draft I recovered a moment ago is not in the clone.
There is a trap in comparing object counts: in count-objects -v output, count and in-pack do not add up. The prune-packable: 34 line in that same output says 34 of the loose objects are already in a pack as well. The correct arithmetic is 49,845 + 1,179 − 34 = 50,990, and counting distinct objects directly with git cat-file --batch-all-objects gives the same figure. The clone holds 48,661, so the gap is 2,329.
That gap is larger than the 2,161 unreachable, and equating the two would be wrong. A clone doesn't just skip unreachable objects; it also skips objects behind refs it doesn't copy. Beyond refs/heads and refs/tags, my local repository has 67 refs/remotes, one refs/stash and one refs/codex; the clone has only 89 branches and one tag:
local clone 89 refs/heads 89 refs/heads 1 refs/tags 1 refs/tags 67 refs/remotes 1 refs/stash 1 refs/codex The practical consequence is blunt: the objects fsck finds exist only on that machine. git push never sent them, they are not on the server, not in a colleague's clone, not in CI's checkout. If your disk goes, nothing you could have recovered with fsck remains. I've written before about a ref-less object staying invisible on the wire, in the notes I wrote on 3,583 commits that a clone never brought along; here is the hardest form of the same rule — no ref, no travel.
So every recipe in this article is a second-chance mechanism, not a backup strategy. Carrying 2,089 unreachable objects on my disk does not save me from being unbacked.
Once you step outside a single machine
These recipes hold inside your own clone. Step outside it and five things change. None of them got a lab in this article, but anyone applying this in production should look at them first.
Git LFS. fsck recovers the LFS pointer, not the file. The content lives under .git/lfs/objects or on the LFS server, and that has its own pruning logic. If the blob you recovered is a 130-byte text file, look inside it before celebrating.
Bare repositories and the server side. In a bare repository core.logAllRefUpdates is off by default, so the second layer — the reflog — effectively doesn't exist there. On GitHub, GitLab or Gitea you cannot run fsck at all; a force-pushed commit is reachable only through the provider's API, for a limited time. The checklist above quietly assumes you have a local clone.
Submodules. fsck in the superproject does not descend into a submodule's object database. If the lost work is inside a submodule, the whole drill has to be repeated in that directory.
Scale, and --lost-found. fsck reads every object; on a multi-gigabyte repository that is not a cheap command. --lost-found can dump thousands of files into .git. And the xargs git grep recipe will hit the argument limit with thousands of SHAs and split into several invocations — the answer stays correct, but "one process, 0.27 seconds" no longer holds.
Leaked secrets, not just disk. Throughout this article I've framed unreachable objects as a disk cost. The sharper angle: a password that was committed and then "removed" stays readable for the whole window. The only thing that closes that window is git gc --prune=now — and if it was ever pushed, there is a separate job waiting on the server.
Recovery checklist
When a piece of work disappears, this is the order I go in:
- Was the file ever
git added? If not, leave Git alone and go to a filesystem backup or your editor's local history. Git holds no trace of that file. -
git reflogandgit reflog show <branch>. If what you want became a commit, the answer is most likely here and on a single line.git branch rescue <SHA>is enough to give it a ref. - Was it a deleted worktree? The branch ref may still be there:
git branch --all | grep <name>. If it is, no recovery is needed. - Was the wrong stash dropped? The commit is there even when
git stash listis empty:git fsck --unreachable | grep commit, identify the right one withgit stash show -p <SHA>, reopen it withgit stash apply <SHA>. A stash taken with-ubrings untracked files back too. -
git fsck --unreachable --no-reflogs. I reach for the variant that doesn't treat reflogs as roots, because I want to see what the reflog is protecting as well. Add--no-progressto keep the output readable. - Looking for a commit? Line them up by date:
git fsck --unreachable | awk '$2=="commit"{print $3}' | xargs -n1 git show -s --format='%ci|%h|%s' | sort -r. Under a second for 103 commits, and the day you want jumps out. - Looking for a blob? Search the content:
git grep -l '<pattern>' <blob SHAs>. Don't write a loop;git greptakes blob SHAs and is 65 times faster. - Bring it back: for content,
git cat-file -p <SHA> > rescued.mdx; for a commit,git branch rescue <SHA>. If you want a directory full of files,git fsck --lost-found. - Check the window:
git config --get gc.pruneExpire. Unset means two weeks. Don't rungit gc --prune=nowin that repository until the recovery is finished, and if an automation would run it, stop it.
The inverse of that last item is true as well. If you don't want to carry the contents of rejected drafts inside .git for months, git gc --prune=now cleans up exactly that — but knowingly, after you are sure there is nothing left to recover.
The remaining question
Git's recovery capability has three layers: refs, reflogs, and unreachable objects. Most writing covers the first two, because those are what daily life needs. The third layer comes into play on a person's worst day, and on that day there is no time to read documentation.
Underneath those three layers, though, there is a more basic line, and it runs exactly where git add passes. Git stubbornly keeps everything that became an object — ref deleted, worktree gone, reflog pruned, it still sits on your disk past the two-week threshold until gc runs again. For anything that never became an object it can do nothing at all, and it gives no warning about that.
The question to put to your own setup is this: which layer do your automations touch? My garbage collector doesn't touch two of them, and it believed it was protecting the third — the file that never became an object.
Writing this, I fell into my own trap twice. First I read the repository's commit count off a stale working copy. Then I looked at a 170-day-old commit date and wrote that the two-week window had been exceeded twelvefold; testing that pruning reads object mtimes rather than commit dates, I had to delete the sentence. Both were right answers to the wrong question — much like scanning 1,723 blobs with grep -c and getting 223. The hard part of recovery isn't remembering the commands; it's knowing what your number counts, and what your date is the date of.
Official Sources
- git-fsck documentation — definitions of
--unreachable,--dangling,--lost-found,--no-reflogs, and reflogs counting as roots by default - config/gc documentation — defaults for
gc.pruneExpire,gc.cruftPacks,gc.reflogExpire,gc.reflogExpireUnreachable,gc.worktreePruneExpire - git-reflog documentation — reflogs recording ref tips,
expiredefaults - git-worktree documentation —
pruneremoving administrative data under$GIT_DIR/worktrees, shared versus per-worktree files - Repository layout documentation —
objectsandlogsbeing redirected via$GIT_COMMON_DIR - Git 2.41.0 release notes — cruft packs becoming the default

