ivãstival

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."

Two panels. Left, git log: a commit graph where a feature branch peels off main and merges back in, showing only which commit descends from which — a fast-forward merge leaves no distinct trace here, it just relabels the tip. Right, git reflog: a chronological, local-only list of every place HEAD pointed — checkout, checkout, merge fast-forward, checkout — including the exact entry recorded the instant before the merge moved the branch pointer.

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>
EventWritten whenTypical description
commitA new commit is createdcommit: <message>
checkoutHEAD moves between branches or commitscheckout: moving from X to Y
mergeA merge completes, fast-forward or notmerge <branch>: Fast-forward
resetgit reset moves a branch pointerreset: moving to <target>
rebase / pullRebase and fetch-then-merge operationsone 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 typeNew commit createdWhat the reflog recordsWhat git log shows
Fast-forwardNoOne merge: Fast-forward entryThe tip just moves; no merge node
Three-way (non-fast-forward)Yes, a merge commitOne merge entry pointing at the new commitA 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."

Five reflog rows read newest-first from HEAD@{0} to HEAD@{4}: checkout to main, checkout to the feature branch, a fast-forward merge highlighted in orange at HEAD@{2}, a checkout to the feature branch at hash a1b2c3d highlighted in teal at HEAD@{3}, and the last commit on the feature branch at HEAD@{4}. A callout marks HEAD@{3} as main's own tip the instant before the merge moved the pointer, making a1b2c3d the reset target.

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

  1. Run git reflog (or git reflog show main if you are not currently on main) and read from the top.
  2. Find the merge ... Fast-forward entry — this is the unwanted merge.
  3. Read the entry immediately below it. That is where the branch sat one moment earlier, before the merge moved it.
  4. 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 a1b2c3dHEAD@{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.

ApproachWhat happensSafe whenRisk
git revert <merge-or-commits>Adds new commit(s) that undo the changes; history keeps every eventOthers may have already pulled the mergeNone to shared history; the unwanted change is still visible in history
git reset --hard <target> + git push --force-with-leaseMoves the branch pointer back; rewrites what the branch points toNobody else has based work on the commits being droppedRewrites 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.

Before: local main and origin/main both point at the bad merge commit d4e5f6a, reached by a straight line from a1b2c3d. After: git reset --hard moves main back to a1b2c3d, and git push --force-with-lease moves origin/main to match — shown as both pointers landing on a1b2c3d. The abandoned commit d4e5f6a is drawn as a dashed side branch still labeled feature/branch-name, noting it is unreferenced by main but still a real, reachable branch tip until garbage collection removes it.

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

  1. Confirm the working tree is clean (git status) before touching history — stash or commit anything in progress first.
  2. Run git reflog and locate the merge ... Fast-forward entry for the unwanted merge.
  3. Read the entry one line below it; that hash is the branch's last valid position.
  4. Confirm that commit is coherent with git show -s --oneline <hash>.
  5. Check divergence between local and remote with git rev-list --left-right --count <branch>...origin/<branch>.
  6. Choose revert if collaborators may already have the merge, or reset --hard + push --force-with-lease if nobody has built on top of it yet.
  7. 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 gc prunes 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 gc collects them.

Glossary

TermMeaning in this context
ReflogLocal, chronological log of every position a ref has held
Fast-forwardA 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-leaseA force-push that aborts if the remote ref moved since your last fetch
Dangling commitA commit no branch or tag currently names, still reachable by hash until garbage-collected

Referenced Commands

CommandResponsibility
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...BReports 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-leaseForce-pushes only if the remote branch has not moved since your last fetch