Skip to content

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.

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.

Terminal window
git rebase -i HEAD~5 # the last 5 commits
git rebase -i main # every commit on this branch not on main
git rebase -i --root # the entire history, including the first commit

Your editor opens with a todo list:

pick 3f2a1b0 Add parser skeleton
pick 4e2b7d1 wip
pick 9c1f0a3 Fix typo
pick 1a2b3c4 Add tests
pick 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 commit

You edit this file, save, and close. Git then executes the plan top to bottom.

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~:

Terminal window
# In the todo list, mark the commit 'edit'. When rebase stops:
git reset HEAD~ # un-commit it, keeping the changes in the working directory
git add -p # stage the first logical piece
git commit -m "First half"
git add .
git commit -m "Second half"
git rebase --continue

Applying the cleanup to the example above:

pick 3f2a1b0 Add parser skeleton
fixup 4e2b7d1 wip
fixup 9c1f0a3 Fix typo
pick 1a2b3c4 Add tests
fixup 7d8e9f0 Fix typo again

Two clean commits out of five messy ones.

If a step conflicts, resolve it exactly as in a normal rebase:

Terminal window
git add <resolved files>
git rebase --continue
git rebase --skip # drop the current commit and move on
git rebase --abort # give up entirely, restore the original branch

Rather than remembering later which commit a small fix belongs to, mark it at commit time:

Terminal window
git commit --fixup=9c1f0a3 # message becomes "fixup! Fix header parsing"
git commit --squash=9c1f0a3 # same, but keeps the message for the squash editor
git 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:

Terminal window
git config --global rebase.autosquash true

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.

Terminal window
git reflog
9c1f0a3 HEAD@{0}: reset: moving to HEAD~2
1a2b3c4 HEAD@{1}: commit: Add tests
4e2b7d1 HEAD@{2}: commit: Extract query builder
3f2a1b0 HEAD@{3}: checkout: moving from main to feature
8b7c6d5 HEAD@{4}: pull: Fast-forward

HEAD@{n} means “where HEAD was n moves ago”. You can use these as ordinary commit references:

Terminal window
git show HEAD@{1}
git diff HEAD@{3} HEAD
git reflog show main # the reflog for one branch specifically
git reflog --date=iso # timestamps instead of counts

Time-based forms also work: HEAD@{2.hours.ago}, main@{yesterday}.

You ran git reset --hard HEAD~3 and want those commits back.

Terminal window
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 1a2b3c4

git branch -D feature removes the label; the commits remain.

Terminal window
git reflog
# Look for the last entry that mentions the branch, e.g.
# => 9c1f0a3 HEAD@{7}: commit: Add pagination
git switch -c feature 9c1f0a3

If you know the branch name, this shortcut usually works because branch reflogs survive deletion for a while:

Terminal window
git reflog show feature

Before starting, ORIG_HEAD is set to the previous tip:

Terminal window
git reset --hard ORIG_HEAD

ORIG_HEAD is set by merge, rebase, reset, and pull — it is a one-slot undo. The reflog is the general case.

If the commit never had HEAD pointing at it — a dangling commit from an aborted operation, say — git fsck can find it:

Terminal window
git fsck --lost-found
# => dangling commit 1a2b3c4d5e6f...
git show 1a2b3c4

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.

Terminal window
git bisect start
git bisect bad # current commit is broken
git bisect good v1.2.0 # this tag was fine
Bisecting: 63 revisions left to test after this (roughly 6 steps)
[4e2b7d1] Extract query builder

Git checks out that commit. Test it, then report:

Terminal window
git bisect good # the bug is not here
git bisect bad # the bug is here
git bisect skip # can't test this commit (won't build, unrelated breakage)

Repeat until Git announces the culprit:

9c1f0a3 is the first bad commit
commit 9c1f0a3...
Add pagination to results
src/query.js | 14 ++++++++------

Always finish by returning to where you started:

Terminal window
git bisect reset

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.

Terminal window
git bisect start HEAD v1.2.0 # bad first, then good
git bisect run npm test -- parser.test.js
Terminal window
git bisect start HEAD v1.2.0
git bisect run ./scripts/check-bug.sh

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

scripts/check-bug.sh
#!/usr/bin/env bash
npm ci --silent || exit 125 # can't build here → skip
node -e "require('./src/parser').parse('x')" || exit 1
exit 0

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.

Terminal window
git switch --detach 9c1f0a3
git checkout v1.0.0
git checkout origin/main
Note: switching to 'v1.0.0'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state 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.

Terminal window
git switch - # back to the previous branch, abandoning any commits made here
git switch main # back to a specific branch

If you made commits you want to keep:

Terminal window
git switch -c experiment # create a branch here, keeping everything

If you already switched away and then realised you needed those commits:

Terminal window
git reflog # find the detached commit's hash
git switch -c experiment 1a2b3c4

Check your state at any time:

Terminal window
git status
# => HEAD detached at v1.0.0

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
Terminal window
# Local, unpushed: erase the last two commits, keep the changes staged
git reset --soft HEAD~2
# Published: neutralise a bad commit for everyone
git revert 9c1f0a3
# Published: neutralise a whole range
git revert --no-commit HEAD~3..HEAD
git 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.

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.

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.

Terminal window
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:

Terminal window
git push --force-with-lease=feature:9c1f0a3 origin feature

Someone force-pushed over commits you needed:

Terminal window
git reflog # your local reflog still knows the old tip
git branch rescue 1a2b3c4 # label it before anything gets collected
git push origin rescue # get it back onto the server

Anyone whose clone still has the old commits can do this. This is the practical upside of everyone holding a full copy of history.

  • rebase -i lists commits oldest-first; squash/fixup meld into the line above.
  • git commit --fixup=<hash> plus rebase -i --autosquash removes the bookkeeping from cleanup.
  • The reflog records every move of HEAD and 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.