Ch. 16 · Git & CI/CD

Git Reflog and Recovering Lost References

Undo a bad reset --hard, restore a deleted branch or a dropped stash with git reflog and git fsck, and learn which lost work Git can't recover.

~6 min readbeginnerupdated Oct 7, 2026

You meant to undo one commit and typed git reset --hard HEAD~3. Three commits of work vanish from git log. It feels like they’re deleted. They aren’t. Git almost never deletes commits right away; it just moved your branch to point at an older one, and the newer commits are sitting in the object database with nothing pointing at them.

The reflog is how you find them again. It’s one of the most useful commands for day-to-day Git, and in interviews it’s the answer to “what do you do if you lose commits?” The follow-up questions are about its limits: what it covers, how long it lasts, and what’s gone for good.

Branches are pointers, and the reflog records where they pointed

A branch in Git is a file containing one commit hash. Committing moves the pointer forward. reset, rebase, commit --amend and checkout move pointers too, sometimes backwards or sideways. When a pointer moves away from a commit and nothing else references it, the commit becomes unreachable. It’s still stored; it just doesn’t show up in git log.

Every time HEAD or a branch moves, Git appends a line to a local log of reference updates: the reflog. It lives in .git/logs/, it’s per-repository, and it’s never pushed or cloned.

Recovering from reset --hard

Here’s the accident, reproduced in a test repository:

$ git log --oneline
0423b9d Quote CSV fields containing commas
76d5d20 Add CSV header row
b280d81 Export reports as CSV
dac3792 Add report generator

$ git reset --hard HEAD~3
HEAD is now at dac3792 Add report generator
$ git log --oneline
dac3792 Add report generator
Text

Now look at the reflog:

$ git reflog -6
dac3792 HEAD@{0}: reset: moving to HEAD~3
0423b9d HEAD@{1}: commit: Quote CSV fields containing commas
76d5d20 HEAD@{2}: commit: Add CSV header row
b280d81 HEAD@{3}: commit: Export reports as CSV
dac3792 HEAD@{4}: checkout: moving from main to feature/csv-export
dac3792 HEAD@{5}: commit (initial): Add report generator
Text

Read it top to bottom as “most recent first.” HEAD@{0} is where you are now. HEAD@{1} is where HEAD was one move ago, right before the reset: 0423b9d, the commit you want. Move the branch back:

$ git reset --hard HEAD@{1}
HEAD is now at 0423b9d Quote CSV fields containing commas
$ git log --oneline
0423b9d Quote CSV fields containing commas
76d5d20 Add CSV header row
b280d81 Export reports as CSV
dac3792 Add report generator
Text

Two cautions. First, HEAD@{1} is relative, and every command that moves HEAD shifts the numbers. Once you’ve found the right entry, use its hash (git reset --hard 0423b9d), which doesn’t change. Second, reset --hard also overwrites uncommitted changes in your working tree. If you had any, stash or commit them before recovering, or recover into a new branch with git branch rescue 0423b9d and inspect it there. Creating a branch is the safest first move in any recovery, because it can’t destroy anything.

Each branch also has its own reflog. git reflog show feature/csv-export lists only that branch’s movements, which is less noisy than HEAD’s when you’ve been switching around a lot. The syntax works with times as well: main@{yesterday} or main@{2.hours.ago}.

Recovering a deleted branch

Deleting a branch removes its reflog along with it:

$ git branch -D feature/csv-export
Deleted branch feature/csv-export (was 0423b9d).
$ git reflog show feature/csv-export
fatal: ambiguous argument 'feature/csv-export': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
Text

But HEAD’s reflog remembers you were on that branch, and Git printed the tip hash when it deleted it (was 0423b9d). Either gives you what you need:

$ git reflog -3
dac3792 HEAD@{0}: checkout: moving from feature/csv-export to main
0423b9d HEAD@{1}: reset: moving to HEAD@{1}
dac3792 HEAD@{2}: reset: moving to HEAD~3
$ git branch feature/csv-export 0423b9d
Text

If someone deleted the branch on the server, anyone who fetched before the deletion still has origin/feature/csv-export in their remote-tracking reflog, or the commits in their local branch.

A dropped stash: no reflog, but still findable

Stashes are commits too, but git stash drop removes the reflog entry that pointed at them, so git reflog won’t help. Git does print the hash when you drop it:

$ git stash drop
Dropped refs/stash@{0} (121edf0417afd4c8fe593b37d15eb8c86dd2065f)
Text

If that’s scrolled away, git fsck can find objects nothing refers to. --no-reflogs makes it ignore reflog references, so recently orphaned commits show up:

$ git fsck --no-reflogs --unreachable
unreachable blob 70c5f1c68e871d4cfa836dd412d6af9a0c6d3662
unreachable commit 121edf0417afd4c8fe593b37d15eb8c86dd2065f
unreachable tree 9914109079c714bdf56747cfcab5b23c69c13638
unreachable commit 18023b47c30832d4c7470a974c38ac1ed0f392e1
Text

Stash commits have recognisable messages, so print each unreachable commit’s subject:

121edf0 WIP on main: dac3792 Add report generator
18023b4 index on main: dac3792 Add report generator
Text

The WIP on commit is the stash itself (the index on one records what was staged). git stash apply 121edf0 brings the changes back.

Files you staged but never committed

Running git add stores the file’s content as a blob in the object database, even if you never commit. So if you staged a file and then reset --hard, the content still exists:

$ echo "never committed" > notes.txt
$ git add notes.txt
$ git reset --hard
$ git fsck --lost-found
dangling blob c29bd7c1e10b4a8ff01e26caa539d29cbb448efc
...
$ git show c29bd7c
never committed
Text

--lost-found also writes each dangling blob to .git/lost-found/other/, named by its hash. There are no filenames, because a blob is only content, so with many files you’ll be grepping through them. It’s tedious, but it works.

What Git can’t recover is a change that was never added or committed. If you edited a file and ran git checkout -- file or git restore file or reset --hard, that content never entered the object database. Your editor’s local history or a backup is the only hope. This is the strongest argument for committing often on a local branch: anything committed, even as “wip,” can be recovered for weeks.

How long you have

Unreachable objects are deleted only by garbage collection, which Git runs automatically from time to time (git gc --auto after some commands) or when you run git gc yourself. By default:

  • Reflog entries for commits still reachable from the branch expire after 90 days (gc.reflogExpire).
  • Entries for commits no longer reachable from the branch, which is exactly the “I reset away from it” case, expire after 30 days (gc.reflogExpireUnreachable).
  • Unreachable objects not referenced by any reflog are pruned once they’re older than two weeks (gc.pruneExpire).

So you typically have weeks. Don’t run git gc --prune=now or git reflog expire --expire=now --all while you’re trying to recover something, and remember that the reflog is local. A fresh clone has none of your history of moves, and nobody else’s reflog has your unpushed commits.

Interview practice

A developer ran an interactive rebase on their feature branch to squash commits, resolved several conflicts badly, and force-pushed. The code is now broken and they want the branch exactly as it was before the rebase. Nobody else uses the branch. How do they get it back?

Answer

The pre-rebase commits still exist locally. The branch’s own reflog will show the rebase as a series of entries, so git reflog show feature/x lists something like rebase (finish): refs/heads/feature/x onto ... at the top and, below the rebase entries, the commit the branch pointed at before it started. Rebase also saves that position as ORIG_HEAD, which works if no other command has overwritten it since.

I’d first create a safety branch at the current broken state, git branch broken-rebase, so nothing is lost either way. Then find the pre-rebase tip in the reflog, check it with git log --oneline <hash> and maybe run the tests on it, and reset the branch to it: git reset --hard <hash>. Because the server has the broken version, pushing the restored branch needs git push --force-with-lease. That’s acceptable here since nobody else uses the branch. Then redo the rebase more carefully, perhaps with merge.conflictStyle zdiff3 turned on so each conflict shows the base version.

Further reading

More in Git & CI/CD

read ✓Git & CI/CD · easy

Git Revert Versus Reset for Undoing Changes

Reset moves a branch back; revert adds a commit that undoes one. Which is safe when, and the merge-revert trap that drops work on re-merge.

~6 min readread →
esc