The git rm That Deleted 30,000 Files

A client kept their notes vault in git. Years of markdown. Meeting notes, runbooks, half-finished ideas — the kind of stuff you don’t think about until it’s gone.

They wanted to stop tracking one big folder. Reasonable. It didn’t belong in version control.

So they ran the obvious command:

git rm -r attachments/

Git chewed on it for a second and printed a wall of rm 'attachments/...' lines. Looked fine. Then git status showed something that made my stomach drop.

It wasn’t just the attachments/ folder.

deleted:    notes/2024/standup.md
deleted:    notes/2024/oncall.md
deleted:    runbooks/dns.md
... 30,206 more ...

The working tree was empty. ls came back nearly bare. 30,209 markdown files were gone from disk. Not staged-for-deletion-someday. Gone. Off the filesystem.

The investigation

The panic version of this story ends with a restore from last night’s backup and a lost afternoon. The calm version starts with one question: where does git actually delete from?

People think git rm means “stop tracking this file.” It doesn’t. git rm removes a file from two places at once: the index (staging area) and the working tree — your actual disk.

┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│ Working Tree │   │    Index     │   │   HEAD/Repo  │
│  (your disk) │   │  (staging)   │   │ (last commit)│
└──────┬───────┘   └──────┬───────┘   └──────┬───────┘
       │                  │                  │
  git rm ──► ✗ DELETES ──►│ ✗ stages delete  │ ✓ STILL HERE
       │                  │                  │
  git rm --cached ─────►  │ ✗ untracks only  │ ✓ STILL HERE
       ▼                  ▼                  ▼
   file gone          marked rm          recoverable

That last column is the whole ballgame. git rm touches the working tree and the index, but it does not touch your last commit until you actually commit the deletion.

Nobody had committed yet.

The files were sitting safe in HEAD the entire time, even as the disk looked like a crime scene.

Working TreeIndexHEAD (commit)wipedstaged rm30,209 safegit rm -r→ recovery lives in the column on the right
The deletion never reached the last commit — that's why this was survivable.

The aha

If HEAD still has every file, recovery is just “copy them back out of the last commit.” No backup tape, no remote clone, no heroics.

The fix

First, confirm the damage scope. Trust nothing, count everything:

git status --porcelain | grep -c '^D'
# 30209

30,209 staged deletions. Matches the missing files exactly. Good — nothing weird, just one bad command.

Now restore the working tree and reset the index straight from HEAD:

git restore --staged --worktree .
# or, on older git:
git checkout HEAD -- .

Verify the files actually came back on disk before believing it:

find . -name '*.md' | wc -l
# 30209

All present. git status clean. If HEAD had been corrupt, the same files were still in the remote — git fetch && git restore --source=origin/main --worktree . would have done it from there.

Then we did the thing they meant to do — untrack without deleting:

git rm -r --cached attachments/
echo 'attachments/' >> .gitignore
git commit -m "Stop tracking attachments/ (keep on disk)"

--cached removes the folder from the index only. The disk is never touched. That one flag is the entire difference between a config tidy-up and a disaster.

Why it happened

git rm carries a default that doesn’t match most people’s mental model. The intent was “git, please forget this exists.” Git heard “delete this from the index and the disk,” because that’s what it does. The -r made it recursive, and the glob pulled in far more than expected.

There was no malice and no bad command syntax. It did exactly what it’s documented to do. The gap was understanding that the working tree is a target, not a bystander.

Takeaways

  • git rm deletes from your disk. Use git rm --cached to untrack a file while keeping it on the filesystem — that’s almost always what you actually want.
  • Count before, count after. find . -name '*.md' | wc -l on both sides of any bulk git op. A number that drops by 30,209 is not a surprise you want at commit time.
  • Test on ONE file first. Run the destructive command against a single path, check git status and ls, then scale up. One file lost is a shrug; thirty thousand is a bad day.
  • Don’t run destructive git in a live data directory without a backup. A notes vault is data, not just a repo. Treat it like one.
  • Know your three trees. Working tree, index, HEAD. Recovery is trivial when you know which one still has your files — and panic when you don’t.