Recovering Lost Work with the Reflog
A rescue manual organised by symptom: bad reset, deleted branch, botched rebase, lost amend, dropped stash — plus git fsck, and the one category of loss that is permanent.
A rescue manual organised by symptom: bad reset, deleted branch, botched rebase, lost amend, dropped stash — plus git fsck, and the one category of loss that is permanent.
There is a specific silence that follows a bad git reset --hard.
You stare at a working tree that is suddenly missing a day of
work, and the commits you made are nowhere in git log. That
feeling is almost always wrong. The work is still there.
By the end of this lesson you can walk into that moment calmly and get it back — commits erased by a reset, a branch you deleted, a rebase that mangled everything, a commit you amended away, even a stash you dropped. You will also know the one kind of loss that no Git command can undo, so you never waste an hour hunting for something that was never there.
Here is the insight the whole lesson rests on. When you reset,
rebase, amend, or delete a branch, Git does not erase the
commits involved. It changes which commits are referenced —
which ones a branch name, a tag, or HEAD currently points at.
The commit objects stay on disk exactly as they were.
Commit objects. Not touched.
git reset --hard and git branch -D do not shred a book.
It sits there, unlisted, waiting for someone who remembers
its shelf number.
Branch names, tags, HEAD.
These are what those commands tear up. Losing the card is why the work looks gone.
A ledger of every shelf number.
A per-repository journal of every position HEAD and each
branch has held, and what moved it there. Every commit,
checkout, reset, merge, rebase step and pull writes a line.
Run git reflog in any repository with some history and you get
something like this:
Four things live on each line. The hash on the left is the
commit HEAD pointed at after that action. HEAD@{n} is a
positional index: HEAD@{0} is where you are now, HEAD@{1} is
where you were one move ago. The action label — commit,
checkout, reset, rebase (start), rebase (finish), pull
— tells you which operation moved you. The rest is the message.
Read it newest-first, top to bottom, like a flight recorder. The
line above the disaster is where you were standing immediately
before it. In the output above, the reset threw away b42c0ae
and 19ff5c2; HEAD@{1} still names b42c0ae, which is the
tip of everything the reset appeared to destroy.
git reflog shows the journal for HEAD. Each branch keeps its
own, which is far quieter when you know which branch went wrong:
Timestamps make the story readable when the action labels alone are ambiguous:
That is usually enough to pin down "the state just before lunch", which is how people actually remember their mistakes.
Each of these follows the same shape: find the hash of the good state, then point something at it. Nothing below rewrites anything until you decide it should.
git reset --hard and lost commitsFind the entry immediately before the reset — the one whose message is your last real commit:
You could move the branch straight back:
Safer, and what an experienced hand does under stress, is to look before you leap. Put the recovered state on a throwaway branch first and inspect it:
If it is right, go back and reset the real branch. If it is not,
you have changed nothing. Prefer git switch over
git checkout here: checkout overloads branch switching and
file restoring in one command, and confusing the two is how
people lose work in the first place.
git branch -D feature/checkout prints the tip hash as it
deletes. If it is still in your scrollback, that is your answer.
If not, the branch's own reflog is gone with it, but HEAD
recorded every time you checked that branch out:
Recreate the branch at the hash you find:
If nothing turns up, fall back to the deeper net:
Before a rebase, reset, or merge, Git stashes the previous
HEAD in a ref called ORIG_HEAD. That is your one-step
undo:
ORIG_HEAD is overwritten by the next such operation, so use it
promptly. If it is already stale, find the rebase (start) line
in the reflog — the entry directly above it is where the branch
stood before the rebase began.
git commit --amend does not edit a commit; it builds a new one
and moves the branch to it. The original is still on the shelf,
recorded one entry back:
It is also how you get back files you swept into an amend by accident.
Stashes are commits too, which is why they are recoverable.
git stash drop prints the dropped stash's hash on the way out
— copy it before you clear the screen. If it is gone, hunt for
unreferenced commits:
Then either apply it straight into your working tree or put it back on the stash list:
Use git stash apply, not git cherry-pick: a stash is a merge
commit with two or three parents, and cherry-pick refuses
merge commits without being told which parent to diff against.
Here is the honest answer: the reflog cannot help you. It is a
record of positions HEAD has held, and uncommitted edits were
never a position. If you ran git restore ., git checkout --,
or git reset --hard over changes you had never committed, Git
has no copy because Git never saw them.
Your only hope is outside Git — your editor's local history (VS Code and the JetBrains IDEs both keep one), an open buffer you have not closed yet, or filesystem snapshots like Time Machine. This is the genuinely permanent category. Everything else in this lesson is a rescue; this one is a lesson.
git fsckgit fsck walks every object in the repository and reports the
ones nothing points at. It is the shelf-by-shelf search you run
when the catalogue has failed you:
--lost-found creates a .git/lost-found directory with
commit/ and other/ subdirectories, containing files named
after each dangling object. Inspect any of them with
git show <hash>, and when you find the right one, give it a
name with git branch rescued <hash>.
Reach for the reflog first; reach for fsck when the reflog has
expired or the entry was never written at all.
Everything so far is local recovery. There is a second net, and it is often the faster one: if the commits ever reached a remote, that remote still has them.
Even after a force-push, a colleague who has not fetched since still holds the old commits in their clone and can push them to a rescue branch. A commit that exists in two places is far harder to lose than one guarded by a 30-day journal on your laptop — which is the real argument for pushing work-in-progress branches.
Commit early and often, badly
A commit called "wip" is fully recoverable; an uncommitted file is not. You can rewrite the message later with interactive rebase — frequency, not polish, is what protects you.
Branch before anything hairy
A long rebase, a reset --hard, a filter of history. The
backup branch costs two files and a hash.
Force-push with --force-with-lease
It refuses to overwrite a remote that moved since you last fetched, which turns "I destroyed a colleague's commits" into an error message.
The middle one, concretely:
That turns a recovery hunt into a one-line
git reset --hard backup/pre-rebase.
You now have a rescue manual: find the hash, name it with a branch, look before you leap, and never run garbage collection while something is missing. The commits you thought you burned were, almost always, just uncatalogued.
Next comes automating Git with hooks — teaching
your repository to run checks before a commit lands, and shaping
git config so the safe behaviour is the default one. Several
of the habits here (backup branches, --force-with-lease, a
generous gc.reflogExpire) are things you can bake in once and
stop thinking about.
The best practice for this lesson is deliberate destruction. Make a scratch repository, commit a few times, then reset, amend, and delete branches on purpose and recover each one from the reflog. Doing it once while calm is what makes it automatic when you are not.
0c8e2d4 HEAD@{0}: reset: moving to HEAD~2
b42c0ae HEAD@{1}: commit: Add retry logic to the uploader
19ff5c2 HEAD@{2}: commit: Extract upload client
0c8e2d4 HEAD@{3}: rebase (finish): returning to refs/heads/api
0c8e2d4 HEAD@{4}: rebase (pick): Tidy the config loader
a1b9e77 HEAD@{5}: rebase (start): checkout main
5e6c31b HEAD@{6}: checkout: moving from main to api0c8e2d4 HEAD@{2026-07-24 15:02:11 +0200}: reset: moving to HEAD~2
b42c0ae HEAD@{2026-07-24 14:58:40 +0200}: commit: Add retry logicgit reflog show api # only the api branch's positions
git reflog show maingit reflog --date=isogit refloggit reset --hard b42c0ae # branch jumps back to the old tipgit switch -c rescue b42c0ae # new branch at the lost tip
git log --oneline -5 # confirm it is what you wantgit reflog | grep checkoutgit branch feature/checkout 9d41c7egit fsck --lost-foundgit reset --hard ORIG_HEAD # back to the pre-rebase stategit reflog | grep -n "rebase (start)"git reflog
git show HEAD@{1} # the pre-amend commit
git switch -c pre-amend HEAD@{1} # keep it if you need itgit fsck --unreachable | grep commit
git show <hash> # is this the one?git stash apply <hash> # drop the changes back in
git stash store -m "recovered" <hash> # or re-list it as a stashgit fsck --lost-found # write danglers to .git/lost-found
git fsck --unreachable # just list themgit fetch origin # refresh remote refs
git switch -c rescue origin/api # rebuild from the remote
git log --oneline origin/api # or just lookgit switch -c backup/pre-rebase # snapshot the current tip
git switch - # back to where you weregit reflog # journal of HEAD's positions
git reflog show <branch> # one branch's journal
git reflog --date=iso # with real timestamps
git show HEAD@{1} # inspect a past position
git show 'HEAD@{2.hours.ago}' # position by time, not index
git switch -c rescue <hash> # safe: look before you leap
git reset --hard <hash> # move the branch back
git reset --hard ORIG_HEAD # undo a rebase/merge/reset
git branch <name> <hash> # resurrect a deleted branch
git fsck --lost-found # dump danglers to .git/lost-found
git fsck --unreachable | grep commit # list unreferenced commits
git stash apply <hash> # apply a dropped stash
git stash store -m "msg" <hash> # or put it back on the list
git fetch origin # recover from the remote
git push --force-with-lease # rewrite safely, never blindly
git gc --prune=now # DANGER: deletes the safety net
git reflog expire --expire=now --all # DANGER: erases the journal