Git reflog: Bringing Back Lost Commits and Deleted Branches
We bring commits back with git reflog after an accidental reset --hard: HEAD@{n} versus HEAD~n, undoing the last merge with ORIG_HEAD, finding a deleted branch in the reflog, backing out of a rebase with the branch's own reflog, and why the reflog is local and expires.
Version note: The commands in this post were tested with Git 2.54.
Guess First
Ada wanted to combine her last two commits into one. The command she had in mind was git reset --soft HEAD~2, but her fingers typed something else out of habit:
$ git log --oneline -3
75253fa (HEAD -> main) Add total label to cart
a2af014 Add price formatter
077420f Add .gitignore
$ git reset --hard HEAD~2
HEAD is now at 077420f Add .gitignore
$ git log --oneline -3
077420f (HEAD -> main) Add .gitignore
24aba37 Add cart total
f7f661c Add book list
Two commits vanished from git log, and the files in the working tree are gone too. These commits had never been pushed anywhere. Do you think they are still somewhere inside the repo right now, or are they deleted for good?
Reflog: HEAD's Logbook
The answer: they are still there. git log shows you the connected history of commits, meaning the parent chain going backwards from a branch. But Git also writes down, in a separate logbook, where HEAD and the tip of every branch went, when, and with which command. That logbook is called the reflog.
$ git reflog
077420f (HEAD -> main) HEAD@{0}: reset: moving to HEAD~2
75253fa HEAD@{1}: commit: Add total label to cart
a2af014 HEAD@{2}: commit: Add price formatter
077420f (HEAD -> main) HEAD@{3}: checkout: moving from feature/wishlist to main
e1f0c14 (feature/wishlist) HEAD@{4}: commit: Document wishlist in README
76745bd HEAD@{5}: commit: Add wishlist module
077420f (HEAD -> main) HEAD@{6}: checkout: moving from main to feature/wishlist
077420f (HEAD -> main) HEAD@{7}: commit: Add .gitignore
24aba37 HEAD@{8}: commit: Add cart total
f7f661c HEAD@{9}: commit: Add book list
0d19b57 HEAD@{10}: commit (initial): Add README
The newest entry is on top. HEAD@{0} is now, HEAD@{1} is "where HEAD was one move ago", HEAD@{2} the one before that. Each line names the command that made the move: commit, checkout, reset, merge, rebase. Think of an aircraft's black box; it's the black box, not the map, that tells you where the plane has been.
There is an important distinction here. HEAD@{1} is not "the commit before HEAD"; that is HEAD~1, which goes one step back along the parent chain. HEAD@{1} goes one step back in time: the commit HEAD pointed to one move ago. Right after a reset these are two very different places.
Bringing Back a Lost Commit
Before the reset, HEAD was at 75253fa, and the reflog shows it as HEAD@{1}. To go back, you do the same reset, this time to the right target:
$ git reset --hard HEAD@{1}
HEAD is now at 75253fa Add total label to cart
$ git log --oneline -3
75253fa (HEAD -> main) Add total label to cart
a2af014 Add price formatter
077420f Add .gitignore
The two commits and the files are back. Now you can calmly do the --soft reset you actually wanted.
If you want to look at what you found before moving the branch, a more careful way is to give the lost commit a name first: git branch rescue 75253fa. The moment a branch points at a commit, it is no longer "lost". You look inside with git log rescue, and once you are sure, move it wherever you want.
ORIG_HEAD: Undoing the Last Big Move
Before commands that move HEAD drastically, Git writes the old position into a special ref called ORIG_HEAD. reset, merge and rebase all do this. Ada merged feature/wishlist into main, then decided it was too early:
$ git merge --no-ff feature/wishlist
Merge made by the 'ort' strategy.
README.md | 4 ++++
src/wishlist.js | 7 +++++++
2 files changed, 11 insertions(+)
create mode 100644 src/wishlist.js
$ git reset --hard ORIG_HEAD
HEAD is now at 75253fa Add total label to cart
$ git log --oneline -2
75253fa (HEAD -> main) Add total label to cart
a2af014 Add price formatter
ORIG_HEAD holds a single value and gets overwritten at every big move. So it can only be trusted for "the last move". If you did another reset or rebase in between, go back to the reflog. And if you pushed the merge, this road is closed; the way to undo a shared merge is git revert -m 1, as we saw in the revert post.
Bringing Back a Deleted Branch
Ada thought feature/wishlist was done and deleted it with a capital D:
$ git branch -D feature/wishlist
Deleted branch feature/wishlist (was e1f0c14).
If you notice right away, the easiest way is that output itself: (was e1f0c14). You recreate the branch from that hash. But if you closed the terminal or never saw the output, you go back to the reflog. Let's look at the latest entries first:
$ git reflog -6
75253fa (HEAD -> main) HEAD@{0}: reset: moving to ORIG_HEAD
d1e7936 HEAD@{1}: merge feature/wishlist: Merge made by the 'ort' strategy.
75253fa (HEAD -> main) HEAD@{2}: reset: moving to HEAD@{1}
077420f HEAD@{3}: reset: moving to HEAD~2
75253fa (HEAD -> main) HEAD@{4}: commit: Add total label to cart
a2af014 HEAD@{5}: commit: Add price formatter
Deleting a branch doesn't move HEAD, so it leaves no line in HEAD's reflog; the branch's own reflog is deleted together with the branch. What you are looking for is the last commit you made while on that branch, or the moment you left it. If the list is long, filter it:
$ git reflog | grep wishlist
d1e7936 HEAD@{1}: merge feature/wishlist: Merge made by the 'ort' strategy.
077420f HEAD@{6}: checkout: moving from feature/wishlist to main
e1f0c14 HEAD@{7}: commit: Document wishlist in README
76745bd HEAD@{8}: commit: Add wishlist module
077420f HEAD@{9}: checkout: moving from main to feature/wishlist
The checkout: moving from feature/wishlist to main line is the moment you left the branch. The entry right below it is the last commit you made on that branch: e1f0c14.
$ git branch feature/wishlist e1f0c14
$ git log --oneline -2 feature/wishlist
e1f0c14 (feature/wishlist) Document wishlist in README
76745bd Add wishlist module
Notice this: in the reflog at the start of the post e1f0c14 was HEAD@{4}, now it is HEAD@{7}. Every new move shifts the numbers by one. If you ran other commands after finding a number in the reflog, look at the list again; the safest option is to use the hash instead of the number.
A Branch's Own Reflog: Undoing a Rebase
HEAD's reflog keeps everything in one list, and it gets crowded during multi-step operations like rebase. Every branch also has its own reflog, which records only the moves of that branch's tip. Ada rebased feature/wishlist onto main:
$ git switch feature/wishlist
Switched to branch 'feature/wishlist'
$ git rebase main
Successfully rebased and updated refs/heads/feature/wishlist.
$ git reflog show feature/wishlist
7ffad63 (HEAD -> feature/wishlist) feature/wishlist@{0}: rebase (finish): refs/heads/feature/wishlist onto 75253fa6130576b4458ac263bd8be0a9f6c0662a
e1f0c14 feature/wishlist@{1}: branch: Created from e1f0c14
Two lines: one for the end of the rebase, the other for the moment the branch was recreated. The state before the rebase is feature/wishlist@{1}. To back out of the rebase:
$ git reset --hard feature/wishlist@{1}
HEAD is now at e1f0c14 Document wishlist in README
$ git log --oneline -3
e1f0c14 (HEAD -> feature/wishlist) Document wishlist in README
76745bd Add wishlist module
077420f Add .gitignore
When you delete a line by mistake in an interactive rebase, the fix is the same: find the entry before the rebase in the branch's reflog and go back to it. The same @{} syntax works with time as well: expressions like main@{yesterday} or HEAD@{2.hours.ago} give you that ref as it was at that time.
The Reflog Is Local and Has an Expiry
The reflog lives on your machine, under .git/logs. It is not pushed and not cloned. Clone the same repo again and the logbook starts from scratch:
$ cd ..
$ git clone -q bookstore bookstore-copy
$ cd bookstore-copy
$ git reflog
75253fa (HEAD -> main, origin/main, origin/HEAD) HEAD@{0}: clone: from .../bookstore
(I shortened the beginning of the path.) A single line. In practice this means you won't find a commit your teammate lost in your reflog; they have to search on their own machine. A CI machine starts every job from a fresh clone, so you can't rely on the reflog there either. The repo on GitHub doesn't have a reflog you can see, either.
Reflog entries don't stay forever. When git gc runs, entries older than gc.reflogExpire are removed; the default is 90 days. There is a separate setting for entries whose commits are not reachable from the ref's current tip, gc.reflogExpireUnreachable, which defaults to 30 days. Once the entry is gone, commits nothing points to are really deleted at the next cleanup. So the reflog is a recycle bin, not an archive. If you lost something, look for it now, not weeks later.
Common Pitfalls
Looking in the reflog for changes that were never committed. The reflog records commits. Lines you threw away with reset --hard or restore that were never committed are not there. The contents of files you added to the index with git add stay around as objects for a while and can be found with git fsck --lost-found, but that is a last resort. The best protection is committing often.
Mixing up HEAD@{1} and HEAD~1. One goes back in time, the other along the parent chain. Use the wrong one after a reset and you lose one more commit.
Trusting an old number. Reflog numbers shift with every move. If you ran a command between reading a number and using it, look at the list again.
Treating the reflog as a backup. It is local, it expires and it is tied to one machine. A real backup is a pushed branch.
Running commands like git gc --prune=now and git reflog expire --expire=now by rote "to clean up the repo". They close exactly the recovery paths in this post.
A Small Experiment
★☆☆ Make three commits in your bookstore repo, run git reset --hard HEAD~3, then bring the commits back both with HEAD@{n} and by first running git branch rescue <hash>. Compare the two ways.
★★☆ Create a branch, make two commits, go back to main and delete the branch with -D. Without looking at the deletion output, bring the branch back using only git reflog. Write in one sentence which line you looked for.
★★★ Tidy a branch with an interactive rebase and drop one commit on purpose. Then go back to the state before the rebase with git reflog show <branch>, and verify which change came back with a comparison like git diff <branch>@{1} <branch>.
The reflog tells you "where HEAD stood and when"; but giving a commit a permanent name that everyone can see is a different thing. In the next post we mark releases with tags and look at lightweight versus annotated tags, git describe and GitHub Releases.