Skip to content
// blog

Undo a hard reset: how far the reflog reaches

theBGuy 10 min read
On this page

You ran git reset --hard HEAD~2 because the last two commits looked disposable. They weren’t.

$ git log --oneline
30b6dbc add cli flags
2333eb4 add parser
8afb88a init
$ git reset --hard HEAD~2
HEAD is now at 8afb88a init
$ git log --oneline
8afb88a init

The branch is two commits shorter, the working tree matches it, and git log reports the new state as if the old one had been a rumor. That’s because log answers one question: what is reachable, walking parents back from this tip? 30b6dbc has no place in that walk anymore. The commit itself still exists — nothing that removes reachability removes objects; that job belongs to garbage collection, much later.

The problem isn’t recovery, it’s addressing: you need the hash of a commit that no ref and no log will show you. Git wrote it down. (All of this is Git 2.51, stock configuration.)

The reflog

Every time a ref that keeps a reflog moves (your branches and HEAD all do, by default), Git appends a line: the new position and the action that caused it. The list runs on wall-clock order, not ancestry, and it keeps growing whether or not the old position stays reachable:

$ git reflog
8afb88a HEAD@{0}: reset: moving to HEAD~2
30b6dbc HEAD@{1}: commit: add cli flags
2333eb4 HEAD@{2}: commit: add parser
8afb88a HEAD@{3}: commit (initial): init

HEAD@{0} is where HEAD stands now and the move that put it there. HEAD@{1} is one move earlier — and there is the “lost” commit, hash and all. The rescue is itself a hard reset, so a few cautions first. --hard replaces your working tree: if you’ve made uncommitted changes since the incident, run git stash --include-untracked and pop it after. Stashing writes a reset entry of its own into HEAD’s reflog, which slides the lost commit one slot down: after a stash, re-read git reflog, or skip the counting entirely with git reset --hard 30b6dbc, which names the commit and can’t drift. (--include-untracked still leaves ignored files out, so if one shares a path with the commits you’re restoring, move it aside first or reach for git stash --all.) And if you’d rather not move the branch at all, git branch rescue 30b6dbc hangs a new branch on the lost commit and leaves everything else where it is. Otherwise, the undo is one more move:

$ git reset --hard HEAD@{1}
HEAD is now at 30b6dbc add cli flags
$ git log --oneline
30b6dbc add cli flags
2333eb4 add parser
8afb88a init

HEAD’s reflog and the branch’s

There is no single reflog. Every branch keeps its own, and so does HEAD, and they record different things. A branch’s reflog gains a line only when the branch itself moves:

$ git reflog show main
30b6dbc main@{0}: reset: moving to HEAD@{1}
8afb88a main@{1}: reset: moving to HEAD~2
30b6dbc main@{2}: commit: add cli flags
2333eb4 main@{3}: commit: add parser
8afb88a main@{4}: commit (initial): init

Commits and both resets are here: a hard reset on a checked-out branch moves the branch and HEAD together, and each reflog records it. What main’s reflog will not contain is a checkout, because switching branches moves HEAD alone. A new branch’s reflog opens by naming where it came from, and an amend, which moves the tip, records like any other move:

$ git switch -c feature
Switched to a new branch 'feature'
$ git reflog show feature
30b6dbc feature@{0}: branch: Created from HEAD
$ git commit -am "wip"
[feature 3dee353] wip
 1 file changed, 1 insertion(+)
$ git commit --amend -m "add search"
[feature db4f742] add search
 Date: Wed Sep 23 11:48:25 2026 -0400
 1 file changed, 1 insertion(+)
$ git reflog show feature
db4f742 feature@{0}: commit (amend): add search
3dee353 feature@{1}: commit: wip
30b6dbc feature@{2}: branch: Created from HEAD

The pre-amend state is addressable right there: 3dee353, one line below the amend that replaced it.

A deleted branch takes its reflog with it

The reflog belongs to the ref. Delete one and you delete the other:

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

Notice (was db4f742): Git hands you the undo in the delete receipt. And the branch’s reflog dying doesn’t orphan its story, because HEAD’s watched every move you made while you sat on that branch:

$ git reflog -6
30b6dbc HEAD@{0}: checkout: moving from feature to main
db4f742 HEAD@{1}: commit (amend): add search
3dee353 HEAD@{2}: commit: wip
30b6dbc HEAD@{3}: checkout: moving from main to feature
30b6dbc HEAD@{4}: reset: moving to HEAD@{1}
8afb88a HEAD@{5}: reset: moving to HEAD~2

The delete only removed the name, so only the name needs remaking:

$ git branch feature db4f742
$ git reflog show feature
db4f742 feature@{0}: branch: Created from db4f742

The branch is back. Its reflog is not: one entry, dated now. Whatever you want to know about the branch’s past, you now ask HEAD — and HEAD’s reflog holds only the moves HEAD itself made. A branch that was created and deleted without a checkout, or one that was moved without one by git fetch origin main:main or git branch -f, or one tended in a linked worktree (each worktree’s HEAD keeps its own reflog, which leaves when the worktree does) has positions no reflog of yours remembers. There the delete receipt is the whole rescue, and if it has scrolled away, git fsck --unreachable still knows the hash; more on that door below.

Commits no branch ever held

A commit made on a detached HEAD (an experiment, a CI script, a quick look at an old release) has no branch reflog at all. HEAD’s is the only record there is, and Git knows it, which is why walking away from one triggers the loudest warning in this post:

$ git switch --detach main
HEAD is now at 30b6dbc add cli flags
$ git commit -am "try a spike"
[detached HEAD 52dcfb2] try a spike
 1 file changed, 1 insertion(+)
$ git switch main
Warning: you are leaving 1 commit behind, not connected to
any of your branches:

  52dcfb2 try a spike

If you want to keep it by creating a new branch, this may be a good time
to do so with:

 git branch <new-branch-name> 52dcfb2

Switched to branch 'main'

The rescue command is printed for you, hash included. If it scrolled past, HEAD’s reflog caught the same hash: git reflog shows 52dcfb2 HEAD@{1}: commit: try a spike, and git branch spike 52dcfb2 makes the commit permanent.

Where the reflog ends

Everything so far worked because a reflog existed and reached back far enough. Three ways that assumption fails, in rising order of surprise.

It clamps at its own beginning. A reflog answers time queries: main@{1.hour.ago} means “where was main an hour ago”. But ask about a time before its first entry and you get the oldest answer available, plus a warning:

$ git log -1 --oneline main@{1.hour.ago}
warning: log for 'main' only goes back to Wed, 23 Sep 2026 11:48:24 -0400
8afb88a init

(This demo repo is minutes old, so an hour ago predates its reflog.) A script that resolves @{time} without reading warnings will happily use the clamped answer as if it were the real one.

It stays home. The reflog is not part of history, and no transfer carries it: push and pull move commits between repositories, while each side’s reflogs record only what happened locally. A clone starts new ones:

$ cd ..
$ git clone -q app copy
$ cd copy
$ git reflog
30b6dbc HEAD@{0}: clone: from …/app

One line. Your reflog can’t help a teammate undo their reset, theirs can’t help you, and a fresh clone of your own repository arrives with no past. The safety net is strictly local.

It can be erased while the history stays. Entries expire on their own eventually (90 days for reachable positions, 30 for unreachable ones, by default), and git reflog expire will erase a reflog on request — including entries that point at commits alive on the branch:

$ git reflog expire --expire=now refs/heads/main
$ git reflog show main
$ git log --oneline
30b6dbc add cli flags
2333eb4 add parser
8afb88a init

The history is intact; the record of its moves is gone. And notice what the emptiness looks like: git reflog show main prints nothing, which is also exactly what it prints for a ref whose logging was switched off from the start. To every read of the log those two are the same thing, but git reflog exists still tells them apart, because expiry empties the file where disabling never writes one:

$ git reflog exists refs/heads/main && echo yes
yes

The expiry above was per-ref, too: it named refs/heads/main, and left HEAD’s reflog untouched.

Two smaller edges: tags don’t get reflogs by default (branches, remote-tracking refs, notes, and HEAD do), and the stash list is itself a reflog on one ref — stash@{0} is a reflog entry, so dropping one edits the record.

And when the reflog has failed you (expired, erased, or never there), one door is left. Garbage collection spares whatever a reflog still names, but a commit nothing names anymore only waits; until gc takes it, git fsck --unreachable will list it, hash and all, and a hash is all a rescue needs. That walk, twin trap and all, is the dropped-stash post.

Or don’t do any of this

Two of GitDesktop’s safety checks are reflog readers, and both had to answer the question this last section raises: what do you do when the reflog isn’t there?

A force push from the app reaches for the strict flag pair, --force-with-lease --force-if-includes. The second of those walks the reflog to prove the remote work you’re about to overwrite was actually seen and integrated locally, not merely fetched past. A branch whose reflog was never written has no way to pass that check, and the app doesn’t pretend it did: the push falls back to the lease alone, and the success toast names the weaker guarantee it ran under. An expired reflog gets no such grace: git reflog exists still answers yes for the emptied file, and the app leaves Git’s refusal standing rather than trading an evidence problem for a weaker guard.

The second reader watches for the situation the fork-point post was about: an upstream rewritten underneath your branch. When a branch and its upstream have diverged, the app pairs two probes before it offers the recovery reset: are all of your local commits already upstream patch-for-patch, and is the upstream’s tip a position your branch’s own reflog has ever seen? A rewrite says yes to the first and no to the second. A branch with no reflog can’t answer the second question at all — and reading no data as “rewritten”, or as “safe”, would both be guesses. The verdict stays unknown, pinned by rewrite_status_without_a_reflog_refuses_to_guess.

The reflog is a record with edges, and a tool that reads it has to treat the edge as an answer of its own, distinct from anything the record could have said.

git log is the history that won. The reflog is the history that happened.

Written while building GitDesktop.

A desktop Git client for GitHub, GitLab and Bitbucket — free and open source, with AI you control or hide entirely.