Git Interactive Rebase: Beyond Squashing Commits


Most developers learn interactive rebase for one purpose: squashing messy commits before merging a PR. You’ve made fifteen commits debugging a feature, and you want to present clean history to reviewers. Run git rebase -i, mark everything except the first commit as “squash,” write a proper commit message, and you’re done.

That’s useful, but interactive rebase does a lot more. I’ve found it invaluable for reordering work, splitting overly large commits, editing commit messages retroactively, and even removing sensitive data from history.

Reordering Commits

Sometimes you realise commits are in the wrong logical order. You fixed a bug, then added a feature, but really the bug fix should come first because the feature depends on it. Or you’ve mixed refactoring changes with feature work and want to separate them.

Interactive rebase lets you reorder commits by simply changing the line order in the rebase file. The commits will be replayed in your new sequence.

I use this often when I’ve been working on multiple things in parallel and my commit history jumps between different concerns. Before creating a PR, I reorder commits to tell a coherent story: first the refactoring, then the tests, then the feature implementation.

This only works cleanly if commits don’t conflict. If commit B depends on changes from commit A, reordering them will cause conflicts you’ll have to resolve. But for independent changes, it’s a quick way to improve logical flow.

Splitting Large Commits

The opposite problem: one commit that does too many things. You’ve wrapped up bug fixes, refactoring, and a new feature in a single commit because you were focused on making things work, not on commit cleanliness.

Interactive rebase’s “edit” command lets you stop the rebase at a specific commit, modify it, then continue. The workflow looks like this:

Start the rebase and mark the commit you want to split as “edit.” When the rebase stops at that commit, reset one commit back with git reset HEAD^. This unstages all changes from that commit while keeping them in your working directory.

Now stage and commit changes in smaller, logical chunks. When you’re done, continue the rebase with git rebase --continue.

I do this whenever I notice a commit has mixed concerns. A single commit shouldn’t fix a bug AND refactor the surrounding code AND add a feature. Break it into three commits that each do one thing.

This makes code review easier. Reviewers can understand each change independently. It also makes git blame more useful—when you track down why code changed, you get a focused commit message explaining one specific change, not a grab bag of unrelated modifications.

Editing Commit Messages

You wrote a commit message at 11 PM that made sense at the time but looks nonsensical the next morning. Or you referenced the wrong issue number. Or you want to add more context now that you understand the change better.

Interactive rebase’s “reword” command lets you change commit messages without modifying the code changes. This is cleaner than git commit --amend, which only works on the most recent commit.

I use this to improve commit messages before creating PRs. The messages I write while working are often terse notes to myself. Before sharing my work, I expand them with proper context, explanation of why the change was needed, and links to relevant issues.

Good commit messages make history searchable. If someone is looking for when a specific behaviour changed, descriptive messages help them find the right commit quickly. Taking a few minutes to improve messages before merging is worthwhile.

Dropping Commits Entirely

Sometimes you make commits that shouldn’t exist. Debug logging you added temporarily. Experiments that didn’t pan out. Changes you later realised were unnecessary.

Instead of reverting these commits—which adds more commits to history—you can remove them entirely with interactive rebase. Just delete the line for that commit, or mark it as “drop.”

This is particularly useful for cleaning up feature branches before merging. You tried three different approaches, committing each one, then went with the first approach. No need to preserve the failed experiments in history. Drop those commits and keep only the final implementation.

I’m cautious about rewriting history on shared branches, but for feature branches I’m working on alone, I freely drop commits that don’t add value.

Fixing Earlier Commits

You made a commit, then realised it has a small bug or missing piece. The conventional advice is to make a new “fix” commit, but this clutters history with corrections. Interactive rebase’s “fixup” command offers a better solution.

Make a new commit with your fix, then rebase and mark it as “fixup” for the commit you’re fixing. Git will automatically squash the fix into the target commit without prompting you for a commit message.

Even better, you can automate this with git commit --fixup=<commit-hash>, which creates a commit message formatted for auto-fixup. Then run git rebase -i --autosquash and Git will automatically arrange commits to squash fixups into their targets.

This workflow lets you maintain clean history without being precious about getting every commit perfect the first time. Notice a typo in code from three commits ago? Fixup commit. Forgot to update tests? Fixup commit. The final history shows polished work.

Combining Techniques

The real power comes from combining these techniques in a single rebase. I often do this before submitting PRs:

Reorder commits to group related changes together. Drop experimental commits that didn’t work out. Squash fixup commits into their targets. Split commits that do too many things. Reword commit messages for clarity.

A messy 20-commit feature branch becomes a clean 6-commit story that’s easy to review and understand.

The Risks

Interactive rebase rewrites history. If anyone else has your branch checked out, you’ll cause problems by changing commits they’re based on. The golden rule is: never rebase commits you’ve pushed to shared branches.

For feature branches you’re working on alone, this isn’t an issue. Rebase freely, then force-push when ready. But on main or other shared branches, stick to forward-only commits.

There’s also the risk of introducing bugs during rebasing. If you split commits incorrectly or reorder dependent changes, you might break intermediate states in history. This matters if you use git bisect to track down when bugs were introduced.

I mitigate this by testing after complex rebases. Run tests at each commit to ensure history remains functional. Git makes this easy with git rebase -i --exec "npm test" to run tests after each rebased commit.

Learning the Commands

Interactive rebase intimidated me at first. The command opens a text editor with cryptic abbreviations and scary warnings. But the interface is actually straightforward once you understand the options:

  • pick: keep this commit as-is
  • reword: keep changes, edit commit message
  • edit: stop and let me modify this commit
  • squash: combine with previous commit, edit message
  • fixup: combine with previous commit, keep its message
  • drop: remove this commit entirely

You can also reorder commits by moving lines up or down.

After editing the file and saving, Git replays your commits following your instructions. If there are conflicts, it pauses and lets you resolve them like any merge conflict.

Practical Workflow

Here’s how I typically use interactive rebase in my development workflow:

While working on a feature, I commit frequently without worrying about message quality or commit granularity. These commits are checkpoints, not polished history.

Before creating a PR, I run git rebase -i main to clean up my branch. I reorder commits logically, squash obvious fixups, split oversized commits, and improve messages.

After code review, if changes are requested, I make commits that address feedback. Before merging, I rebase again to incorporate these changes into the appropriate commits rather than leaving “addressed review comments” commits in history.

This gives me the benefits of frequent committing during development without the cost of messy history in the main branch.

Alternative Approaches

Not everyone agrees with this workflow. Some teams prefer preserving complete history, including all the false starts and fixes. They argue it better reflects the development process and makes debugging easier.

There’s merit to this view. If you need to track down exactly when a bug was introduced, having granular commits helps. And some people find comfort in never losing any commits, even messy ones.

But I think the benefits of clean history outweigh these concerns. Pull requests already preserve discussion and iteration history. What matters in the main branch is a clear record of what changed and why, not every keystroke that led there.

Making It Safer

If you’re worried about losing work during rebasing, create a backup branch first: git branch backup-branch. If the rebase goes wrong, you can return to your backup and try again.

Git also keeps a reflog of all operations, so even without explicit backups you can usually recover lost commits. But backups are simpler.

Start with simple rebases on throwaway branches. Practice squashing, reordering, and splitting commits until the workflow feels comfortable. Interactive rebase becomes less scary once you’ve done it a few times successfully.

Why It Matters

Clean Git history is valuable. When debugging, you want to find the commit that introduced a behaviour quickly. When onboarding new developers, clear history helps them understand how the codebase evolved. When reviewing old changes, good commit messages provide context.

Interactive rebase is the tool that makes clean history possible without sacrificing the freedom to commit messily while working. You get both: frequent commits during development, polished commits in shared history.

It’s worth learning the full power of interactive rebase beyond just squashing commits. The ability to reorder, split, edit, and drop commits gives you complete control over your project’s history. Use that control to tell clear stories about how your code evolved.