Git rebase and history rewriting
In this series (8 parts)
A feature branch often accumulates messy history. “WIP”, “fix typo”, “actually fix the bug” — these commits tell you nothing useful six months later. Interactive rebase lets you clean up before merging.
The branching article introduced rebase as an alternative to merge. This article goes deeper into interactive rebase and the broader topic of history rewriting.
Standard rebase recap
A standard rebase replays your branch’s commits on top of another branch.
git checkout feature
git rebase main
Every replayed commit gets a new hash because its parent changed. The result is a linear history as if you branched off the current tip of main.
Interactive rebase
Interactive rebase opens a text editor listing your commits. You choose what to do with each one.
# Rebase the last 4 commits
git rebase -i HEAD~4
The editor shows something like:
pick a1b2c3d Add user model
pick e4f5g6h Add user controller
pick i7j8k9l Fix typo in model
pick m0n1o2p Add user tests
Each line starts with an action keyword. You change these keywords to reshape history.
Available actions
| Action | Effect |
|---|---|
pick | Keep the commit as-is |
reword | Keep the commit but edit the message |
edit | Pause at this commit so you can amend it |
squash | Meld into the previous commit, combine messages |
fixup | Meld into the previous commit, discard this message |
drop | Remove the commit entirely |
Save and close the editor. Git replays the commits with your modifications.
Squashing commits
Squashing combines multiple commits into one. This is the most common rebase operation.
pick a1b2c3d Add user model
squash e4f5g6h Add user controller
squash i7j8k9l Fix typo in model
squash m0n1o2p Add user tests
Git opens a new editor with all four commit messages combined. Edit the message to describe the full change:
Add user module with model, controller, and tests
The result is a single, descriptive commit instead of four noisy ones.
When to squash
- Before merging a feature branch. Reviewers see one logical commit per feature.
- When a series of commits represents one logical change.
- When intermediate commits are meaningless (“WIP”, “oops”, “fix tests”).
When not to squash
- When individual commits are meaningful and independently reviewable.
- When you want
git bisectto pinpoint the exact commit that introduced a bug.
Fixup: silent squashing
Fixup works like squash but automatically discards the fixup commit’s message.
pick a1b2c3d Add user model
fixup i7j8k9l Fix typo in model
pick e4f5g6h Add user controller
pick m0n1o2p Add user tests
Notice that the fixup commit was reordered to sit directly after the commit it fixes. You can reorder lines freely during interactive rebase.
The fixup workflow
A convenient pattern during development:
# Make the fix
git add src/user-model.js
# Create a fixup commit that references the original
git commit --fixup=a1b2c3d
# Later, auto-squash during rebase
git rebase -i --autosquash main
--autosquash automatically reorders fixup and squash commits to their correct positions. Set it as a default:
git config --global rebase.autoSquash true
Reordering commits
During interactive rebase, simply move lines to change the order.
pick m0n1o2p Add user tests
pick a1b2c3d Add user model
pick e4f5g6h Add user controller
This replays the test commit first, then the model, then the controller. Reordering can cause conflicts if later commits depend on earlier ones. Resolve them as they appear.
Editing commits
The edit action pauses the rebase at a specific commit so you can modify it.
pick a1b2c3d Add user model
edit e4f5g6h Add user controller
pick m0n1o2p Add user tests
Git pauses after applying e4f5g6h. You can now:
# Make changes to files
vim src/user-controller.js
# Amend the commit
git add .
git commit --amend
# Continue the rebase
git rebase --continue
This is useful for splitting a commit in two, adding forgotten changes, or fixing bugs in a specific commit.
Splitting a commit
To split one commit into multiple:
- Mark the commit as
edit. - When Git pauses, reset to undo the commit but keep changes.
- Make selective commits.
# Git pauses at the commit
git reset HEAD~1
# Stage and commit pieces separately
git add src/model.js
git commit -m "Add user model"
git add src/controller.js
git commit -m "Add user controller"
git rebase --continue
Dropping commits
Change pick to drop or simply delete the line.
pick a1b2c3d Add user model
drop i7j8k9l Fix typo in model
pick e4f5g6h Add user controller
The dropped commit disappears from the branch history. Its changes are reverted.
When not to rebase
Rebase rewrites commit hashes. This is safe on your local, unpushed branches. It is dangerous on shared branches.
The golden rule: never rebase commits that other people have based work on.
If you rebase and force-push a shared branch, everyone else’s copy of that branch diverges from the remote. They must resolve the divergence manually. This causes confusion and lost work.
Safe scenarios
- Rebasing a local feature branch before pushing.
- Rebasing a feature branch that only you work on.
- Rebasing after
git pull --rebaseon a shared branch (this only replays your local, unpushed commits).
Dangerous scenarios
- Rebasing
mainordevelopafter others have pulled. - Rebasing a shared feature branch without coordinating with collaborators.
- Rebasing any branch that has been tagged for a release.
Force push safety
After rebasing a branch that has already been pushed, you need to force push.
# Dangerous: overwrites remote unconditionally
git push --force
# Safer: only overwrites if the remote matches your expectation
git push --force-with-lease
--force-with-lease checks that the remote branch has not been updated since your last fetch. If someone else pushed in the meantime, the push fails. Always prefer this over --force.
# Set as default behavior
git config --global push.default current
Recovering from a bad rebase
If a rebase goes wrong, use the reflog.
# Find the commit before the rebase
git reflog
# a1b2c3d HEAD@{5}: rebase (start): checkout main
# Reset to pre-rebase state
git reset --hard HEAD@{5}
The reflog stores every HEAD movement. Even after a destructive rebase, the original commits exist for at least 30 days.
You can also abort a rebase in progress:
git rebase --abort
Best practices for clean history
- Commit often during development. Messy history is fine while working.
- Clean up before merging. Interactive rebase your feature branch.
- Write good final commit messages. Explain what and why.
- One logical change per commit. This helps reviews and bisection.
- Use fixup commits during development. Autosquash cleans them up later.
What comes next
Clean history makes code review easier. The next article covers Git hooks and automation, where you can enforce commit message conventions, run linters, and scan for secrets automatically before code leaves your machine.