History and Recovery
Git almost never loses anything. Commits become unreachable — no branch or tag points at them — but they sit in the object database for weeks, and the reflog remembers where every pointer has been. This page covers reshaping history deliberately, and getting it back when you reshape it wrongly.
Interactive rebase
Section titled “Interactive rebase”git rebase -i <base> replays the commits after <base>, but first opens an editor listing them and
letting you decide what happens to each one. It is the tool for cleaning up a messy branch before it
becomes a pull request.
git rebase -i HEAD~5 # the last 5 commitsgit rebase -i main # every commit on this branch not on maingit rebase -i --root # the entire history, including the first commitYour editor opens with a todo list:
pick 3f2a1b0 Add parser skeletonpick 4e2b7d1 wippick 9c1f0a3 Fix typopick 1a2b3c4 Add testspick 7d8e9f0 Fix typo again
# Rebase 8b7c6d5..7d8e9f0 onto 8b7c6d5 (5 commands)## Commands:# p, pick <commit> = use commit# r, reword <commit> = use commit, but edit the commit message# e, edit <commit> = use commit, but stop for amending# s, squash <commit> = use commit, but meld into previous commit# f, fixup <commit> = like "squash", but discard this commit's log message# d, drop <commit> = remove commitYou edit this file, save, and close. Git then executes the plan top to bottom.
The commands
Section titled “The commands”| Command | Effect |
|---|---|
pick |
Keep the commit as is. |
reword |
Keep the changes, open an editor to change the message. |
edit |
Stop after applying this commit so you can amend the content, then --continue. |
squash |
Combine into the previous commit; opens an editor to merge both messages. |
fixup |
Combine into the previous commit; discard this commit’s message. |
drop |
Discard the commit entirely. Deleting the line does the same thing. |
exec |
Run a shell command at this point (e.g. exec npm test). |
break |
Stop here unconditionally; resume with --continue. |
Reordering is done by moving lines. Splitting a commit uses edit plus git reset HEAD~:
# In the todo list, mark the commit 'edit'. When rebase stops:git reset HEAD~ # un-commit it, keeping the changes in the working directorygit add -p # stage the first logical piecegit commit -m "First half"git add .git commit -m "Second half"git rebase --continueApplying the cleanup to the example above:
pick 3f2a1b0 Add parser skeletonfixup 4e2b7d1 wipfixup 9c1f0a3 Fix typopick 1a2b3c4 Add testsfixup 7d8e9f0 Fix typo againTwo clean commits out of five messy ones.
If a step conflicts, resolve it exactly as in a normal rebase:
git add <resolved files>git rebase --continuegit rebase --skip # drop the current commit and move ongit rebase --abort # give up entirely, restore the original branchAutosquash
Section titled “Autosquash”Rather than remembering later which commit a small fix belongs to, mark it at commit time:
git commit --fixup=9c1f0a3 # message becomes "fixup! Fix header parsing"git commit --squash=9c1f0a3 # same, but keeps the message for the squash editorgit rebase -i --autosquash HEAD~10--autosquash reads those fixup! / squash! prefixes, moves each line directly beneath its target,
and sets the right command. The todo list arrives pre-arranged. Make it the default:
git config --global rebase.autosquash trueThe reflog
Section titled “The reflog”Every time HEAD or a branch tip moves — commit, checkout, merge, rebase, reset, pull — Git appends a
line to the reflog. It is a local, per-repository journal of where your pointers have been, and it
is the reason “I lost my commits” is almost always wrong.
git reflog9c1f0a3 HEAD@{0}: reset: moving to HEAD~21a2b3c4 HEAD@{1}: commit: Add tests4e2b7d1 HEAD@{2}: commit: Extract query builder3f2a1b0 HEAD@{3}: checkout: moving from main to feature8b7c6d5 HEAD@{4}: pull: Fast-forwardHEAD@{n} means “where HEAD was n moves ago”. You can use these as ordinary commit references:
git show HEAD@{1}git diff HEAD@{3} HEADgit reflog show main # the reflog for one branch specificallygit reflog --date=iso # timestamps instead of countsTime-based forms also work: HEAD@{2.hours.ago}, main@{yesterday}.
Recovering a lost commit
Section titled “Recovering a lost commit”You ran git reset --hard HEAD~3 and want those commits back.
git reflog# => 8b7c6d5 HEAD@{0}: reset: moving to HEAD~3# => 1a2b3c4 HEAD@{1}: commit: Add tests ← the tip you lost
git reset --hard 1a2b3c4 # move the branch back# or, safer — inspect first:git switch -c recovered 1a2b3c4Recovering a deleted branch
Section titled “Recovering a deleted branch”git branch -D feature removes the label; the commits remain.
git reflog# Look for the last entry that mentions the branch, e.g.# => 9c1f0a3 HEAD@{7}: commit: Add pagination
git switch -c feature 9c1f0a3If you know the branch name, this shortcut usually works because branch reflogs survive deletion for a while:
git reflog show featureRecovering a botched rebase or merge
Section titled “Recovering a botched rebase or merge”Before starting, ORIG_HEAD is set to the previous tip:
git reset --hard ORIG_HEADORIG_HEAD is set by merge, rebase, reset, and pull — it is a one-slot undo. The reflog is the
general case.
When the reflog is not enough
Section titled “When the reflog is not enough”If the commit never had HEAD pointing at it — a dangling commit from an aborted operation, say —
git fsck can find it:
git fsck --lost-found# => dangling commit 1a2b3c4d5e6f...git show 1a2b3c4git bisect
Section titled “git bisect”Bisect finds the exact commit that introduced a bug by binary search. Given a known-bad commit and a known-good one, it checks out the midpoint, you say “good” or “bad”, and it halves the range. Ten steps covers a thousand commits.
git bisect startgit bisect bad # current commit is brokengit bisect good v1.2.0 # this tag was fineBisecting: 63 revisions left to test after this (roughly 6 steps)[4e2b7d1] Extract query builderGit checks out that commit. Test it, then report:
git bisect good # the bug is not heregit bisect bad # the bug is heregit bisect skip # can't test this commit (won't build, unrelated breakage)Repeat until Git announces the culprit:
9c1f0a3 is the first bad commitcommit 9c1f0a3... Add pagination to results src/query.js | 14 ++++++++------Always finish by returning to where you started:
git bisect resetAutomating it
Section titled “Automating it”If you can express the test as a command that exits 0 when good and non-zero when bad, Git runs the whole search itself.
git bisect start HEAD v1.2.0 # bad first, then goodgit bisect run npm test -- parser.test.jsgit bisect start HEAD v1.2.0git bisect run ./scripts/check-bug.shAn exit code of 125 means “skip this commit” — use it in a script when the build is broken for unrelated reasons. Exit codes 126 and 127 also abort, so avoid them.
#!/usr/bin/env bashnpm ci --silent || exit 125 # can't build here → skipnode -e "require('./src/parser').parse('x')" || exit 1exit 0Detached HEAD
Section titled “Detached HEAD”Normally HEAD points at a branch name. When you check out a commit, tag, or remote-tracking branch
directly, HEAD points at the commit instead. That is detached HEAD.
git switch --detach 9c1f0a3git checkout v1.0.0git checkout origin/mainNote: switching to 'v1.0.0'.
You are in 'detached HEAD' state. You can look around, make experimentalchanges and commit them, and you can discard any commits you make in thisstate without impacting any branches by switching back to a branch.It is not an error state. It is genuinely useful: you can check out an old release, build it, test it,
and leave without affecting anything. git bisect uses it constantly.
The danger is only this: commits made in detached HEAD belong to no branch. Switch away and nothing points at them; they are unreachable and eventually garbage collected.
Escaping
Section titled “Escaping”git switch - # back to the previous branch, abandoning any commits made heregit switch main # back to a specific branchIf you made commits you want to keep:
git switch -c experiment # create a branch here, keeping everythingIf you already switched away and then realised you needed those commits:
git reflog # find the detached commit's hashgit switch -c experiment 1a2b3c4Check your state at any time:
git status# => HEAD detached at v1.0.0Reset vs revert for recovery
Section titled “Reset vs revert for recovery”Both undo a commit; they differ in whether history is rewritten.
git reset |
git revert |
|
|---|---|---|
| Mechanism | Moves the branch pointer backwards | Adds a new commit with the inverse diff |
| History | Rewritten — old commits become unreachable | Preserved — history grows |
| Safe after pushing | No | Yes |
| Undoing the undo | Reflog | Revert the revert |
| Working directory | Depends on --soft/--mixed/--hard |
Unaffected apart from the applied change |
# Local, unpushed: erase the last two commits, keep the changes stagedgit reset --soft HEAD~2
# Published: neutralise a bad commit for everyonegit revert 9c1f0a3
# Published: neutralise a whole rangegit revert --no-commit HEAD~3..HEADgit commit -m "Revert the pagination feature"You can revert a revert. It is an ordinary commit, so git revert <the-revert-commit> reinstates the
original change — useful when a feature is pulled for a release then brought back.
Rewriting published history
Section titled “Rewriting published history”Every history-rewriting command — commit --amend, rebase, reset followed by force-push,
filter-repo — replaces commits with new ones that have different hashes.
If nobody else has the old commits, no harm done. If they do, their repository still contains the
originals. Their next git pull merges the old and new lines together, duplicating every rewritten
commit, and the resulting mess has to be cleaned up by hand on every clone.
–force-with-lease
Section titled “–force-with-lease”Bare git push --force says “make the remote branch equal my branch, whatever is there”. If a
colleague pushed in the last five minutes, you have just deleted their work with no warning.
git push --force-with-lease--force-with-lease first checks that the remote branch is still at the commit your origin/<branch>
says it is. If someone pushed since your last fetch, the push is rejected and you go look at what
happened.
! [rejected] feature -> feature (stale info)error: failed to push some refs to '...'You can be explicit about the expected value:
git push --force-with-lease=feature:9c1f0a3 origin featureIf it goes wrong anyway
Section titled “If it goes wrong anyway”Someone force-pushed over commits you needed:
git reflog # your local reflog still knows the old tipgit branch rescue 1a2b3c4 # label it before anything gets collectedgit push origin rescue # get it back onto the serverAnyone whose clone still has the old commits can do this. This is the practical upside of everyone holding a full copy of history.
Key points
Section titled “Key points”rebase -ilists commits oldest-first;squash/fixupmeld into the line above.git commit --fixup=<hash>plusrebase -i --autosquashremoves the bookkeeping from cleanup.- The reflog records every move of
HEADand every branch tip, and makes almost any local mistake reversible — but it is local-only and entries expire in 30–90 days. git bisect run <cmd>finds the offending commit automatically; exit 125 means skip.- Detached HEAD is safe to be in and unsafe to commit in;
git switch -c <name>rescues the commits. - Rewrite history only where nobody has pulled from, and always push with
--force-with-lease(ideally plus--force-if-includes) rather than--force.