git reflog: the undo button you didn't know you had
Every commit you've ever made is recoverable for at least 30 days, even after a hard reset, a bad rebase, or a deleted branch. Here's how reflog works and the exact commands to bring lost work back.
You've just run git reset --hard HEAD~5. The terminal goes quiet. Your stomach drops.
Five commits — three hours of work — are gone. You didn't push them. You didn't open a PR. Your editor doesn't have them in its undo buffer anymore. The Git object database has freed the references and is going to garbage-collect them eventually.
You can get every one of them back, perfectly, in about forty seconds.
This is the most important command in Git that almost nobody learns until they've already lost work twice. It's called git reflog.
What reflog actually is
A "ref" in Git is just a pointer. HEAD is a ref. Every branch name is a ref. Every tag is a ref. When you make a commit, Git updates whatever ref you're currently sitting on to point at the new commit's SHA. When you switch branches, the HEAD ref moves to the tip of the new branch.
The reflog is a journal of every ref movement. Every time HEAD shifts — every commit, every checkout, every merge, every reset, every rebase step — Git writes a line into the reflog with the previous SHA, the new SHA, and a short reason like "commit" or "reset: moving to HEAD~5".
That journal lives in .git/logs/HEAD and .git/logs/refs/heads/<branch>. It's local to your machine. It's not pushed anywhere. It's not part of the repo. Nobody else can see it.
And, critically — and this is the part that saves your three hours of work — the commits the reflog references are not deleted until garbage collection runs, which by default is 30 days from now. Even when those commits are no longer reachable from any branch, no longer in any working tree, no longer mentioned by any tag — they sit in .git/objects/, untouched, waiting for you to point a branch at them.
A failed reset --hard doesn't delete commits. It just unparks them.
The commands you actually need
There are only three. Memorise them.
git reflog
git reflog show <branch>
git reset --hard <sha-or-ref>
Let's walk through a recovery from start to finish.
Step 1: Look at the reflog
git reflog
You'll get something like:
a1b2c3d HEAD@{0}: reset: moving to HEAD~5
9f4e2c8 HEAD@{1}: commit: feat: wire payment confirmation email
6c1a5e3 HEAD@{2}: commit: feat: add payment confirmation route
3d8f2b1 HEAD@{3}: commit: chore: bump stripe SDK
0e7a9d4 HEAD@{4}: commit: refactor: extract email queue
b4c8e2f HEAD@{5}: commit: feat: add email queue worker
2a1b3c4 HEAD@{6}: checkout: moving from main to feature/payments
Read this top-down as "what HEAD just did, in reverse order". The most recent thing is HEAD@{0} — the reset --hard we just regretted. The five commits we lost are right there at HEAD@{1} through HEAD@{5}.
The five lost commits are not "deleted from history" in any meaningful sense. They're orphans — no branch points at them — but they exist as objects in .git/objects/. We can rehome them.
Step 2: Move HEAD back
The simplest recovery: put your branch back where it was before the reset. The reflog tells you the SHA of the commit you regret leaving — it's the entry that says commit: immediately above the bad reset: line. In the example above, that's 9f4e2c8.
git reset --hard 9f4e2c8
That's it. Your branch is now at 9f4e2c8 again. All five commits are reachable from the tip. git log shows them. Your working tree matches commit 9f4e2c8. Three hours of work, restored.
Step 3 (optional): Sanity-check
git log --oneline -10
Confirm you see the commits you expected. If something looks off, just run git reflog again — you'll see your recovery reset as the new HEAD@{0} and the old reset's HEAD@{0} shifted to HEAD@{1}. You can move back and forth as many times as you want; every move adds a line. Nothing gets lost.
The four scenarios where reflog saves you
1. Hard reset you regret
The scenario above. Either you reset more than you meant to, or you reset and then realised "wait, I needed that branch state for something."
git reflog # find the SHA you want back
git reset --hard <sha> # move HEAD there
2. Deleted a branch with unmerged work
git branch -D feature/payments
# (panic)
Branches are just refs. Deleting one removes the pointer but leaves the commits intact for the reflog window. Recovery:
git reflog | grep payments # find the last SHA the branch was at
# or: less .git/logs/refs/heads/feature/payments (gone, but the entries
# still appear in the global reflog)
git checkout -b feature/payments <sha>
Heads up: git reflog shows only HEAD movements. If you never had the deleted branch checked out (so HEAD never sat on it), you might need:
git fsck --lost-found
That walks the object database and surfaces commits not reachable from any ref. Each "dangling commit" is a candidate.
3. Rebase ate someone else's commits
You ran git rebase main and resolved a conflict by picking your side. Now you realise that side dropped Alice's commits that were on the branch before you started.
git reflog
Find the entry that says rebase: start or rebase (start). Whatever HEAD was at just before the rebase began is your pre-rebase state. Reset to it:
git reset --hard HEAD@{12} # or whatever the line number is
Now you're back to before the rebase. Try again, this time more carefully.
(Tip: before any non-trivial rebase, run git rebase --abort-able rebases, and bookmark your starting point: git tag pre-rebase. Cheap insurance.)
4. Force-pushed over a teammate's work
Your teammate pushed something. You force-pushed your version over it without pulling first. Their commits are gone from the remote — and from your local, because you git push --force'd.
# On their machine:
git reflog | head -20 # they can still see their commits
git push --force origin feature # re-publish their state
If they're not online, their reflog has the commits. If you have them locally too (e.g. you pulled their work then force-pushed yours over it), your reflog has them. Either of you can republish.
For this exact reason: never git push --force. Use git push --force-with-lease. The --with-lease variant checks that the remote ref is still at the SHA you last saw before pushing; if it isn't (because your teammate pushed in the meantime), the push is refused and you have a chance to pull their work first.
The thing reflog won't save you from
Reflog tracks ref movement. It does not track changes to files you never committed.
If you ran git stash drop on a stash you wanted, that stash's snapshot is gone from the stash list, but the commit it pointed at may still be in .git/objects/ for 30 days — recoverable via git fsck --lost-found. Worth trying.
If you ran git checkout -- <file> (or its modern equivalent git restore <file>) on a file you'd been editing but never staged, that work is lost. The file's pre-edit state replaced your edits in the working tree, and nothing was ever written to the object database. The reflog can't help. Your editor's local undo buffer is your only hope, and only if the editor is still open.
If you ran git clean -fd, those untracked files are gone. Same story — they were never in the object database.
The rule: anything Git ever assigned a SHA to is recoverable for ~30 days. Anything Git never saw is gone the moment you tell it to discard.
This is also why "commit early, commit often" is good advice for individual workflow even when commits aren't ready to share. Every commit drops an anchor into reflog territory. Five small commits across an afternoon give you five places to time-travel back to. One giant commit at the end gives you one.
How long do I actually have?
By default, two windows:
- Reachable but unreferenced commits (e.g. dangling after a reset): 90 days before
git gcis allowed to remove them. - Unreachable commits (orphan branches, dropped stashes): 30 days.
You can check with:
git config gc.reflogExpire # default: 90 days
git config gc.reflogExpireUnreachable # default: 30 days
You can also extend these in ~/.gitconfig if you want longer recovery windows:
[gc]
reflogExpire = 365.days
reflogExpireUnreachable = 365.days
I run with 365 on personal machines. The disk cost is negligible (.git/objects/ for a year of edits on a normal-sized repo is single-digit megabytes), and the peace of mind is real.
git gc only runs automatically when triggered by certain commands — it's not a daemon. So even past the expire window, your commits often persist for weeks longer until gc actually gets invoked. Don't rely on this, but don't despair immediately if you find yourself outside the window.
The mental model that makes reflog click
Think of every commit as a pebble dropped on a beach.
When you make a new commit, you drop a new pebble. Your branch is a flag stuck in a particular pebble. When you git reset --hard HEAD~5, you don't pick up the five pebbles you walked past — you just move the flag back five pebbles. The pebbles you walked past are still on the beach, exactly where you left them.
The reflog is the trail of footprints. As long as your footprints haven't been washed away (i.e. git gc hasn't run with those orphans past their expire window), you can walk back along the trail to any pebble you stood on and plant the flag there again.
git reset --hard <sha> doesn't destroy. It plants the flag.
That's the entire model. Once it clicks, the panic when you mistype a reset command goes away. You're never one keystroke from losing work. You're one keystroke from needing to remember the reflog exists.
A practical habit
I keep this command aliased in my .gitconfig:
[alias]
rl = reflog --date=relative
oops = !git reset --hard HEAD@{1}
The first gives me a human-friendly reflog with relative dates. The second is the literal undo button: it moves HEAD back to where it was one operation ago, no matter what that operation was. git oops is the muscle memory I want when I realise I just did something wrong.
Try this once today: do a git reset --hard HEAD~1 on a throwaway branch, then recover with git oops. The first time you do it deliberately, the panic-flavored memory of doing it accidentally evaporates.
You don't need to learn Git's internals to ship code. But you do need to know the reflog exists. The next three-hour mistake you save yourself from will make it the highest-ROI five minutes of Git education you ever have.
Try this in the live runner → Open the Lost commits after a hard reset scenario to walk through a seeded "you just lost five commits" repo with a real terminal underneath. Get them back the way you would in a real emergency, with no actual emergency.