Skip to main content
Settings
Search
Appearance
Theme Mode
About
Jekyll v3.10.0
Environment Production
Last Build
2026-07-03 02:47 UTC
Current Environment Production
Build Time Jul 03, 02:47
Jekyll v3.10.0
Build env (JEKYLL_ENV) production
Quick Links
Page Location
Page Info
Layout article
Collection hacks
Path _hacks/git-reflog-undo-almost-anything.md
URL /hacks/git-reflog-undo-almost-anything/
Date 2026-07-02
Theme Skin
SVG Backgrounds
Layer Opacity
0.6
0.04
0.08

Undo almost anything in git: the reflog is your undo history

There is a specific flavor of panic reserved for the moment right after you hit Enter on git reset --hard. The commits are gone from git log. The terminal is calm. Your stomach is not.

Here’s the thing nobody mentions until you’re already crying: those commits are almost never actually gone. Git keeps a private log of everywhere HEAD has ever pointed — every commit, checkout, reset, rebase, merge. It’s called the reflog, it lives only on your machine, and it is the closest thing git has to a universal undo button.

Every command below was run for real with git version 2.54.0. Both the rescues and the two times the reflog can’t save you stay in, because you’ll meet all of them.

Reproduce the disaster

Three commits, then the classic mistake — reset --hard to the wrong place:

$ git init -q -b main demo && cd demo
$ for n in 1 2 3; do echo "line $n" >> notes.txt; git add notes.txt; git commit -q -m "commit $n"; done
$ git log --oneline
741b1fb commit 3
dc04a6c commit 2
82d1e59 commit 1
$ git reset --hard HEAD~2
HEAD is now at 82d1e59 commit 1
$ git log --oneline
82d1e59 commit 1

Commits 2 and 3 are gone from git log. This is the part where most people start googling “git recover deleted commits” through tears. Don’t. Ask the reflog first.

The receipt: git reflog

$ git reflog
82d1e59 HEAD@{0}: reset: moving to HEAD~2
741b1fb HEAD@{1}: commit: commit 3
dc04a6c HEAD@{2}: commit: commit 2
82d1e59 HEAD@{3}: commit (initial): commit 1

Read it top-down as “most recent thing first.” HEAD@{0} is where you are now (the bad reset). HEAD@{1} is where HEAD pointed just before — the tip you thought you destroyed, 741b1fb, still sitting there with its full history behind it. The reflog didn’t delete the commit; reset only moved a pointer. The commit object is still in the repo, just unreferenced.

Recover: move a pointer back

You have the address (HEAD@{1}, or the SHA 741b1fb — either works). Two ways to use it.

The careful way — put the lost tip on a new branch so you can inspect it without touching main:

$ git branch rescue HEAD@{1}
$ git log --oneline rescue
e7cfea3 commit 3
647ee83 commit 2
7e3e7f6 commit 1

(Different SHAs than the first run — this is a fresh reproduction. The point is all three commits came back.)

The decisive way — if you’re sure, move main itself back to where it was:

$ git reset --hard HEAD@{1}
HEAD is now at e7cfea3 commit 3
$ git log --oneline
e7cfea3 commit 3
647ee83 commit 2
7e3e7f6 commit 1

You’ll know it worked when git log shows the commits you thought you’d lost, with the same messages and the same order. The fix for a bad reset --hard is, satisfyingly, another reset --hard — this time aimed at the reflog entry.

The other rescue: a commit from a branch you deleted

reset isn’t the only thing that orphans commits. Delete a branch with unmerged work and the same trick applies. Here we commit on feature, delete the branch, and watch git log --all swear the commit never existed:

$ git switch -c feature
$ echo "experimental" >> notes.txt && git commit -qam "risky feature work"
$ git switch main
$ git branch -D feature
Deleted branch feature (was a491c8c).
$ git log --oneline --all
e7cfea3 commit 3
647ee83 commit 2
7e3e7f6 commit 1

git branch -D even printed the SHA (a491c8c) on its way out — that’s your recovery address. And the reflog remembers it regardless:

$ git reflog | head -4
e7cfea3 HEAD@{0}: checkout: moving from feature to main
a491c8c HEAD@{1}: commit: risky feature work
e7cfea3 HEAD@{2}: checkout: moving from main to feature
e7cfea3 HEAD@{3}: reset: moving to HEAD@{1}
$ git branch feature-recovered a491c8c
$ git log --oneline feature-recovered | head -2
a491c8c risky feature work
e7cfea3 commit 3

Branch un-deleted. Same move every time: find the SHA in the reflog, point a branch or HEAD at it.

The part where it breaks: uncommitted work is not in the reflog

Here’s the limit that catches people who start to think the reflog is magic. The reflog only tracks commits — where HEAD and branches have pointed. Work you never committed was never a commit, so reset --hard eats it with no receipt:

$ echo "an hour of unsaved edits" >> notes.txt
$ git status --short
 M notes.txt
$ git reset --hard
$ cat notes.txt
committed
$ git reflog | head -3
ef12f3f HEAD@{0}: reset: moving to HEAD
ef12f3f HEAD@{1}: commit (initial): base

The edit is gone and the reflog never mentions it, because it never became a commit. The lesson: the reflog protects committed history, not your working tree. If you want reset --hard to be survivable, git stash (which does make a commit-like object) or commit early and often before you do anything destructive.

The other limit: the reflog is local and per-repo

The reflog is not pushed. It is not shared. It is not in a fresh clone of your project — a clone’s reflog starts the moment you cloned:

$ git clone -q demo clone2 && cd clone2
$ git reflog
ef12f3f HEAD@{0}: clone: from /tmp/…/demo

One entry: the clone itself. So the reflog can save you, on this machine, from something you just did — but it can’t recover a commit a teammate lost in their checkout, and it won’t survive rm -rfing the repo. It’s a personal safety net, not a backup. (And the entries do expire: git garbage-collects unreachable ones after ~30 days, reachable ones after ~90. Fast, but not forever — rescue promptly.)

The whole rescue, tested end to end

Here’s the shape to reach for: reproduce the bad reset, read the reflog, and put HEAD back. This block is opted into our test harness (lh:run) and runs on every build in a locked-down, no-network sandbox, so the version you’re reading is the version that passed:

```bash lh:run #!/usr/bin/env bash set -euo pipefail

export GIT_AUTHOR_NAME=you GIT_AUTHOR_EMAIL=you@example.com export GIT_COMMITTER_NAME=you GIT_COMMITTER_EMAIL=you@example.com

root=”$(mktemp -d)”; cd “$root” git init -q -b main demo && cd demo for n in 1 2 3; do echo “line $n” » notes.txt; git add notes.txt; git commit -q -m “commit $n”; done

echo “==> three commits:” git log –oneline

The disaster: throw away the last two commits.

git reset –hard HEAD~2 >/dev/null echo “==> after ‘reset –hard HEAD~2’ — two commits look gone:” git log –oneline

echo “==> but the reflog kept the receipt:” git reflog

Recover: point main back at the tip the reflog remembers.

git reset –hard ‘HEAD@{1}’ >/dev/null echo “==> after ‘reset –hard HEAD@{1}’ — all three are back:” git log –oneline

Assert it, so a silent regression fails the gate.

test “$(git rev-list –count HEAD)” -eq 3 echo “done: 3 commits recovered” ```

All the console output above is real, captured with git version 2.54.0.

When this goes wrong

  • fatal: ambiguous argument 'HEAD@{1}' — your shell ate the braces. Quote it: git reset --hard 'HEAD@{1}', or use the bare SHA the reflog printed.
  • The commit isn’t in git reflog — try git reflog --all (it also lists branch and stash reflogs), or fall back to git fsck --lost-found, which finds dangling commit objects the reflog no longer references.
  • git reflog is empty except for a clone entry — you’re in a fresh clone, or on a different machine. The reflog is local; the commit you want was lost somewhere else and this repo never saw it.
  • It really is gone — you never committed it (working-tree edits aren’t tracked) or git gc already collected it (past the ~30/90-day window). The reflog is a fast safety net, not a backup. Commit early, and reach for it the same day.

The reflex, after a bad reset --hard, is to assume the work is gone and start over. It almost never is. Type git reflog, find the line from thirty seconds ago, and point a branch at it. Git was keeping a receipt the whole time — it doesn’t volunteer it.