git
Architecture: Recovering a Branch with the Reflog
Overview
git log answers "what descends from what." It walks the commit graph, and a
commit graph is shared — anyone who clones the repository sees the same one.
A fast-forward merge does not add a node to that graph; it just relabels an
existing tip. So the moment a branch pointer moved is invisible to git log.
git reflog answers a different question: "where has this pointer been, in
what order, on this machine." Every checkout, commit, merge, and reset writes
one entry, newest first. That is exactly the record needed to undo a merge —
not "what is main's history," but "where was main one step ago."
The graph on the left is what you'd inspect to understand history. The list on the right is what you inspect to undo something that just happened.
The Reflog Contract
The reflog is a per-ref log, kept locally in .git/logs/. git reflog with
no arguments shows HEAD's log; git reflog show main shows the log for a
specific branch ref. Each line has the same shape:
<commit> HEAD@{N}: <event>: <description>
| Event | Written when | Typical description |
|---|---|---|
commit | A new commit is created | commit: <message> |
checkout | HEAD moves between branches or commits | checkout: moving from X to Y |
merge | A merge completes, fast-forward or not | merge <branch>: Fast-forward |
reset | git reset moves a branch pointer | reset: moving to <target> |
rebase / pull | Rebase and fetch-then-merge operations | one entry per step |
HEAD@{0} is always the most recent entry; higher numbers go further back
in time. This ordering is the opposite of reading a history top to bottom —
you read a reflog from the top down, and down means further into the past.
Anatomy of a Fast-Forward Merge
A merge is only fast-forward when the branch being merged in is a direct descendant of the target branch — nothing new happened on the target branch in the meantime. Git does not create a merge commit; it simply moves the target branch's pointer forward to match.
| Merge type | New commit created | What the reflog records | What git log shows |
|---|---|---|---|
| Fast-forward | No | One merge: Fast-forward entry | The tip just moves; no merge node |
| Three-way (non-fast-forward) | Yes, a merge commit | One merge entry pointing at the new commit | A visible merge node with two parents |
This is precisely why "the last valid commit" cannot be read off git log
for a fast-forward: the commit that used to be the tip is still in the
graph, but nothing in the graph marks it as "where main was before."
HEAD@{2} is the merge itself. Reading one entry further back, HEAD@{3}, lands on the exact commit main pointed to the moment before — the reflog gives that answer directly, with no need to infer it from commit messages or timestamps.
Finding the Last Valid Commit
- Run
git reflog(orgit reflog show mainif you are not currently onmain) and read from the top. - Find the
merge ... Fast-forwardentry — this is the unwanted merge. - Read the entry immediately below it. That is where the branch sat one moment earlier, before the merge moved it.
- Sanity-check that candidate before trusting it:
git show -s --oneline <hash>— confirm it is a real, coherent commit and not a mid-rebase or half-finished state.git rev-list --left-right --count main...origin/<branch>— confirm whether local and remote have already diverged, which changes whether a later force-push will be clean.
$ git reflog
d4e5f6a HEAD@{0}: checkout: moving from feature/branch-name to main
d4e5f6a HEAD@{1}: checkout: moving from main to feature/branch-name
d4e5f6a HEAD@{2}: merge feature/branch-name: Fast-forward
a1b2c3d HEAD@{3}: checkout: moving from feature/branch-name to main
d4e5f6a HEAD@{4}: commit: last commit on feature/branch-name
Here a1b2c3d — HEAD@{3} — is the answer: not because of anything in its
commit message, but because the reflog recorded it as main's position the
instant before HEAD@{2} moved the pointer forward.
Undoing the Merge
Once the target commit is confirmed, there are two ways to act on it, and they are not interchangeable.
| Approach | What happens | Safe when | Risk |
|---|---|---|---|
git revert <merge-or-commits> | Adds new commit(s) that undo the changes; history keeps every event | Others may have already pulled the merge | None to shared history; the unwanted change is still visible in history |
git reset --hard <target> + git push --force-with-lease | Moves the branch pointer back; rewrites what the branch points to | Nobody else has based work on the commits being dropped | Rewrites shared history; collaborators must re-sync |
--force-with-lease (rather than plain --force) refuses the push if
origin's branch moved since you last fetched it — it protects against
clobbering a commit someone else just pushed, without weakening the rest of
the operation.
Resetting a branch only moves its pointer. The abandoned commits are not deleted — they stay reachable from whatever other ref still names them, and from the reflog itself, until something actually garbage-collects them.
How to Recover From an Unwanted Merge
- Confirm the working tree is clean (
git status) before touching history — stash or commit anything in progress first. - Run
git reflogand locate themerge ... Fast-forwardentry for the unwanted merge. - Read the entry one line below it; that hash is the branch's last valid position.
- Confirm that commit is coherent with
git show -s --oneline <hash>. - Check divergence between local and remote with
git rev-list --left-right --count <branch>...origin/<branch>. - Choose
revertif collaborators may already have the merge, orreset --hard+push --force-with-leaseif nobody has built on top of it yet. - After a reset and force-push, tell collaborators to re-sync (typically a fresh fetch and a hard reset of their own local branch) rather than merging or rebasing on top of the abandoned commits.
Reflog Limits
- The reflog is local only — it lives under
.git/logs/and was never pushed, fetched, or cloned. This technique only works from a machine (or clone) that actually performed the merge. - Entries expire. By default,
git gcprunes reflog entries older than 90 days if still reachable (gc.reflogExpire) and 30 days if unreachable (gc.reflogExpireUnreachable). Recovery is a closing window, not a permanent safety net. - A reset does not delete commits. They remain reachable — and recoverable
by hash — through any other ref that still names them, or through the
reflog, until an actual
git gccollects them.
Glossary
| Term | Meaning in this context |
|---|---|
| Reflog | Local, chronological log of every position a ref has held |
| Fast-forward | A merge that only moves a pointer forward, creating no merge commit |
HEAD@{N} | The Nth-most-recent reflog entry for the current ref, 0-indexed from now |
--force-with-lease | A force-push that aborts if the remote ref moved since your last fetch |
| Dangling commit | A commit no branch or tag currently names, still reachable by hash until garbage-collected |
Referenced Commands
| Command | Responsibility |
|---|---|
git reflog / git reflog show <ref> | Lists chronological pointer movements for HEAD or a specific ref |
git show -s --oneline <hash> | Confirms a candidate commit is real and coherent before acting on it |
git rev-list --left-right --count A...B | Reports how many commits each side has that the other lacks |
git revert <commit> | Undoes a commit's changes via a new commit, without rewriting history |
git reset --hard <target> | Moves the current branch pointer to <target> and matches the working tree to it |
git push --force-with-lease | Force-pushes only if the remote branch has not moved since your last fetch |