Git Internals for Engineers

The Reflog — Your Safety Net

A developer runs git reset --hard HEAD~5 to "clean up" their branch. They immediately realize they meant to reset only one commit. Five commits of work — a full day — are gone from the branch. They turn white. A senior at the next desk walks over, types git reflog, points at the entry from 12 seconds ago, and runs git reset --hard HEAD@{1}. The five commits are back. The junior's face goes from despair to amazement in under a minute. This is the conversation that makes every Git user a reflog user for life.

The reflog is Git's journal of every HEAD change. Reset, rebase, checkout, commit, amend — they all write entries. Within the default 30-day retention, almost anything you do can be undone by pointing HEAD back to an earlier entry. This lesson is the full tour: what the reflog contains, how to read it, and the specific recovery recipes for the most common "oh no" situations.


What the Reflog Actually Is

The reflog is a per-ref log of every value a ref has held. There is one reflog for HEAD, and one for each branch:

ls .git/logs/
# HEAD
# refs/

ls .git/logs/refs/heads/
# main
# feature

cat .git/logs/HEAD | head
# 0000... a1b2c3d Sharon <sharon@example.com> 1713600000 +0000  commit (initial): first commit
# a1b2c3d 789abc0 Sharon <sharon@example.com> 1713600060 +0000  commit: add readme
# 789abc0 d4e5f6a Sharon <sharon@example.com> 1713600120 +0000  commit: add login
# d4e5f6a 22221111 Sharon <sharon@example.com> 1713600180 +0000  reset: moving to HEAD~2
# ...

Each line: <old-sha> <new-sha> <author> <timestamp> <operation: message>. Every time HEAD (or a branch) moves, a new line is appended.

git reflog reads this file and formats it:

git reflog
# a1b2c3d HEAD@{0}: commit: add login
# 789abc0 HEAD@{1}: commit: add readme
# 22221111 HEAD@{2}: reset: moving to HEAD~2
# d4e5f6a HEAD@{3}: commit: add login (before reset)
# ...

HEAD@{0} is the current position. HEAD@{1} is where HEAD was before its last change. HEAD@{N} is N entries back.

KEY CONCEPT

The reflog is local to your clone. Nobody else's Git sees your reflog; nothing is pushed with it. It is your personal safety net for your history — a private local log of every ref movement. That is both its strength (nobody can mess with it) and its limit (if your clone is lost, so is the reflog).


The Mental Model

Every time HEAD changes:

  • git commit — HEAD moves to the new commit; reflog entry added.
  • git checkout <branch> — HEAD moves to point at the branch tip; reflog entry.
  • git reset --hard <sha> — HEAD jumps to a different commit; reflog entry.
  • git merge — HEAD moves to the merge commit; reflog entry.
  • git rebase — dozens of entries as HEAD moves commit-by-commit through the rebase.
  • git cherry-pick — one entry per picked commit.
  • git pull — entries for fetch + merge/rebase.

Every movement is recorded. The content is not duplicated — only the SHA it pointed at is stored. The commits themselves live in .git/objects/ as long as something references them, including the reflog.


Reading the Reflog

# Just HEAD's reflog (the usual one)
git reflog

# Same thing explicitly
git reflog show HEAD

# Reflog for a specific branch
git reflog show main
git reflog show feature

# With more context
git reflog --date=iso
# a1b2c3d HEAD@{2026-04-20 10:00:00 +0000}: commit: add login
# 789abc0 HEAD@{2026-04-20 09:59:00 +0000}: commit: add readme

Date variants:

  • HEAD@{5} — 5 entries ago.
  • HEAD@{yesterday} — where HEAD was 24 hours ago.
  • HEAD@{2026-04-15} — where HEAD was on that date.
  • HEAD@{1.hour.ago}, HEAD@{3.days.ago}.

These work anywhere a commit ref is expected:

git show HEAD@{yesterday}
git diff HEAD@{1.hour.ago} HEAD
git checkout HEAD@{3}

The Golden Recipe: Undo Anything

When you realize "I just did something terrible":

# 1. Look at the reflog
git reflog
# a1b2c3d HEAD@{0}: reset: moving to HEAD~5     ← bad move, this is "now"
# b2c3d4e HEAD@{1}: commit: my last good commit ← where I want to be
# ...

# 2. Reset to the entry before the bad move
git reset --hard HEAD@{1}
# or use the SHA directly:
git reset --hard b2c3d4e

# 3. Done. You're back.

That is the universal recovery. Almost every "I lost work" situation collapses into this three-step flow.


Specific Recovery Recipes

"I did git reset --hard and lost commits"

git reflog
# <new-sha> HEAD@{0}: reset: moving to HEAD~N
# <old-sha> HEAD@{1}: ...                        ← this is your pre-reset state

git reset --hard HEAD@{1}

"I deleted a branch by accident"

Branch reflogs persist even after the branch is gone — but only briefly, and only for branches that had activity. Safer: grab the branch's last SHA from HEAD's reflog:

git reflog
# Look for the last time you were on the deleted branch:
# abc123 HEAD@{5}: checkout: moving from feature to main
#         ← feature was at abc123 at that point

git branch recovered-feature abc123
# Branch recreated. Check it out to resume work.

If the branch had its own reflog (e.g., you had made commits on it):

git reflog show feature@{now}
# (deleted branch's reflog — may still exist briefly)

"I amended the wrong commit"

git reflog
# <new> HEAD@{0}: commit (amend): new message
# <old> HEAD@{1}: commit: original message     ← your pre-amend state

# Option 1: reset to the pre-amend commit
git reset --hard HEAD@{1}

# Option 2: use the old SHA to bring back that commit as a branch
git branch pre-amend HEAD@{1}

"I force-pushed over someone's work"

The reflog of the remote server would have recorded the overwritten state, but you cannot see that from your clone. However:

  • If the collaborator has their local clone, ask them for the SHA of what they had (they can git reflog locally).
  • If not, and the GitHub/GitLab platform has reflog-like protection, check there (some platforms keep 7 days of ref history).
  • Recover with git push --force-with-lease origin <their-old-sha>:<branch-name> if you got the SHA.

Prevention: use --force-with-lease (never --force) and review before pushing.

"My rebase went wrong and I don't remember where I was before"

git reflog
# <current> HEAD@{0}: rebase -i (finish): refs/heads/feature ...
# <middle>  HEAD@{1}: rebase -i (pick): ...
# <middle>  HEAD@{2}: rebase -i (pick): ...
# ...
# <before>  HEAD@{15}: checkout: moving from main to feature
# ← this is your pre-rebase tip

git reset --hard HEAD@{15}

Or more simply: before starting any rebase, make a backup branch — git branch backup-before-rebase. Recovery is then git reset --hard backup-before-rebase.

"I committed to the wrong branch"

# You committed to main instead of feature.
# Simplest undo + redo:
git log --oneline -3
# bad-sha (HEAD -> main) my accidental commit on main
# good-sha previous main
# ...

# Step 1: create feature at current main (where the accidental commit is)
git branch feature

# Step 2: reset main back to the good commit
git reset --hard HEAD~1

# Step 3: switch to feature
git checkout feature
# feature has the commit; main is clean

"I lost my stash"

git stash drop deletes the stash ref. But stashes are commits with special parents, and they stay in the reflog briefly:

git fsck --unreachable --no-reflogs | grep commit
# Look for commits tagged as "WIP on ..."
# Or:

git log --graph --oneline --all --reflog
# Including reflog entries in log — finds recent stashes you dropped

git show <sha>    # verify it's your stash
git branch stash-recovery <sha>
# Or: git stash apply <sha>

Note: without --no-reflogs, git fsck considers reflog-referenced commits as reachable. The --no-reflogs option shows you commits that are only in the reflog — which is what you want for orphan recovery.

PRO TIP

The very first thing to do when anything goes wrong: git reflog | head -20. Your most recent 20 reflog entries are almost always enough context to see what happened and recover. Before Googling, before asking a senior, always git reflog.


Reflog Retention

# Default: 90 days for reachable entries, 30 days for unreachable
git config --get gc.reflogExpire
# 90 days

git config --get gc.reflogExpireUnreachable
# 30 days

# To extend:
git config --global gc.reflogExpire 365.days
git config --global gc.reflogExpireUnreachable 90.days

"Unreachable" means: entries referring to commits no longer reachable from any ref. After the configured period, git gc prunes them, and the underlying commits in .git/objects/ can be collected (if nothing else references them).

For critical projects where recovery-via-reflog should be durable, set longer retention. For tiny laptops short on disk, the defaults are fine.

Force reflog expiration

If you actually want to clear reflog (for privacy, cleaning up, or forcing GC):

git reflog expire --expire=now --all        # set all reflog expiry to now
git gc --prune=now --aggressive              # GC with immediate pruning

This is how you permanently delete commits (e.g., after using git filter-repo to remove a leaked secret). Without it, the secret-containing commit lingers in the reflog for 30-90 days.


Inspecting History With Reflog

# Log including reflog entries
git log --walk-reflogs
# Same output format as git log, but from reflog, oldest-first

# Log with graph including reflog (finds lost commits across branches)
git log --graph --oneline --all --reflog

# List unreachable objects (maybe lost commits)
git fsck --unreachable --no-reflogs
# Shows orphans from dropped stashes, lost commits from deleted branches, etc.

Reflog vs git log --all

These are different:

  • git log --all walks history from every ref (branches, tags, remote-tracking). Includes only what refs currently point at.
  • git log --reflog walks history starting from every reflog entry. Includes past states — things that are no longer reachable from any ref.
# Normal history: currently-reachable commits
git log --all --oneline | wc -l
# 143

# Include reflog: also past commits that are no longer referenced
git log --all --reflog --oneline | wc -l
# 187   ← 44 reflog-only commits (including dropped stashes, old amends, etc.)

For "find a commit I lost," --reflog is the right flag.


Reflog on Branches, Not Just HEAD

Every branch has its own reflog:

# Every movement of main
git reflog show main
# a1b2c3d main@{0}: commit: add login
# 789abc0 main@{1}: commit: add readme
# 22221111 main@{2}: reset: moving to HEAD~1
# ...

# You can use these
git show main@{5}
git reset --hard feature@{2}

The branch reflog lets you recover even if you have moved HEAD around a lot: "where was main 3 days ago?" is main@{3.days.ago}.


Common Confusions

HEAD^ vs HEAD@{1}

  • HEAD^ = the commit's parent (one step backward in the commit graph).
  • HEAD@{1} = where HEAD pointed one reflog entry ago.

Very different. After an amend, HEAD^ is the parent of the new commit (same as the old parent). HEAD@{1} is the pre-amend commit (which is NOT the new commit's parent).

Reflog and other clones

# My clone's reflog is local
git reflog          # shows MY ref movements

# A collaborator's is separate
# (they have their own reflog; I can't see it, they can't see mine)

For team-wide "what happened" after an incident, you need each person's reflog (if relevant) plus the remote's history. GitHub, GitLab, and others keep some ref history that can be consulted via their web UIs or APIs.


Automating Reflog Hygiene

If your team uses rebases heavily and you have disk to spare:

# Set on new machines as part of dotfiles setup
git config --global gc.reflogExpire "1.year"
git config --global gc.reflogExpireUnreachable "1.year"
git config --global rerere.enabled true
git config --global merge.conflictstyle zdiff3
git config --global rebase.autosquash true
git config --global pull.rebase true

These are the five configs that dramatically improve the Git UX and safety net. Apply once; never worry again.

WAR STORY

A developer ran git stash drop to clean up old stashes. They immediately realized one of them was their "I'll come back to this" feature from three weeks ago — four hours of work. Within 30 days of the drop (the default unreachable-reflog window), they could have recovered it with git fsck --unreachable. But they did not realize until 45 days later. The commit was gone — garbage-collected. Lesson: git stash drop is destructive within the reflog window (recoverable) and catastrophic outside it (irrecoverable). Extending reflog retention to a year has essentially zero cost on disk but provides a massive safety margin.


Key Concepts Summary

  • The reflog is a per-ref journal of every SHA a ref has held.
  • Every HEAD change (commit, reset, checkout, rebase, merge, cherry-pick) adds a reflog entry.
  • git reflog shows HEAD's reflog; git reflog show <branch> shows a branch's.
  • HEAD@{N} = N reflog entries ago. HEAD@{yesterday} = where HEAD was 24h ago.
  • Recovery pattern: git reflog, identify the pre-mistake entry, git reset --hard HEAD@{N}.
  • Retention defaults: 90 days reachable, 30 days unreachable. Configurable.
  • The reflog is local. Not pushed, not shared. Your private safety net.
  • git fsck --unreachable --no-reflogs finds truly orphaned commits — like dropped stashes.
  • Backup branches beat reflog for recoveries you want durable over months or years.
  • Force expiration (git reflog expire --expire=now --all + git gc --prune=now) is needed to permanently remove commits (e.g., leaked secrets).

Common Mistakes

  • Panicking and re-doing hours of work, forgetting the reflog exists.
  • Confusing HEAD^ (parent) with HEAD@{1} (previous reflog entry). After an amend, these are different.
  • Assuming the reflog is pushed to the remote. It is local only.
  • Not making a backup branch before a risky rebase, then relying solely on the reflog which might expire.
  • Forgetting that clones have separate reflogs. Your teammate's laptop might still have the lost commits even when yours does not.
  • Using git push --force (no lease) and overwriting a shared branch without realizing it. --force-with-lease + reflog cannot save you if a coworker has already pulled the bad state.
  • Running git reflog expire prematurely on a healthy repo "to clean up." You just gave up your safety net.
  • Relying on the reflog for months-old recoveries. It expires. Backup branches or tags for durable recovery.
  • Using git checkout HEAD@{...} and ending up in detached HEAD without realizing. Always follow with git branch <name> to save the state as a named ref.
  • Not setting gc.reflogExpire longer when disk is cheap. A year of reflog is essentially free and lifesaving.

KNOWLEDGE CHECK

You accidentally run `git reset --hard origin/main` on your feature branch, thinking you were on main. Four of your local commits (not yet pushed) vanish. Your colleague says `git pull` will bring them back. Are they right, and what is the correct recovery?