"I ran git reset --hard and three hours of commits are gone."
What actually happened is that the contents of a 41-byte file changed. Not one commit object was deleted.
Almost every incident that feels like loss in Git is not deletion but unreachability. And unreachable objects have a grace period — two weeks by default. Knowing this structure is what stops you, in the moment, from making it worse by running git gc or re-cloning.
This article is an operational runbook for recovery, built on Git's internals. It covers not just "run this command" but why it works and when it does not, with the official documentation as the primary source. All command output was captured on git version 2.50.1.
If you want the internals themselves first — objects, refs, and the index — read the companion article. Here we assume that knowledge and focus on getting back from a broken state.
1. The core principle: Git is better at hiding than at deleting
Reachability is the only axis
Whether a Git object is alive or dead comes down to one question.
Can it be reached from some ref?
Refs means branches (refs/heads/*), tags (refs/tags/*), remote-tracking branches (refs/remotes/*), HEAD, the stash (refs/stash), and reflog entries. Commits reachable from those, the trees those commits point at, the blobs those trees point at — all of that is "reachable." Everything else is "unreachable."
git reset --hard, git branch -D, git commit --amend, force push. Every one of these is a ref update. None of them deletes an object.
Objects only die at gc, and even then with a grace period
Unreachable objects are physically removed when git gc reaches its prune stage. git-gc(1) defines it:
--prune=<date>— Prune loose objects older than date (default is 2 weeks ago, overridable by the config variablegc.pruneExpire).--prune=nowprunes loose objects regardless of their age and increases the risk of corruption if another process is writing to the repository concurrently.
Modern Git also does not leave unreachable objects loose; it gathers them into a cruft pack.
--cruft— When expiring unreachable objects, pack them separately into a cruft pack instead of storing them as loose objects.--cruftis on by default.
A cruft pack holds unreachable objects together with a record of when each became unreachable. With the older loose-object approach the grace period was judged from file mtime, which repacking kept refreshing — so objects could effectively never expire. Cruft packs fix that. For recovery purposes it is enough to know: what became unreachable is sitting together in a pack for about two weeks.
Three things not to do right after an incident
| Do not | What it costs you |
|---|---|
git gc / git gc --prune=now | The unreachable objects themselves. This is the one action that makes recovery impossible |
Delete the repository and git clone again | Reflog, unreachable objects, stashes, local branches — everything that exists only locally |
| Keep working in the working tree | Uncommitted changes that have not yet been overwritten |
There is exactly one first move.
# Back up .git before investigating (takes seconds)
$ cp -a .git /tmp/git-backup-$(date +%s)
Recovery is almost entirely a read operation. With a backup in hand, a wrong turn mid-investigation costs you nothing.
2. Tool 1: reflog — the history of ref movements
What gets recorded
The reflog is an append-only log of "when this ref moved, from what value, to what value." It exists per ref as a text file such as .git/logs/refs/heads/main.
$ git reflog
b673550 HEAD@{0}: commit (amend): oops overwritten
c3d491e HEAD@{1}: commit: second
f9dc22c HEAD@{2}: commit (initial): important work
c3d491e at HEAD@{1} is the commit that the amend "destroyed." It was not destroyed. It is in the log and its object is on disk.
git reflog show is an alias for git log -g --abbrev-commit --pretty=oneline, so all of git log's options work. In practice, add timestamps.
$ git reflog --date=iso
faf9142 HEAD@{2026-08-16 14:30:28 +0900}: reset: moving to HEAD~2
6aa734d HEAD@{2026-08-16 14:30:28 +0900}: commit: commit 3
49c9b67 HEAD@{2026-08-16 14:30:28 +0900}: commit: commit 2
faf9142 HEAD@{2026-08-16 14:30:28 +0900}: commit (initial): commit 1
Note that the reason for each operation is recorded too — "reset: moving to HEAD~2". You can reconstruct what happened from the log alone. That is the reflog's real strength.
HEAD@{n} and main@{n} are different things
This trips people up constantly.
HEAD@{n}— the movement history of HEAD, including branch switchesmain@{n}— the movement history of the main branch; nothing that happened while you were on another branch appears
If something disappeared on a branch you had switched away from, git reflog show main finds it faster.
You can also index by time, which is the shortest path to "the state it was in last Friday."
$ git diff main@{2.days.ago} main # what changed in the last two days
$ git switch -c rescue main@{yesterday}
Reflog expiry — the trap
Reflog entries do not live forever. git-reflog(1) is precise:
--expire=<time>— […] defaults to 90 days.--expire-unreachable=<time>— […] entries […] not reachable from the current tip […] defaults to 30 days.
Two separate deadlines, and that distinction matters.
| Kind | Default | Config |
|---|---|---|
| Entries for reachable history | 90 days | gc.reflogExpire |
| Unreachable entries | 30 days | gc.reflogExpireUnreachable |
A commit you discarded in an accident is on the unreachable side, so you have 30 days, not 90.
Extending that is one line, and a defensible default on a personal machine.
# Keep unreachable reflog entries for a year (the disk cost is negligible)
$ git config --global gc.reflogExpireUnreachable "1.year"
The biggest trap: bare repositories have no reflog
On the default value of core.logAllRefUpdates, the docs say:
This value is true by default in a repository that has a working directory associated with it, and false by default in a bare repository.
Which means:
- Your local development repository → reflog ✅
- A plain Git server, a mirror, anything from
git clone --bare→ no reflog ❌ - A CI workspace → enabled (it has a working tree), but gone when the job ends ⚠️
"Someone force-pushed on the server and the history is gone" cannot be solved by plain Git. Hosting providers sometimes record their own events, and GitHub's Events API, PR commit lists, or support can occasionally identify the lost SHA. But that is a vendor feature, not a Git feature, and you should not design around it.
The server-side answer is not reflog; it is policy and backups (see section 8).
3. Tool 2: fsck — finding what the reflog never saw
The reflog records ref movements. Conversely, anything that never went through a ref is absent:
- Content you staged with
git addbut never committed - A stash you ran
git stash dropon - Intermediate state from an aborted merge or rebase
- Objects created with plumbing but never given a ref
git fsck is the tool for those.
$ git fsck --unreachable
unreachable blob dbbfe51483ff05f84e7187a835d023be8e496473
$ git cat-file -p dbbfe51483ff05f84e7187a835d023be8e496473
precious uncommitted content
That output reproduces a very specific situation: git add, then git reset --hard. The file is gone from the working tree, but because git add wrote the blob into the object database, the content is still there. Nothing about it appears in the reflog.
--unreachable / --dangling / --lost-found
| Option | Meaning |
|---|---|
--unreachable | List objects not reachable from any ref |
--dangling (on by default) | Show objects that exist but are never directly used; suppress with --no-dangling |
--lost-found | Write dangling objects into .git/lost-found/commit/ and .git/lost-found/other/. For a blob, the contents are written into the file |
--no-reflogs | Do not treat the reflog as a reachability root. Use it to see only what is not even in the reflog |
--connectivity-only | Check connectivity without reading blob contents — fast on huge repositories |
--lost-found is the workhorse.
$ git fsck --lost-found
dangling commit 6aa734d3e7b4fa54de72f05f9fad0eb6f12949b9
$ ls .git/lost-found/commit/
6aa734d3e7b4fa54de72f05f9fad0eb6f12949b9
Note that --no-reflogs shows nothing while the reflog is still alive. Do not conclude "fsck found nothing, give up" — check git reflog first. That is the correct order.
Narrowing down when there are too many candidates
Old repositories can produce thousands of unreachable objects. Filter by date and message.
# Unreachable commits only, with date and message, newest first
$ git fsck --unreachable --no-progress 2>/dev/null \
| awk '$2 == "commit" { print $3 }' \
| git log --stdin --no-walk --date=iso \
--pretty='%h %ad %an %s' \
| sort -k2 -r \
| head -20
git log --stdin --no-walk means "show exactly the commits fed on stdin, one line each, without walking history." Nothing finds the one you want among thousands faster.
4. Recipes by incident
Each case starts with what actually happened — which ref moved. Once the mechanism is clear, the commands follow.
A. git reset --hard threw away committed history
What happened: the branch ref was rewritten to an older value. The commit objects are untouched.
$ git reflog # find the previous value
6aa734d HEAD@{1}: commit: commit 3 ← the tip you discarded
$ git branch rescue 6aa734d # attach a ref first, to make it safe
$ git log --oneline rescue # verify the contents
6aa734d commit 3
49c9b67 commit 2
faf9142 commit 1
$ git switch main && git reset --hard rescue # restore once you're satisfied
Key point: do not jump straight to reset --hard. Attach a ref with git branch first. The moment a ref points at it, the commit is reachable again and out of gc's reach. This is the safest possible first move.
B. git commit --amend clobbered the previous commit
What happened: HEAD was updated to point at a new commit. The original became unreachable.
$ git reflog
b673550 HEAD@{0}: commit (amend): oops overwritten
c3d491e HEAD@{1}: commit: second ← the pre-amend commit
$ git switch -c before-amend c3d491e # take the original out as a branch
If you want to compare the two, you can diff them.
$ git diff c3d491e b673550
C. You deleted a branch with git branch -D
What happened: a file named refs/heads/<name> was removed. That is all.
# The value at deletion is in the HEAD reflog
$ git reflog --date=iso | grep -i "checkout\|feature"
# Or take it from the deletion message — Git prints the SHA
$ git branch feature-restored <sha>
The Deleted branch feature (was 6aa734d). line Git prints is the shortest path. Check your terminal scrollback first.
D. A rebase went wrong, or you are lost mid-rebase
What happened: rebase recreates each original commit as a new one, and while it is in progress HEAD is detached.
# Still in progress: abort, returning fully to the pre-rebase state
$ git rebase --abort
# Already finished: use the position from before it started
$ git reset --hard ORIG_HEAD
ORIG_HEAD records the value of HEAD immediately before an operation that moves it dramatically — reset, merge, rebase, pull. It holds only the most recent one, so use it before your next risky operation.
$ git rev-parse ORIG_HEAD
b6735506b23643a633056af01af9afa194ce358e
E. git restore / git checkout . wiped uncommitted edits
This is where fates diverge.
| State | Recoverable? | How |
|---|---|---|
It had been git added (staged) | ✅ Yes | The blob is in the object database. Find it with git fsck --unreachable |
| Never added | ❌ No | Git never saw those bytes |
# Find content that had been staged
$ git fsck --unreachable | grep blob
unreachable blob dbbfe51483ff05f84e7187a835d023be8e496473
$ git cat-file -p dbbfe514 > recovered.txt
The lesson is blunt: when work starts to feel valuable, run git add. You do not have to commit — the moment you stage, the content lands in the object database and comes within recovery range. This is easier to make a habit of than "commit often" and nearly as effective.
Your editor's local history (VS Code's Timeline, JetBrains' Local History) is a separate insurance policy living outside Git. If you never staged, it is realistically your only hope.
F. You ran git stash drop
What happened: one entry was removed from refs/stash. The stash commit itself survives.
A stash is a commit — specifically, a merge commit.
$ git log --oneline --graph refs/stash
* 719d02e WIP on main: faf9142 commit 1 ← the working-tree state
|\
| * 766195b index on main: faf9142 commit 1 ← the index state
|/
* faf9142 commit 1 ← the original HEAD
First parent is the original HEAD, second parent is the index state, and the stash commit itself is the working-tree state. The three trees are captured as three commits.
Recovering after a drop takes one command, given the object name.
$ git fsck --unreachable | grep commit
unreachable commit d0a3490836dcafc8846416cd1bbbae32f83a4421
$ git stash apply d0a3490836dcafc8846416cd1bbbae32f83a4421
On branch main
Changes not staged for commit:
...
git stash apply accepts a raw commit SHA, not just a stash-stack entry. A great many people give up without knowing this.
G. A force push destroyed history on the remote
What happened: a ref on the remote was rewritten. And as covered above, bare repositories have no reflog.
Work through these in order.
# 1. Does anyone's local copy still hold the old value?
$ git reflog show refs/remotes/origin/main
# 2. If you still have the old commits locally, push them back
$ git push --force-with-lease=main:$(git rev-parse refs/remotes/origin/main) \
origin <old-sha>:main
If nobody has it locally, Git itself cannot help. You are down to what the host recorded (GitHub's Events API, the PR's commit list, SHAs printed in CI logs). CI build logs very often print the checked-out SHA, and in practice that is a strong lead.
The real fix is not a recovery procedure. It is forbidding force push on the server (see section 8).
H. You want to redo a conflict resolution
# Redo the whole merge
$ git merge --abort
# Put one file back into conflicted state (all three stages reappear)
$ git checkout --merge -- path/to/file
# If rerere memorized a bad resolution, forget it
$ git rerere forget path/to/file
git checkout --merge (-m) restores a resolved file to its conflict-marked state. Add --conflict=diff3 to include the common ancestor, which makes "what did they actually change" far easier to judge.
5. When you genuinely need something gone: leaked secrets
The flip side of recovery is that survival becomes the problem — when an API key or password ends up in a commit.
First, understand this: deleting it in a later commit does nothing
"I removed it in the next commit, so it's fine" is wrong. The original blob is in the object database and readable with git log -p or git show <old-sha>.
The right order
1. Rotate — revoke — the credential immediately.
This is the only reliable fix. Rewriting history does not reach any of the following:
- The local repository of everyone who already cloned
- Forks on the hosting provider
- The provider's caches (GitHub can serve a rewritten-away commit via a direct link for some time)
- CI systems, mirrors, backups
- Crawlers and search indexes
A leaked key is leaked. It does not revert to being "a key probably nobody saw."
2. Then remove it from history.
git-filter-branch(1) does not recommend itself:
git filter-branchhas a plethora of pitfalls that can produce non-obvious manglings of the intended history rewrite (and can leave you with little time to investigate such problems since it has such abysmal performance). These safety and performance issues cannot be backward compatibly fixed and as such, its use is not recommended. Please use an alternative history filtering tool such as git filter-repo.
So the tool to use is git filter-repo (installed separately; it is the officially recommended alternative).
$ pip install git-filter-repo
# Remove a path from all of history
$ git filter-repo --invert-paths --path config/secrets.yml
# Replace a string throughout history (redact the value itself)
$ git filter-repo --replace-text <(echo 'AKIAEXAMPLE0000000000==>REDACTED')
Read the accompanying warning too:
WARNING! The rewritten history will have different object names for all the objects and will not converge with the original branch. You will not be able to easily push and distribute the rewritten branch on top of the original branch.
This is a destructive operation that requires everyone on the team to re-clone. Which is exactly why the order is rotate, then clean, and never the reverse.
Prevention: stop it at the boundary
# Detect corrupt or suspicious objects on the way in and out
$ git config --global transfer.fsckObjects true
$ git config --global fetch.fsckObjects true
$ git config --global receive.fsckObjects true
Beyond that, secret detection belongs in a pre-commit hook or a CI secret scan — stopped before the commit lands. Rewriting history after the fact is always expensive.
6. Recovery decision table
Read top to bottom when something goes wrong.
| Symptom | Look here first | Main command |
|---|---|---|
| Commits gone (reset / amend / rebase) | git reflog | git branch rescue <sha> |
| Branch deleted | git reflog / the deletion output | git branch <name> <sha> |
| Want the state from just before a rebase or merge | ORIG_HEAD | git reset --hard ORIG_HEAD |
| Want to cancel an in-progress rebase or merge | — | git rebase --abort / git merge --abort |
| Staged but uncommitted changes vanished | git fsck --unreachable | git cat-file -p <blob> |
| Unstaged edits vanished | Your editor's local history | (Not possible in Git) |
| Stash dropped | git fsck --unreachable | git stash apply <sha> |
| Remote history destroyed by force push | Local refs/remotes/* reflog | Push it back with git push --force-with-lease |
| Repository corrupted (bad objects) | git fsck --full | Backfill the object from another clone |
7. Settings that keep you recoverable
Accidents happen. The question is whether you can come back from them.
# Keep unreachable reflog entries for a year instead of 30 days (fine on a personal machine)
git config --global gc.reflogExpireUnreachable "1.year"
# Extend reachable reflog retention too
git config --global gc.reflogExpire "1.year"
# Extend the prune grace period for unreachable objects from two weeks to 90 days
git config --global gc.pruneExpire "90.days.ago"
# Refuse to send or receive corrupt objects
git config --global transfer.fsckObjects true
git config --global fetch.fsckObjects true
git config --global receive.fsckObjects true
The only cost here is disk, and cruft packs keep even that small. As an investment in extending your recovery window, the return is extremely high.
These settings also hold if you use git maintenance: the incremental strategy disables the gc task by default, so a full prune does not run as part of routine upkeep in the first place.
8. Server side: designing so you never need recovery
Everything above assumes the information still exists locally somewhere. As an organization, the reliable move is to stop the incident from happening at all.
Enforce it with policy
| Control | Effect |
|---|---|
| Forbid force push on protected branches | The incident in section 4-G stops occurring |
| Forbid branch deletion | Prevents accidental deletion |
| Require PRs (no direct push) | Removes unreviewed history rewrites |
| Require signed commits | Proof of authorship |
Client-side settings (using --force-with-lease and so on) depend on discipline and are weak as an organizational defense. Have the server refuse.
Mirror backups
# A complete mirror, including all refs
$ git clone --mirror https://github.com/org/repo.git repo.git
# Periodic update (this also syncs deletions — think carefully for backups)
$ cd repo.git && git remote update --prune
With --prune, refs deleted upstream are deleted in the backup too, so for "recover from accidental deletion" purposes you may want to leave it off. Choose the behavior based on the backup's purpose: disaster recovery or operator-error recovery.
git bundle: a single-file offline backup
# Pack all refs into one file
$ git bundle create backup-$(date +%Y%m%d).bundle --all
# Verify (not corrupt, prerequisite commits present)
$ git bundle verify backup-20260816.bundle
# Restore (a bundle can be treated as an ordinary remote)
$ git clone backup-20260816.bundle restored-repo
A bundle is a single file that needs no network and no server, so dropping it in object storage gives you an off-site backup. Being able to check integrity with git bundle verify also makes it better than tarring up the repository.
9. Conclusion
Git recovery is decided not by how many commands you have memorized but by one model you understand.
- Removing a ref does not remove the objects. They live for two weeks by default (
gc.pruneExpire), inside a cruft pack. - Reflog is the history of ref movements. Unreachable entries last 30 days, and bare repositories have none at all.
- fsck finds what the reflog never saw: staged-but-uncommitted content, dropped stashes, aborted operations.
- The first move is
git branch rescue <sha>. Attaching a ref makes it reachable and takes it out of gc's path. - When survival is the problem (a leaked secret), rotate the credential before cleaning history, not after.
Most importantly: right after an incident, do nothing. Run cp -a .git /tmp/backup, then start investigating — and in nearly every case you will get the work back.
Why Git behaves this way — how objects, refs, and the index fit together — is covered in the companion article on internals. If you want to move recovery from memorized to derived, read that one too.