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
- What the reflog is and where it lives
- Local and temporary: the two limits you need to know
- Reading
git reflog HEAD@{n}versusHEAD@{time}- The universal recovery pattern
- Recipe book:
reset --hard, deleted branch, rebase, merge,--amend - When the reflog is not enough:
git fsck --lost-found - Recovering a deleted stash
- The real limits: what cannot be recovered
- Hygiene: do not destroy your own safety net
- 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:
4f8a2e6c9b1d5e3a b52c9d1e4a7f3c8b Ana Ferrer <[email protected]> 1785312041 +0200 commit: GT-241 filter groundwork b52c9d1e4a7f3c8b 7d3a8f4a2c6e9b1d Ana Ferrer <[email protected]> 1785312388 +0200 commit: GT-241 calculate the counter 7d3a8f4a2c6e9b1d 4f8a2e6c9b1d5e3a Ana Ferrer <[email protected]> 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:
.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
- 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.
A single entry. All the movement history Ana had on her machine stays on her machine.
Three practical consequences:
- Ana's reflog cannot rescue Bruno's disaster. Everybody has their own.
- The reflog is not a backup. If the disk dies, it dies with it. The real backups are
git bundleand the team's own clones (lesson 09-05). - A freshly made clone has no safety net. The first unfortunate
reset --hardin 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 neverIt 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
- Reading
git reflog
git reflog7d3a8f4 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 configurationHow 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
HEADended up AFTER that operation. This detail is crucial and produces the most frequent mistake: if you want the state prior to theHEAD@{0}operation, you need the hash fromHEAD@{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
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 HEADFar 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-2417d3a8f4 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:
Useful when you do not recognise a commit by its message and need to see which files it touched.
HEAD@{n} versus HEAD@{time}
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 agoHEAD~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} mainAnd the warning Git gives you when you go beyond the deadline:
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.
- 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 9c4e7b2git 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
- Recipe book:
reset --hard, deleted branch, rebase, merge, --amend
reset --hard, deleted branch, rebase, merge, --amend6.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).
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-241HEAD@{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 -49c4e7b2 (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:
The shortcut, if you have just done it and have done nothing else since (lesson 09-02):
Or directly with the reflog syntax:
6.2. Recovering a branch deleted with -D
This closes the promise from lesson 03-06.
First gift: Git prints the hash when it deletes. If you still have it in the terminal buffer, recovery is immediate:
If you have closed the terminal, there are two routes.
A. The HEAD reflog (works if you were on that branch):
b52c9d1 HEAD@{3 hours ago}: checkout: moving from GT-247 to main
b52c9d1 HEAD@{3 hours ago}: commit: GT-247 validate the formB. Searching by message among all the orphaned commits (always works):
Or directly against the object database:
And the important detail that gets forgotten: a branch's own reflog is deleted along with it.
.git/logs/refs/heads/GT-247 no longer exists. That is why you have to search in the HEAD reflog, which does survive.
-Dis almost never irreversible. The commits are still in.git/objects, reachable from theHEADreflog, 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.
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-241The key entry is rebase (start): the one before it is the exact state prior to the rebase. Here, HEAD@{5} → 9c4e7b2.
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 wayAnd if the rebase has not finished yet, the way out is far simpler:
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):
6.4. Undoing a merge you should not have done
If you have just done it and there is nothing on top:
merge always writes ORIG_HEAD before merging. It is the canonical way out.
If you have already done more things on top:
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 stylesHEAD@{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.
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 groundworkHEAD@{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 3f8a1d6And 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 8f4c2a96.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.
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 4f8a2e6The line checkout: moving from 9c4e7b2 to main gives you the hash of where you came from.
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 |
- When the reflog is not enough:
git fsck --lost-found
git fsck --lost-foundThe 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.
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:
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 -r2026-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:
tree 8a1f6c3d5e2b9c7f4a6d1e8b3c5f7a9d2e4b6c8a parent 2f8c6e1a4b7d9c3e5f2a8b6d1c9e4f7a3b5d2c8e author Ana Ferrer <[email protected]> 1785312041 +0200 committer Ana Ferrer <[email protected]> 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 8a1f6c3Dangling 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;
}Searching for a string you remember is far more effective than reviewing the blobs one by one.
- Recovering a deleted stash
This closes the promise from lesson 05-04.
First gift, once again: drop prints the hash. With it:
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:
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:
| 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# 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)
- 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:
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-241That 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-241The fifth is the most underrated. A published branch is on two machines. No local accident can beat that.
- 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 show → git 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:
- Create six commits on
mainand a branchGT-241with three commits of its own. - Provoke four disasters in a row, without recovering in between:
a.
git reset --hard HEAD~2onGT-241. b.git switch main && git branch -D GT-241. c. Agit commit --amendonmainthat deletes part of the message. d. Agit mergeof an experimental branch you did not want. - Run
git reflog --date=relativeand note down, for each disaster, which entry you would use. - Recover all four things using always
git branch, neverreset. - Verify each recovery with
git show --statandgit diff.
Exercise 2: the limits of recovery
- In a new repository, create three situations: a commit, a file staged with
git add, and a file modified on disk only. - Run
git reset --hard HEAD~1. - Recover the commit with the reflog and the staged file with
git fsck --lost-found+git cat-file -p. - Document why the third is unrecoverable, checking it with
git fsckandgit reflog. - Create a stash with
-u, delete it withgit stash clearand recover it withgit fsck --unreachable. - Check with
git show --format='%p' -sthat the stash has three parents and inspect each of them.
Exercise 3: expiry and hygiene
- Create a repository with ten commits and produce five orphaned commits with
reset. - Check that they appear in
git fsck --lost-found. - Run
git reflog expire --expire=now --alland look atgit reflogandgit fsckagain. Explain the difference between the two outputs. - Run
git gc --prune=nowand check withgit cat-file -t <hash>that the objects really have disappeared. - Repeat the experiment having first configured
gc.reflogExpireUnreachable "1 year"and explain what changes. - 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 "[email protected]"
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# 2a. reset --hard
git reset -q --hard HEAD~2
# 2b. delete the branch
git switch -q main
git branch -D GT-241# 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 -3c7e2a91 (HEAD -> main) Merge experimental 2b9e6c1 (experimental) experiment that is no good 3f8a1d6 chore
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 branchNone 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.
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.
The original message, before the --amend reduced it to chore.
The only thing the unwanted merge contributed. To undo it:
And to restore GT-241 properly:
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~13f8a1d6 HEAD@{0}: reset: moving to HEAD~1
8f4c2a9 HEAD@{1}: commit: c2: committed
3f8a1d6 HEAD@{2}: commit (initial): c1# 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 -lA 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 listgit fsck --unreachable --no-progress | grep commit | awk '{print $3}' \
| xargs -I{} git log -1 --format='%h %ci %s' {} | grep -i "on main"Recovered in full, including the untracked file.
git cat-file -t b52c9d1
git show --stat b52c9d1^2 # the index
git show --stat b52c9d1^3 # the untracked onesThree 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# 2. The orphans
git fsck --lost-found --no-progress | grep -c "dangling commit"
git reflog | head -33f8a1d6 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"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# 4. Now for the purge
git gc --prune=now -q
git fsck --lost-found --no-progress | grep -c "dangling commit"
git cat-file -t 9c4e7b2Now 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)"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.shEntries 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: mainThe 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, whichgcenforces. HEAD@{n}is notHEAD~n. The first walks your personal history of movements; the second walks the graph. After areset, they are different things, and confusing them is the commonest mistake.- The recovery pattern is always the same:
git reflog→ verify withgit show --stat→git branch rescue <hash>. Creating a branch is additive and moves nothing;reset --hardcomplicates every failed attempt. - The recipe book covers everything this course had been promising: undoing a
reset --hard(the entry before thereset:), recovering a branch deleted with-D(the hash it prints, or theHEADreflog, because the branch's own is deleted with it), rescuing a rebase (the entry beforerebase (start), or--abortif it is still under way), undoing amerge(ORIG_HEAD) and recovering what an--amendswept away (the previouscommit: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 agit addthat was never committed.git cat-file -pandgit showinspect them. - A stash is a commit with two or three parents (
HEAD, index, untracked), and that is why it survivesdropandclear: you look forWIP onamong the unreachable ones and put it back withgit stash store. - The limits are real: what never became an object (modifications with no
add, files deleted byclean) and what has already been throughgc --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 branchbefore anything risky,stash push -ubefore 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
