Master git rebase -i to squash, reorder, reword and split commits, then use the reflog as a safety net to recover from any rewrite gone wrong.
Introduction
Most developers treat Git history as something that happens to them. You commit, you push, and whatever mess you made along the way becomes a permanent part of the record β typos in commit messages, a fix lint commit right after the feature commit, work split across three commits that should have been one.
It doesn't have to be this way. Git history is editable, and two tools make that safe: interactive rebase for rewriting history on purpose, and the reflog for recovering when a rewrite goes wrong. Together they let you reshape your commits into a clean, reviewable story without the fear that you'll lose work.
This post covers both. First how to rewrite history deliberately with git rebase -i, then how the reflog acts as your undo button when an experiment goes sideways. By the end you'll edit commits, squash fixups, split a commit in two, and recover a branch you thought was gone β all without panic.
The Mental Model: Commits Are Cheap, History Is a Draft
Before any rewriting, internalize one idea: a commit is an immutable snapshot, but a branch is just a movable pointer.
When you "edit" history, you don't actually mutate old commits. Git creates new commits with the changes you want and moves the branch pointer to them. The old commits don't disappear β they're simply no longer reachable from any branch. They sit in the object database until garbage collection eventually removes them.
That detail is the whole reason rewriting is safe. As long as you can find the old commit's hash, you can get back to it. And finding it is exactly what the reflog is for.
So rewriting history is really: build a better sequence of commits, point the branch at it, and keep the old sequence around as a safety net until you're sure.
Interactive Rebase: Rewriting History on Purpose
git rebase -i opens a "todo list" of commits and lets you decide what to do with each one. Start it by naming the commit before the range you want to edit:
# Edit the last 4 commits
git rebase -i HEAD~4
# Or rebase everything since you branched off main
git rebase -i main
Git opens an editor with the commits in oldest-to-newest order β the reverse of git log:
pick a1b2c3d Add user authentication
pick e4f5g6h fix typo
pick i7j8k9l Add password reset
pick m0n1o2p fix lint
# Rebase 9f8e7d6..m0n1o2p onto 9f8e7d6
#
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like squash, but discard this commit's log message
# d, drop = remove commit
You edit this list, save, and Git replays the commits according to your instructions. The key actions:
pickβ keep the commit as-is.rewordβ keep the changes, but edit the message.editβ stop at this commit so you can amend its contents or split it.squashβ combine into the previous commit, keeping both messages.fixupβ combine into the previous commit, discarding this message.dropβ delete the commit entirely.- Reorder β just move the lines; Git replays them in the new order.
Example: Cleaning Up a Feature Branch
Take the messy history above. The two fix commits should fold into the work they correct, and we want a clearer message on the first commit:
reword a1b2c3d Add user authentication
fixup e4f5g6h fix typo
pick i7j8k9l Add password reset
fixup m0n1o2p fix lint
After saving, Git stops once to let you reword the first message, then replays the rest. The result is two clean commits β "Add user authentication" and "Add password reset" β with the fixups absorbed and no noise. That is a branch a reviewer will thank you for.
Autosquash: Letting Git Wire Up the Fixups
Manually marking fixup lines is fine for four commits, tedious for twenty. Git can do it for you. When you make a correction, commit it as a fixup that targets a specific commit:
git commit --fixup a1b2c3d
This creates a commit titled fixup! Add user authentication. Later, run rebase with --autosquash:
git rebase -i --autosquash main
Git automatically reorders the fixup commit directly under its target and pre-marks it fixup. You just confirm. (Set git config --global rebase.autosquash true to make it the default.) This pairs perfectly with a fixup-driven workflow where you commit corrections as you go and let rebase assemble the clean history at the end.
Splitting One Commit Into Two
edit is the most powerful action because it stops mid-rebase and hands you the working tree. Suppose one commit accidentally bundled two unrelated changes. Mark it edit:
edit a1b2c3d Add auth and unrelated config change
When the rebase pauses on it, undo the commit but keep the changes staged-or-not, then commit them separately:
# Move the commit's changes back into the working tree
git reset HEAD^
# Now stage and commit in logical pieces
git add auth/
git commit -m "Add user authentication"
git add config/
git commit -m "Update build config"
# Continue the rebase
git rebase --continue
One commit became two, each focused and self-contained.
Reflog: Your Safety Net
Everything above moves branch pointers around and discards old commits. Eventually you'll do it wrong β drop the wrong commit, squash too aggressively, or reset --hard and realize the work mattered. This is where the reflog turns a disaster into a five-second fix.
The reflog records every time HEAD (or a branch tip) moves. Every commit, checkout, reset, merge, and rebase step leaves an entry:
git reflog
m0n1o2p HEAD@{0}: rebase -i (finish): returning to refs/heads/feature
i7j8k9l HEAD@{1}: rebase -i (pick): Add password reset
a1b2c3d HEAD@{2}: rebase -i (reword): Add user authentication
9f8e7d6 HEAD@{3}: rebase -i (start): checkout main
4d5e6f7 HEAD@{4}: commit: fix lint
b8c9d0e HEAD@{5}: commit: Add password reset
Each HEAD@{n} is a position HEAD occupied. Notice HEAD@{4} and HEAD@{5} β those are the commits from before the rebase, still fully reachable by hash. The rewrite didn't destroy them; it just stopped pointing at them.
Recovering After a Bad reset --hard
The classic disaster:
git reset --hard HEAD~3 # oops, that deleted three commits of real work
git status looks clean, the commits seem gone. They aren't:
git reflog
# 4d5e6f7 HEAD@{1}: commit: the work you thought you lost
git reset --hard HEAD@{1} # back exactly where you were
Your branch is restored to the precise state before the reset. Because the commits were never unreachable from the reflog, nothing was actually lost.
Recovering After a Botched Rebase
Same idea, different mistake. You finish an interactive rebase, run the tests, and a feature is broken because you dropped the wrong commit. Rather than reconstruct it by hand, jump back to the pre-rebase tip:
git reflog
# look for the entry just before "rebase -i (start)"
# 9a8b7c6 HEAD@{6}: commit: <last commit before you started>
git reset --hard HEAD@{6}
The branch returns to its exact pre-rebase state, and you can try again. There's also a convenient shorthand for the position before the most recent rebase or reset:
git reset --hard ORIG_HEAD
Git sets ORIG_HEAD before any operation that moves the tip dramatically, so it often points exactly where you want.
How Long Does the Reflog Keep Entries?
The safety net isn't infinite. Reachable entries expire after 90 days by default, and unreachable ones after 30 days (gc.reflogExpire and gc.reflogExpireUnreachable). In practice that's plenty β recovery almost always happens within minutes. But it's why the reflog is a working-tree safety net, not a backup strategy. The reflog is also local and per-repository: it doesn't travel when you clone or push, and a fresh clone has an empty reflog.
A Real Recovery Workflow
Putting it together, here's the loop I actually use when reshaping a branch:
- Before rewriting, note the current tip:
git log -1 --format=%H(or just trust the reflog). - Rewrite freely with
git rebase -iβ squash, reorder, reword, split. - Run the tests. If everything passes, done.
- If something broke, don't debug the rewrite β
git reset --hard ORIG_HEADand start over.
The rewrite is disposable. That's the mindset shift: because the reflog guarantees you can always get back, you can be aggressive about cleaning history without ever risking the work.
Rules of the Road
Rewriting is powerful, which means it has one hard rule and a few soft ones:
- Never rewrite history that others have pulled. Rebasing commits that exist on a shared branch (like
main) changes their hashes, which forces everyone else into painful conflicts. Rewrite only your own un-pushed or feature-branch commits. - If you must update a shared branch you own, use
--force-with-lease, not--force. It refuses to overwrite if someone else pushed in the meantime, protecting their work. - Rebase before you open the PR, not after review starts β otherwise you invalidate the comments reviewers left on specific commits.
- Keep each commit focused. The point of all this is reviewability; a clean history is the deliverable, not just a tidy feeling.
Conclusion
Interactive rebase and the reflog are two halves of the same skill. Rebase lets you treat history as a draft β squashing fixups, rewording messages, reordering and splitting commits until the story is clear. The reflog removes the fear, because every state your branch has ever been in is recoverable by hash for weeks afterward.
Once these click, your relationship with Git changes. You stop tolerating messy history because cleaning it is cheap, and you stop fearing destructive commands because nothing is truly destroyed. Commit messily, rewrite deliberately, and let the reflog be the net that makes it all safe.