Search…

Git rebase and history rewriting

In this series (8 parts)
  1. Git internals: how Git actually works
  2. Everyday Git: the commands that matter
  3. Branching and merging
  4. Branching strategies for teams
  5. Git rebase and history rewriting
  6. Git hooks and automation
  7. Monorepos and large repo management
  8. GitOps

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

ActionEffect
pickKeep the commit as-is
rewordKeep the commit but edit the message
editPause at this commit so you can amend it
squashMeld into the previous commit, combine messages
fixupMeld into the previous commit, discard this message
dropRemove 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 bisect to 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:

  1. Mark the commit as edit.
  2. When Git pauses, reset to undo the commit but keep changes.
  3. 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 --rebase on a shared branch (this only replays your local, unpushed commits).

Dangerous scenarios

  • Rebasing main or develop after 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

  1. Commit often during development. Messy history is fine while working.
  2. Clean up before merging. Interactive rebase your feature branch.
  3. Write good final commit messages. Explain what and why.
  4. One logical change per commit. This helps reviews and bisection.
  5. 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.

Start typing to search across all content
navigate Enter open Esc close