We have been putting this lesson off for six modules. In 03-02 we said that the reflog explains how Git remembers where HEAD has been. In 03-06 we promised that deleting a branch with -D is almost never irreversible, and pointed here. In 05-01 we said a disastrous rebase can be undone, and pointed here. In 05-04, that an accidental stash drop is recoverable. In 09-02, that the commits a reset --hard leaves behind are still there. And in 09-03, that origin/main@{1} keeps where the server was before the rewrite.

All those promises rest on the same piece, and it is time to develop it in full.

The reflog is probably the Git feature with the best ratio between what it saves and how little it is known. Plenty of people have been using Git for years without ever having run it, and it is exactly the command that turns "I have lost three days' work" into "it took me two minutes to get it back".

Let us start with the most important thing: if you have arrived here in a panic, you have lost nothing, provided that what you are looking for made it as far as being a commit. Read on calmly.

Contents

  1. What the reflog is and where it lives
  2. Local and temporary: the two limits you need to know
  3. Reading git reflog
  4. HEAD@{n} versus HEAD@{time}
  5. The universal recovery pattern
  6. Recipe book: reset --hard, deleted branch, rebase, merge, --amend
  7. When the reflog is not enough: git fsck --lost-found
  8. Recovering a deleted stash
  9. The real limits: what cannot be recovered
  10. Hygiene: do not destroy your own safety net

  1. What the reflog is and where it lives

The reflog is the local record of every movement of every reference.

Every time a reference — HEAD, a branch, a remote tracking branch — changes value, Git writes a line in a text file noting where it came from, where it is going, who did it and why.

Look at it directly:

cat .git/logs/HEAD | tail -3
4f8a2e6c9b1d5e3a b52c9d1e4a7f3c8b Ana Ferrer <ana.ferrer@example.com> 1785312041 +0200	commit: GT-241 filter groundwork
b52c9d1e4a7f3c8b 7d3a8f4a2c6e9b1d Ana Ferrer <ana.ferrer@example.com> 1785312388 +0200	commit: GT-241 calculate the counter
7d3a8f4a2c6e9b1d 4f8a2e6c9b1d5e3a Ana Ferrer <ana.ferrer@example.com> 1785312551 +0200	reset: moving to HEAD~2

Each line has: previous hash, new hash, identity, timestamp, and a description of the operation. It is plain text, with no magic.

The directory structure:

find .git/logs -type f | head
.git/logs/HEAD
.git/logs/refs/heads/main
.git/logs/refs/heads/GT-241
.git/logs/refs/remotes/origin/main
.git/logs/refs/stash
File Records the movements of
.git/logs/HEAD HEAD: everything you have done, including branch switches
.git/logs/refs/heads/<branch> Only that branch
.git/logs/refs/remotes/origin/<branch> The tracking branch: what each fetch brought in (09-03)
.git/logs/refs/stash The stash stack (05-04)

And here is the key to the whole module. Recall the idea from lesson 09-01:

A commit survives as long as something reaches it. Reflog entries count as references.

That is why git gc does not remove a commit that appears in the reflog, even if no branch points at it. The reflog is not just a record: it is what keeps orphaned objects alive. That is the technical reason recovery works.

flowchart LR
    subgraph refs["References that keep a commit alive"]
        R1["Branches<br/>refs/heads/*"]
        R2["Tags<br/>refs/tags/*"]
        R3["Remote branches<br/>refs/remotes/*"]
        R4["Stash<br/>refs/stash"]
        R5["REFLOG<br/>.git/logs/*"]
    end
    C["commit b52c9d1"]
    R1 -.-> C
    R5 ==>|"even if the others<br/>disappear"| C

  1. Local and temporary: the two limits you need to know

Before the recipe book, the two limitations. Not understanding them is what leads people to trust the reflog when they should not.

It is LOCAL

The reflog does not travel. It is not published with push, it is not downloaded with fetch, and a git clone does not bring it.

git clone git.example.com:team/task-manager.git
cd task-manager
git reflog
2f8c6e1 HEAD@{0}: clone: from git.example.com:team/task-manager.git

A single entry. All the movement history Ana had on her machine stays on her machine.

Three practical consequences:

  1. Ana's reflog cannot rescue Bruno's disaster. Everybody has their own.
  2. The reflog is not a backup. If the disk dies, it dies with it. The real backups are git bundle and the team's own clones (lesson 09-05).
  3. A freshly made clone has no safety net. The first unfortunate reset --hard in a new clone really can leave you with nothing to recover, because there are no earlier entries.

And a positive consequence, the one from section 9 of lesson 09-03: if a colleague forces and destroys a branch on the server, your reflog for origin/branch keeps where it was, and with it you can restore it.

It is TEMPORARY

Entries expire. Two parameters govern this:

git config --get gc.reflogExpire            # default: 90 days
git config --get gc.reflogExpireUnreachable # default: 30 days
Parameter Applies to Default value
gc.reflogExpire Entries whose commit is still reachable 90 days
gc.reflogExpireUnreachable Entries whose commit is no longer reachable from any branch 30 days

The second is the one that matters: genuinely orphaned commits have 30 days. After that, a git gc purges them for good.

The expiry does not happen on its own: it is carried out by git gc (lesson 08-06), which Git launches automatically when loose objects pile up. In practice, you have weeks, not months, and certainly not years.

You can extend the deadline:

# A year for everything, in your working repositories
git config --global gc.reflogExpire "1 year"
git config --global gc.reflogExpireUnreachable "1 year"

# Or per branch, with a pattern
git config gc.refs/heads/main.reflogExpire never

It costs a few megabytes and buys a great deal of peace of mind.

And the opposite, which you should never run unless you know exactly what you are doing (lesson 08-06):

# DESTROYS the safety net of this entire module
git reflog expire --expire=now --all
git gc --prune=now

  1. Reading git reflog

git reflog
7d3a8f4 HEAD@{0}: reset: moving to HEAD~2
9c4e7b2 HEAD@{1}: commit: GT-241 indicator styles
3e7f1a8 HEAD@{2}: commit: GT-241 calculate the counter
8a1f6c3 HEAD@{3}: rebase (finish): returning to refs/heads/GT-241
8a1f6c3 HEAD@{4}: rebase (pick): GT-241 filter groundwork
2f8c6e1 HEAD@{5}: rebase (start): checkout origin/main
b52c9d1 HEAD@{6}: checkout: moving from main to GT-241
7d3a8f4 HEAD@{7}: pull: Fast-forward
4f8a2e6 HEAD@{8}: commit: chore: eslint configuration

How to read it:

  • HEAD@{0} is always the last thing that happened. The order runs from the present into the past.
  • The hash on each line is where HEAD ended up AFTER that operation. This detail is crucial and produces the most frequent mistake: if you want the state prior to the HEAD@{0} operation, you need the hash from HEAD@{1}.
  • The description says which command caused it.

The vocabulary of the descriptions:

Description What produced it
commit: git commit
commit (amend): git commit --amend
commit (initial): The repository's first commit
checkout: moving from X to Y git switch or git checkout
reset: moving to X git reset
pull: / merge X: An integration
rebase (start/pick/finish): The phases of a rebase
cherry-pick: git cherry-pick
revert: git revert
branch: Created from X git branch or git switch -c
clone: from X The initial clone
fetch: forced-update Somebody rewrote the remote branch (09-03)

The reflog of one particular branch

git reflog show GT-241
9c4e7b2 GT-241@{0}: commit: GT-241 indicator styles
3e7f1a8 GT-241@{1}: commit: GT-241 calculate the counter
8a1f6c3 GT-241@{2}: rebase (finish): refs/heads/GT-241 onto 2f8c6e1
b52c9d1 GT-241@{3}: branch: Created from HEAD

Far cleaner than the HEAD one when you know which branch you are interested in, because it does not include the branch switches.

Useful formats

# With relative dates: essential for getting your bearings
git reflog --date=relative -10

# With the exact date and time
git reflog --date=iso -10

# With the commit message as well as the operation
git reflog --format='%h  %gd  %gs  |  %s' -10

# Only one branch's, with a graph
git log --walk-reflogs --oneline GT-241
7d3a8f4 HEAD@{3 minutes ago}: reset: moving to HEAD~2
9c4e7b2 HEAD@{41 minutes ago}: commit: GT-241 indicator styles
3e7f1a8 HEAD@{2 hours ago}: commit: GT-241 calculate the counter

--date=relative is the one that helps most in a recovery: "I had it just before lunch" translates directly into an entry.

And git log -g (or --walk-reflogs), which walks the reflog showing each entry as a complete commit:

git log -g --oneline --stat -5

Useful when you do not recognise a commit by its message and need to see which files it touched.

  1. HEAD@{n} versus HEAD@{time}

Two syntaxes that look alike and mean different things.

Syntax What it is Example
HEAD@{2} Entry number 2 of the reflog (counting from 0) Two operations back
HEAD@{2.hours.ago} Where HEAD was two hours ago By the clock
HEAD~2 Two commits back in the graph Walks parents, not the reflog
HEAD^2 The second parent (merges only) The other branch of the merge
main@{5} Fifth entry in main's reflog
main@{yesterday} Where main was yesterday
origin/main@{1} Where the remote branch was before the last fetch 09-03
@{-1} The previous branch you were on What git switch - uses

The critical distinction, and it deserves an example:

git log --oneline HEAD~3 -1        # three commits back in the history
git log --oneline HEAD@{3} -1      # where HEAD was three operations ago

HEAD~3 walks the graph: it follows the chain of parents. HEAD@{3} walks your personal history: it may be on another branch, on an orphaned commit, anywhere you have been.

When you have done a reset, HEAD~1 takes you to the current commit's parent (which is no longer what you were after) and HEAD@{1} takes you to where you were before the reset (which is).

The time forms accept very natural expressions:

git log --oneline main@{yesterday} -3
git log --oneline main@{"1 week ago"} -3
git log --oneline main@{2026-07-28.09:00:00} -3
git diff main@{1.day.ago} main

And the warning Git gives you when you go beyond the deadline:

warning: log for 'main' only goes back to Tue, 14 Jul 2026 10:12:04 +0200

It is not an error: it is telling you that it has used the oldest entry available. If the date you asked for is earlier than that, the result is not what you think it is.

  1. The universal recovery pattern

All the recipes in the next section are the same procedure. Learn it once.

flowchart TD
    A["1. git reflog (or git reflog show branch)"] --> B["2. Locate the entry:<br/>use the description and --date=relative"]
    B --> C["3. VERIFY the candidate:<br/>git show hash --stat<br/>git log --oneline hash -5"]
    C --> D{"Is it what I was after?"}
    D -->|No| B
    D -->|Yes| E["4. git branch rescue hash"]
    E --> F["5. Check on the new branch"]
    F --> G["6. Integrate: merge, cherry-pick,<br/>or reset --hard if you are sure"]

Step 4 is the one to internalise, and it is the difference between a calm recovery and a second scare:

# GOOD: creates a new pointer. It moves nothing you already have
git branch rescue 9c4e7b2

# WORSE: moves your current branch, and if you get the hash wrong you lose sight of where you are now
git reset --hard 9c4e7b2

git branch is purely additive. If the hash was the wrong one, you delete the branch and try another. Nothing has moved. With reset --hard each failed attempt adds another layer of confusion.

And step 3, verifying, prevents the mistake of recovering the wrong commit:

git show 9c4e7b2 --stat          # which files did it touch?
git log --oneline 9c4e7b2 -5     # what history does it have behind it?
git diff HEAD 9c4e7b2 --stat     # how does it differ from what I have now?

And to inspect without committing to anything:

git switch --detach 9c4e7b2      # look at the project in that state
# ...open files, run the application...
git switch -                     # go back

  1. Recipe book: reset --hard, deleted branch, rebase, merge, --amend

6.1. Undoing a git reset --hard

The situation. Ana wanted to remove one commit and typed HEAD~3.

You have lost nothing (apart from anything uncommitted, lesson 09-02).

git reflog -5
4f8a2e6 HEAD@{0}: reset: moving to HEAD~3
9c4e7b2 HEAD@{1}: commit: GT-241 indicator styles
3e7f1a8 HEAD@{2}: commit: GT-241 calculate the counter
8a1f6c3 HEAD@{3}: commit: GT-241 filter groundwork
2f8c6e1 HEAD@{4}: checkout: moving from main to GT-241

HEAD@{1} (9c4e7b2) is the tip that existed just before the reset.

git show 9c4e7b2 --stat          # verify
git branch rescue-GT-241 9c4e7b2
git log --oneline rescue-GT-241 -4
9c4e7b2 (rescue-GT-241) GT-241 indicator styles
3e7f1a8 GT-241 calculate the counter
8a1f6c3 GT-241 filter groundwork
2f8c6e1 chore: eslint configuration

Recovered. Now, if you want GT-241 to be that again:

git switch GT-241
git reset --hard rescue-GT-241
git branch -d rescue-GT-241

The shortcut, if you have just done it and have done nothing else since (lesson 09-02):

git reset --hard ORIG_HEAD

Or directly with the reflog syntax:

git reset --hard HEAD@{1}

6.2. Recovering a branch deleted with -D

This closes the promise from lesson 03-06.

git branch -D GT-247
Deleted branch GT-247 (was b52c9d1).

First gift: Git prints the hash when it deletes. If you still have it in the terminal buffer, recovery is immediate:

git branch GT-247 b52c9d1

If you have closed the terminal, there are two routes.

A. The HEAD reflog (works if you were on that branch):

git reflog --date=relative | grep -i "GT-247"
b52c9d1 HEAD@{3 hours ago}: checkout: moving from GT-247 to main
b52c9d1 HEAD@{3 hours ago}: commit: GT-247 validate the form

B. Searching by message among all the orphaned commits (always works):

git log -g --oneline --all | grep -i "GT-247"

Or directly against the object database:

git fsck --lost-found --no-progress | grep commit

And the important detail that gets forgotten: a branch's own reflog is deleted along with it.

ls .git/logs/refs/heads/
main

.git/logs/refs/heads/GT-247 no longer exists. That is why you have to search in the HEAD reflog, which does survive.

# The recovery
git branch GT-247 b52c9d1
git log --oneline GT-247 -3

-D is almost never irreversible. The commits are still in .git/objects, reachable from the HEAD reflog, for at least 30 days. The promise from lesson 03-06 is duly kept.

And the exception, to be honest about it: if the branch was created, committed to and deleted without HEAD ever passing through it (for example, with git branch X <hash> and then git branch -D X), the HEAD reflog does not record it. In that case, the git fsck from section 7.

6.3. Rescuing a rebase that went wrong

This closes the promise from lesson 05-01.

Bruno did a rebase -i of ten commits, squashed the ones he should not have and finished with --continue.

git reflog -12
4b8e2c9 HEAD@{0}: rebase (finish): returning to refs/heads/GT-241
4b8e2c9 HEAD@{1}: rebase (squash): GT-241 the complete filter
8f2d6a1 HEAD@{2}: rebase (squash): GT-241 filter groundwork
6e2b9c7 HEAD@{3}: rebase (pick): GT-241 filter groundwork
2f8c6e1 HEAD@{4}: rebase (start): checkout origin/main
9c4e7b2 HEAD@{5}: checkout: moving from main to GT-241

The key entry is rebase (start): the one before it is the exact state prior to the rebase. Here, HEAD@{5}9c4e7b2.

git branch before-the-rebase 9c4e7b2
git log --oneline before-the-rebase -10

The ten original commits, intact.

A shortcut hardly anybody knows about: during a rebase, Git saves the initial position in ORIG_HEAD, and also in a special reference:

git rev-parse ORIG_HEAD
cat .git/rebase-merge/orig-head 2>/dev/null    # only while the rebase is under way

And if the rebase has not finished yet, the way out is far simpler:

git rebase --abort

It returns everything exactly to the previous state. Use it whenever you can: it is cleaner than recovering afterwards.

To compare the result with the original and decide what to do, git range-diff (lesson 07-02):

git range-diff before-the-rebase...GT-241

6.4. Undoing a merge you should not have done

git merge feature/experimental
Merge made by the 'ort' strategy.
 14 files changed, 892 insertions(+), 31 deletions(-)

If you have just done it and there is nothing on top:

git reset --hard ORIG_HEAD

merge always writes ORIG_HEAD before merging. It is the canonical way out.

If you have already done more things on top:

git reflog -6
c7e2a91 HEAD@{0}: commit: GT-241 adjust the counter
2b9e6c1 HEAD@{1}: merge feature/experimental: Merge made by the 'ort' strategy.
7d3a8f4 HEAD@{2}: commit: GT-241 indicator styles

HEAD@{2} is the state prior to the merge. But careful: if you go back there, you also lose the commit c7e2a91 you made afterwards. The right thing to do is to rescue it separately:

git branch before-the-merge 7d3a8f4
git switch before-the-merge
git cherry-pick c7e2a91          # bring across only the good commit (lesson 05-03)

And if the merge is already published, forget the reflog: the answer is git revert -m 1 (lesson 05-06).

A frequent and disconcerting case: git merge --abort does not work because the merge finished successfully; it was merely a bad idea. --abort is for while there are unresolved conflicts, not afterwards.

6.5. Recovering a commit overwritten by --amend

Carla ran git commit --amend and realised she has lost part of the original message — or worse, that the previous commit had changes the new one does not.

You have lost nothing. --amend does not modify the commit: it creates a new one and moves the branch (lesson 09-01). The original is still in the database.

git reflog -3
3f8a1d6 HEAD@{0}: commit (amend): GT-241 calculate the counter over the complete list
8f4c2a9 HEAD@{1}: commit: GT-241 calculate the counter
b52c9d1 HEAD@{2}: commit: GT-241 filter groundwork

HEAD@{1} (8f4c2a9) is the original commit, before the --amend.

# See the complete original message
git show 8f4c2a9 --stat
git log -1 --format=%B 8f4c2a9

# Compare the two
git diff 8f4c2a9 3f8a1d6

And the ways of recovering, depending on what you need:

# A. Go all the way back to the original
git reset --hard 8f4c2a9

# B. Recover only the message
git commit --amend -m "$(git log -1 --format=%B 8f4c2a9)"

# C. Recover one particular file that was lost in the amend
git restore --source=8f4c2a9 --staged --worktree app.js

# D. See exactly what was lost
git diff 3f8a1d6 8f4c2a9

6.6. Recovering after a checkout to another branch with commits in detached HEAD

Picking up section 5.7 of lesson 09-01: you made commits in detached HEAD and moved away.

git reflog -5
7d3a8f4 HEAD@{0}: checkout: moving from 9c4e7b2 to main
9c4e7b2 HEAD@{1}: commit: test of the alternative algorithm
3e7f1a8 HEAD@{2}: commit: sketch of the date filter
4f8a2e6 HEAD@{3}: checkout: moving from main to 4f8a2e6

The line checkout: moving from 9c4e7b2 to main gives you the hash of where you came from.

git branch date-experiment 9c4e7b2

The quick reference table

Disaster Where to look Rescue command
One reset --hard too many git reflog -5, the entry before the reset: git branch rescue HEAD@{1}
Branch deleted with -D The hash -D printed, or git reflog | grep <branch> git branch <branch> <hash>
Disastrous rebase The entry before rebase (start) git branch before 9c4e7b2 / git rebase --abort if still under way
Unwanted merge ORIG_HEAD, or the entry before merge git reset --hard ORIG_HEAD
--amend that swept something away The commit: entry before commit (amend): git restore --source=<hash> <file>
Commits in detached HEAD checkout: moving from <hash> to <branch> git branch <name> <hash>
Remote branch rewritten by somebody else git reflog show origin/<branch> git branch old origin/<branch>@{1}
Deleted stash git fsck --unreachable | grep commit Section 8
Nothing shows up in the reflog git fsck --lost-found Section 7

  1. When the reflog is not enough: git fsck --lost-found

The reflog records the movements of references. There are objects that reach the database without any reference moving, and for those you need the other tool.

git fsck --lost-found --no-progress
Checking object directories: 100% (256/256), done.
dangling commit 9c4e7b2e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b
dangling commit 3e7f1a8b6d2e9a5f1c7b3d8e4a6f2c9b7d3a8f4a
dangling blob 6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b
dangling tree 8a1f6c3d5e2b9c7f4a6d1e8b3c5f7a9d2e4b6c8a

And it also creates a directory of links:

ls .git/lost-found/commit/
ls .git/lost-found/other/

What each thing is

Type What it is Why it appears
dangling commit A commit no reference reaches reset, deleted branch, rebase, --amend, deleted stash
dangling blob A file's content with no tree referencing it A git add whose commit was never made
dangling tree A directory with no commit using it A half-finished commit, or a stash's tree
unreachable X The same, but taking the reflog into account With --unreachable you also see the ones from the reflog

dangling is normal and harmless. It is not a symptom of corruption. It is what you would expect in any active repository. The distinction between that and the real errors (missing, broken link) is lesson 09-05.

How to inspect the candidates

An fsck can return dozens of lines. The work is identifying which one is yours.

# The dangling commits, with date, author and message, sorted by date
git fsck --lost-found --no-progress 2>/dev/null \
  | awk '/dangling commit/ {print $3}' \
  | xargs -I{} git log -1 --format='%ci  %h  %an  %s' {} \
  | sort -r
2026-07-30 18:22:41 +0200  9c4e7b2  Ana Ferrer  GT-241 indicator styles
2026-07-30 17:51:03 +0200  3e7f1a8  Ana Ferrer  GT-241 calculate the counter
2026-07-28 11:04:17 +0200  8a1f6c3  Bruno Salas  WIP on GT-238

That turns a list of hashes into usable information. Save it as an alias:

git config --global alias.lost '!git fsck --lost-found --no-progress 2>/dev/null | awk "/dangling commit/ {print \$3}" | xargs -I{} git log -1 --format="%ci  %h  %an  %s" {} | sort -r'

Individual inspection, with the plumbing tools from lesson 01-04:

# The content of the commit object as it stands
git cat-file -p 9c4e7b2
tree 8a1f6c3d5e2b9c7f4a6d1e8b3c5f7a9d2e4b6c8a
parent 2f8c6e1a4b7d9c3e5f2a8b6d1c9e4f7a3b5d2c8e
author Ana Ferrer <ana.ferrer@example.com> 1785312041 +0200
committer Ana Ferrer <ana.ferrer@example.com> 1785312041 +0200

GT-241 styles for the pending indicator
# The complete diff
git show 9c4e7b2

# Only the files
git show --stat 9c4e7b2

# What a dangling blob contained
git cat-file -p 6f2b9d4 | head -20

# What type any given object is
git cat-file -t 8a1f6c3

Dangling blobs: the add that was never committed

This is the case that saves more work than people expect. If you staged a file with git add and then lost it, the content is in the database (lesson 09-01, table in section 1).

# Search the dangling blobs for the one containing a string you remember
for b in $(git fsck --lost-found --no-progress 2>/dev/null | awk '/dangling blob/ {print $3}'); do
  if git cat-file -p "$b" 2>/dev/null | grep -q "calculatePending"; then
    echo "=== $b ==="
    git cat-file -p "$b" | head -5
  fi
done
=== 6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b ===
function calculatePending(tasks) {
  return tasks.filter(t => !t.completed).length;
}
# Recover it
git cat-file -p 6f2b9d4 > app.js

Searching for a string you remember is far more effective than reviewing the blobs one by one.

  1. Recovering a deleted stash

This closes the promise from lesson 05-04.

git stash drop
Dropped refs/stash@{0} (b52c9d1e4a7f3c8b6d2e9a5f1c7b3d8e4a6f2c9b)

First gift, once again: drop prints the hash. With it:

git stash apply b52c9d1

And if it was git stash clear, which wipes the whole stack without printing anything, there are two routes.

A. The stash reflog, if it still exists:

git reflog show stash
cat .git/logs/refs/stash

stash clear deletes the reference, but the log file sometimes survives until the next gc. It is worth looking at that first.

B. Searching for the stash commits among the unreachable ones:

git fsck --unreachable --no-progress | grep commit | awk '{print $3}' \
  | xargs -I{} git log -1 --format='%h %ci %s' {} \
  | grep -i "WIP on\|On .*:"
b52c9d1 2026-07-30 16:12:44 +0200 WIP on GT-241: 7d3a8f4 GT-241 filter groundwork
8f4c2a9 2026-07-29 09:31:20 +0200 On main: counter tests

Stashes are recognisable by their message: WIP on <branch>: (the automatic ones) or On <branch>: <message> (the ones you created with -m).

Internally a stash is a merge commit with two or three parents, and it is worth seeing because it explains how it works:

git show --format='%h  parents: %p  %s' -s b52c9d1
b52c9d1  parents: 7d3a8f4 4f8a2e6 9c4e7b2  WIP on GT-241: 7d3a8f4 GT-241 filter groundwork
Parent What it contains
1st (7d3a8f4) The HEAD at the moment of the stash
2nd (4f8a2e6) The index (what was staged)
3rd (9c4e7b2) The untracked files, if you used -u

And the recovery:

# Apply it directly
git stash apply b52c9d1

# Or put it back on the stack to handle it as normal
git stash store -m "recovered: GT-241 half done" b52c9d1
git stash list
stash@{0}: recovered: GT-241 half done
# See what it contained without applying it
git show b52c9d1                  # the working directory changes
git show b52c9d1^2                # what was staged
git show b52c9d1^3                # the untracked files (if any)

  1. The real limits: what cannot be recovered

Time to be honest. There are two boundaries no technique in this lesson crosses.

Boundary 1: what never entered the database

We saw it in lesson 09-01, and it is the fundamental limitation:

Situation Recoverable?
A commit, even if the branch was deleted Yes
A git add without committing Yes: there is a blob
A stash, even a deleted one Yes: they are commits
A file modified and destroyed with git restore No
A file destroyed by reset --hard with no prior add No
An untracked file deleted by git clean No

When you are in one of the last three cases, Git can no longer help you. What is left is outside Git:

1. Your editor's local history. It is the route that works most often and the one that is tried least.

  • VS Code: command palette → "Local History: Find Entry to Restore". It keeps versions of every file you have edited, independently of Git.
  • IntelliJ / WebStorm / Eclipse: the file's context menu → Local History → Show History. It keeps weeks of it.
  • Vim: if you have set undofile, the undo history persists between sessions (:earlier 1h).

2. System backups: Time Machine on macOS, Btrfs/ZFS snapshots, previous versions on Windows, cloud copies.

3. Looking in /tmp and in the editor's swap files (Vim's .swp, other editors' ~).

Boundary 2: what has already been through gc

If git gc --prune=now was run after the commit became orphaned, the object has physically disappeared. And if git reflog expire --expire=now --all was run too, there is not even a record.

Check whether it is still there before giving up:

git cat-file -t 9c4e7b2
fatal: Not a valid object name 9c4e7b2

That message means the object no longer exists. But before writing it off, look outside your machine:

# Does the server have it?
git fetch origin '+refs/*:refs/remotes/backup/*'
git log --all --oneline | grep -i "GT-241"

# Does a colleague have it? (their clone is an almost complete copy)
git remote add bruno /path/or/url/to/brunos/clone
git fetch bruno
git log --oneline bruno/GT-241

That is the safety net of the distributed model, and it is the central theme of lesson 09-05.

And the prevention, which is what really solves this

# 1. Commit early and often. A "wip" every half hour changes everything.
git commit -am "wip"

# 2. Before any risky operation, a marker. It costs two seconds.
git branch backup-$(date +%H%M)

# 3. Before cleaning up, a stash with untracked files included
git stash push -u -m "just in case"

# 4. Extend the reflog in your working repositories
git config --global gc.reflogExpire "1 year"
git config --global gc.reflogExpireUnreachable "1 year"

# 5. Publish your branches, even half-finished ones
git push -u origin GT-241

The fifth is the most underrated. A published branch is on two machines. No local accident can beat that.

  1. Hygiene: do not destroy your own safety net

The lesson closes with what not to do.

1. git reflog expire --expire=now --all. It empties the record. Everything orphaned is left unprotected.

2. git gc --prune=now. It immediately removes unreachable objects. It has its legitimate use (after a filter-repo from lesson 08-05, where deleting is precisely the aim), and none on a normal day.

3. Trusting the reflog as a backup. It is local, temporary and dies with the disk. It is not a backup.

4. Recovering with reset --hard instead of git branch. Each failed attempt adds confusion. branch moves nothing.

5. Working for months without publishing a branch. A new clone has an empty reflog; an unpublished branch exists on a single disk.

And a thirty-second health check, to know whether your safety net is where you think it is:

echo "Entries in the HEAD reflog: $(git reflog | wc -l)"
echo "Oldest entry: $(git reflog --date=iso | tail -1)"
echo "reflogExpire: $(git config --get gc.reflogExpire || echo '90 days (default)')"
echo "reflogExpireUnreachable: $(git config --get gc.reflogExpireUnreachable || echo '30 days (default)')"
echo "Dangling objects: $(git fsck --lost-found --no-progress 2>/dev/null | grep -c dangling)"
echo "Unpublished branches: $(git for-each-ref --format='%(refname:short) %(upstream)' refs/heads | awk '$2==""{print $1}' | tr '\n' ' ')"

Common Mistakes and Tips

Mistake 1: not trying git reflog. It is the first command for any "I have lost commits", and most people never run it.

Mistake 2: confusing HEAD@{1} with HEAD~1. The first is where you were one operation ago; the second is the parent commit. After a reset, they are completely different things.

Mistake 3: taking the hash from the wrong entry. A line's hash is where HEAD ended up after that operation. For the state prior to HEAD@{0}, you need HEAD@{1}.

Mistake 4: recovering with reset --hard instead of git branch. If the hash was the wrong one, with branch you delete the branch and try another; with reset each attempt makes the state more complicated.

Mistake 5: believing that the reflog travels in a clone. It does not. The new clone has a single entry and no safety net.

Mistake 6: trusting the reflog forever. 90 days for the reachable, 30 for the orphaned, and gc enforces them. In practice, weeks.

Mistake 7: being alarmed by git fsck's dangling entries. They are normal in any active repository. What is serious is missing and broken link (lesson 09-05).

Mistake 8: writing off a stash. stash drop prints the hash, stash clear leaves the commits orphaned, and both are recovered with git fsck --unreachable looking for WIP on.

Mistake 9: giving up without looking outside Git. The editor's local history recovers what Git cannot, and hardly anybody tries it.

Tip 1: the pattern is always the same. Reflog → verify with git showgit branch rescue <hash>.

Tip 2: git reflog --date=relative. "I had it before lunch" translates directly into an entry.

Tip 3: define the git lost alias. On the day you need it you will not remember the fsck and awk pipeline.

Tip 4: git rebase --abort while you still can. Far cleaner than recovering afterwards.

Tip 5: extend the reflog to a year. Two lines of global configuration and a few megabytes.

Tip 6: publish your branches. A branch on two machines is immune to the accidents of one.

Exercises

Exercise 1: the complete recipe book

On a test repository with app.js, styles.css and index.html:

  1. Create six commits on main and a branch GT-241 with three commits of its own.
  2. Provoke four disasters in a row, without recovering in between: a. git reset --hard HEAD~2 on GT-241. b. git switch main && git branch -D GT-241. c. A git commit --amend on main that deletes part of the message. d. A git merge of an experimental branch you did not want.
  3. Run git reflog --date=relative and note down, for each disaster, which entry you would use.
  4. Recover all four things using always git branch, never reset.
  5. Verify each recovery with git show --stat and git diff.

Exercise 2: the limits of recovery

  1. In a new repository, create three situations: a commit, a file staged with git add, and a file modified on disk only.
  2. Run git reset --hard HEAD~1.
  3. Recover the commit with the reflog and the staged file with git fsck --lost-found + git cat-file -p.
  4. Document why the third is unrecoverable, checking it with git fsck and git reflog.
  5. Create a stash with -u, delete it with git stash clear and recover it with git fsck --unreachable.
  6. Check with git show --format='%p' -s that the stash has three parents and inspect each of them.

Exercise 3: expiry and hygiene

  1. Create a repository with ten commits and produce five orphaned commits with reset.
  2. Check that they appear in git fsck --lost-found.
  3. Run git reflog expire --expire=now --all and look at git reflog and git fsck again. Explain the difference between the two outputs.
  4. Run git gc --prune=now and check with git cat-file -t <hash> that the objects really have disappeared.
  5. Repeat the experiment having first configured gc.reflogExpireUnreachable "1 year" and explain what changes.
  6. Write the health check from section 10 into a script and interpret its output on a real repository of your own.

Solutions

Solution 1:

rm -rf /tmp/p9-04 && mkdir /tmp/p9-04 && cd /tmp/p9-04 && git init -q -b main
git config user.name "Ana Ferrer"; git config user.email "ana.ferrer@example.com"
for i in 1 2 3 4 5 6; do echo "line $i" >> app.js; git add .; git commit -q -m "chore: commit $i"; done

git switch -q -c GT-241
echo "// filter" > filter.js && git add . && git commit -q -m "GT-241 filter groundwork"
echo "// counter" >> filter.js && git commit -q -am "GT-241 calculate the counter"
echo ".pending{}" > styles.css && git add . && git commit -q -m "GT-241 indicator styles"
git rev-parse --short HEAD
9c4e7b2
# 2a. reset --hard
git reset -q --hard HEAD~2

# 2b. delete the branch
git switch -q main
git branch -D GT-241
Deleted branch GT-241 (was 8a1f6c3).
# 2c. destructive amend
git commit -q --amend -m "chore"

# 2d. unwanted merge
git switch -q -c experimental HEAD~1
echo "experiment" > experiment.txt && git add . && git commit -q -m "experiment that is no good"
git switch -q main
git merge -q --no-ff experimental -m "Merge experimental"
git log --oneline -3
c7e2a91 (HEAD -> main) Merge experimental
2b9e6c1 (experimental) experiment that is no good
3f8a1d6 chore
# 3. The complete reflog
git reflog --date=relative -12
c7e2a91 HEAD@{4 seconds ago}: merge experimental: Merge made by the 'ort' strategy.
3f8a1d6 HEAD@{12 seconds ago}: checkout: moving from experimental to main
2b9e6c1 HEAD@{18 seconds ago}: commit: experiment that is no good
7d3a8f4 HEAD@{25 seconds ago}: checkout: moving from main to experimental
3f8a1d6 HEAD@{33 seconds ago}: commit (amend): chore
8f4c2a9 HEAD@{40 seconds ago}: checkout: moving from GT-241 to main
6e2b9c7 HEAD@{48 seconds ago}: reset: moving to HEAD~2
9c4e7b2 HEAD@{55 seconds ago}: commit: GT-241 indicator styles
Disaster Entry to use Hash
a. reset --hard The one before the reset: 9c4e7b2
b. branch -D The printed hash, or the checkout: moving from GT-241 8a1f6c3 (what -D printed)
c. --amend The commit: before commit (amend): 8f4c2a9
d. merge The one before the merge, or ORIG_HEAD 3f8a1d6

Note the nuance in (b): after the reset --hard, GT-241 pointed at 6e2b9c7, and that is what -D deleted (8a1f6c3 in the message's example). But what Ana wants is the state prior to the reset, 9c4e7b2. The reflog keeps both.

# 4. Recover, always with branch
git branch rescue-GT-241-complete 9c4e7b2
git branch rescue-amend 8f4c2a9
git branch rescue-before-merge 3f8a1d6

git branch
  experimental
* main
  rescue-GT-241-complete
  rescue-amend
  rescue-before-merge

None of those operations has moved main. Everything that was there is still where it was, and now there are also three points of access to what has been recovered.

# 5. Verify
git log --oneline rescue-GT-241-complete -4
9c4e7b2 (rescue-GT-241-complete) GT-241 indicator styles
8a1f6c3 GT-241 calculate the counter
6e2b9c7 GT-241 filter groundwork
7d3a8f4 chore: commit 6

The three commits, including the two the reset --hard had left behind.

git log -1 --format=%B rescue-amend
chore: commit 6

The original message, before the --amend reduced it to chore.

git show --stat rescue-before-merge -1
git diff rescue-before-merge main --stat
 experiment.txt | 1 +
 1 file changed, 1 insertion(+)

The only thing the unwanted merge contributed. To undo it:

git reset --hard rescue-before-merge
git log --oneline -1
3f8a1d6 (HEAD -> main, rescue-before-merge) chore

And to restore GT-241 properly:

git branch -m rescue-GT-241-complete GT-241
git branch -d rescue-amend rescue-before-merge

Solution 2:

rm -rf /tmp/p9-04b && mkdir /tmp/p9-04b && cd /tmp/p9-04b && git init -q -b main
echo "base" > app.js && git add . && git commit -q -m "c1"

# 1. The three situations
echo "COMMIT" >> app.js && git commit -q -am "c2: committed"
echo "STAGED" > styles.css && git add styles.css
echo "ON DISK ONLY" >> app.js

# 2. The disaster
git reset -q --hard HEAD~1
# 3. Recover the commit
git reflog -3
3f8a1d6 HEAD@{0}: reset: moving to HEAD~1
8f4c2a9 HEAD@{1}: commit: c2: committed
3f8a1d6 HEAD@{2}: commit (initial): c1
git branch rescue-commit 8f4c2a9
git show --stat rescue-commit
 app.js | 1 +
# The staged file
git fsck --lost-found --no-progress
dangling blob 6f2b9d4a8c1e5f3b7d9a2c6e4f8b1d3a5c7e9f2b
git cat-file -p 6f2b9d4
git cat-file -p 6f2b9d4 > styles.css
STAGED
# 4. The third one
git fsck --lost-found --no-progress | wc -l
git reflog | grep -c "ON DISK ONLY"
grep -rl "ON DISK ONLY" .git/ 2>/dev/null | wc -l
1
0
0

A single dangling object (the one from the add), no reflog entry, and not one byte anywhere in .git. The line ON DISK ONLY never became an object: its SHA was never calculated, it was never compressed, it was never written. Git cannot give back what it never received. The only routes would be the editor's local history or a filesystem copy.

# 5. The stash
echo "half-finished work" >> app.js
echo "new file" > draft.txt
git stash push -u -q -m "GT-241 half done"
git stash list
git stash clear
git stash list
stash@{0}: On main: GT-241 half done
(no output)
git fsck --unreachable --no-progress | grep commit | awk '{print $3}' \
  | xargs -I{} git log -1 --format='%h %ci %s' {} | grep -i "on main"
b52c9d1 2026-07-31 12:14:02 +0200 On main: GT-241 half done
git stash store -m "recovered" b52c9d1
git stash list
git stash pop
git status --short
stash@{0}: recovered
 M app.js
?? draft.txt

Recovered in full, including the untracked file.

# 6. The stash's internal structure
git show --format='%h  parents: %p' -s b52c9d1
b52c9d1  parents: 3f8a1d6 8a1f6c3 9c4e7b2
git cat-file -t b52c9d1
git show --stat b52c9d1^2      # the index
git show --stat b52c9d1^3      # the untracked ones
commit
(empty: there was nothing staged)
 draft.txt | 1 +

Three parents exactly as section 8 said: HEAD, the index and the untracked files. A stash is a commit, and that is why it survives stash clear.

Solution 3:

rm -rf /tmp/p9-04c && mkdir /tmp/p9-04c && cd /tmp/p9-04c && git init -q -b main
for i in $(seq 1 10); do echo "line $i" >> app.js; git add .; git commit -q -m "commit $i"; done
git rev-parse --short HEAD
git reset -q --hard HEAD~5
9c4e7b2
# 2. The orphans
git fsck --lost-found --no-progress | grep -c "dangling commit"
git reflog | head -3
5
3f8a1d6 HEAD@{0}: reset: moving to HEAD~5
9c4e7b2 HEAD@{1}: commit: commit 10
8f4c2a9 HEAD@{2}: commit: commit 9
# 3. Empty the reflog
git reflog expire --expire=now --all
git reflog | wc -l
git fsck --lost-found --no-progress | grep -c "dangling commit"
0
5

The key difference: the reflog is empty, but the objects are still there. reflog expire deletes the record, not the objects. They can still be recovered with fsck... but you no longer know which one was the tip or what order they were in, because that information lived in the reflog. Recoverable, yes; convenient, no.

git cat-file -t 9c4e7b2
git log --oneline 9c4e7b2 -3        # the commit and its history are still accessible
commit
# 4. Now for the purge
git gc --prune=now -q
git fsck --lost-found --no-progress | grep -c "dangling commit"
git cat-file -t 9c4e7b2
0
fatal: Not a valid object name 9c4e7b2

Now it really has gone. The object no longer exists in .git/objects. No technique in this lesson recovers it; only somebody else's clone or a backup.

# 5. With a long reflog
rm -rf /tmp/p9-04d && mkdir /tmp/p9-04d && cd /tmp/p9-04d && git init -q -b main
git config gc.reflogExpire "1 year"
git config gc.reflogExpireUnreachable "1 year"
for i in $(seq 1 10); do echo "l $i" >> app.js; git add .; git commit -q -m "c$i"; done
git rev-parse --short HEAD > /tmp/tip.txt
git reset -q --hard HEAD~5
git gc --prune=now -q
git reflog | wc -l
git cat-file -t "$(cat /tmp/tip.txt)"
6
commit

The gc --prune=now has not been able to purge anything, because the reflog entries are still valid and the reflog counts as a reference. That is the exact mechanism that keeps orphaned objects alive, and it is why extending the expiry genuinely protects you.

# 6. Health check
cat > /tmp/git-health.sh <<'EOF'
#!/usr/bin/env bash
echo "Entries in the HEAD reflog: $(git reflog | wc -l)"
echo "Oldest entry:               $(git reflog --date=iso | tail -1)"
echo "reflogExpire:               $(git config --get gc.reflogExpire || echo '90 days (default)')"
echo "reflogExpireUnreachable:    $(git config --get gc.reflogExpireUnreachable || echo '30 days (default)')"
echo "Dangling objects:           $(git fsck --lost-found --no-progress 2>/dev/null | grep -c dangling)"
echo "Unpublished branches:       $(git for-each-ref --format='%(refname:short) %(upstream)' refs/heads | awk '$2==""{print $1}' | tr '\n' ' ')"
EOF
chmod +x /tmp/git-health.sh && /tmp/git-health.sh
Entries in the HEAD reflog: 11
Oldest entry:               3f8a1d6 HEAD@{2026-07-31 11:02:14 +0200}: commit (initial): c1
reflogExpire:               1 year
reflogExpireUnreachable:    1 year
Dangling objects:           5
Unpublished branches:       main

The last line is the one that gives most information in a real repository: every branch with no upstream exists on a single disk.

Conclusion

The reflog is the reason almost nothing is lost in Git, and knowing it completely changes your relationship with the destructive commands.

  • The reflog records every movement of every reference, in plain text under .git/logs/. And it is not merely a record: its entries count as references, and that is why they keep alive the commits no branch reaches.
  • It is local: it does not travel in a clone, it is not published, and it dies with the disk. It is not a backup. And it is temporary: 90 days for the reachable, 30 for the orphaned, which gc enforces.
  • HEAD@{n} is not HEAD~n. The first walks your personal history of movements; the second walks the graph. After a reset, they are different things, and confusing them is the commonest mistake.
  • The recovery pattern is always the same: git reflog → verify with git show --statgit branch rescue <hash>. Creating a branch is additive and moves nothing; reset --hard complicates every failed attempt.
  • The recipe book covers everything this course had been promising: undoing a reset --hard (the entry before the reset:), recovering a branch deleted with -D (the hash it prints, or the HEAD reflog, because the branch's own is deleted with it), rescuing a rebase (the entry before rebase (start), or --abort if it is still under way), undoing a merge (ORIG_HEAD) and recovering what an --amend swept away (the previous commit: entry).
  • When the reflog is not enough, git fsck --lost-found: dangling objects are normal, and among them are the orphaned commits and the blobs of a git add that was never committed. git cat-file -p and git show inspect them.
  • A stash is a commit with two or three parents (HEAD, index, untracked), and that is why it survives drop and clear: you look for WIP on among the unreachable ones and put it back with git stash store.
  • The limits are real: what never became an object (modifications with no add, files deleted by clean) and what has already been through gc --prune. There, only the editor's local history, a system copy or a colleague's clone can save you.
  • And the prevention that makes the whole lesson unnecessary: commit often, plant a git branch before anything risky, stash push -u before cleaning up, extend the reflog to a year and publish your branches.

Up to here, everything has rested on one premise: that the object database was healthy. The objects existed, the hashes added up and Git could read them.

And when it is not? When git status replies error: object file .git/objects/4f/8a2e6... is empty, when a reference points at an object that does not exist, when the index is corrupt and Git refuses to do anything at all. That is no longer a problem of badly placed references: it is a problem of integrity, and it has its own diagnosis, its own repairs and — most importantly — a clear criterion for knowing when to stop trying to fix it and clone again.

Continue in lesson 09-05: Dealing with Corrupted Repositories.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved