Version Control

Interactive Rebase Mastery

A comprehensive guide to Git interactive rebase for editing, squashing, reordering, splitting, and cleaning up commits before sharing code.

Interactive Rebase Mastery

Interactive rebase is Git's editing suite for commit history. You can squash five WIP commits into one clean commit, reword a misleading message, reorder commits for logical flow, split a commit that changed too many things, and drop commits you no longer need. All without losing any code.

I rebase before every merge to main. The feature branch is my draft — interactive rebase turns it into a polished final version. This guide covers every interactive rebase operation with real scenarios.

Prerequisites

  • Git installed (v2.20+)
  • Understanding of commits and branches
  • Terminal access
  • A feature branch with multiple commits to practice on

Starting an Interactive Rebase

# Rebase the last N commits
git rebase -i HEAD~5

# Rebase onto a branch point
git rebase -i main

# Rebase onto a specific commit
git rebase -i abc1234

Git opens your editor with a list of commits:

pick abc1234 WIP: start search feature
pick def5678 fix typo in search query
pick ghi9012 WIP: add search results page
pick jkl3456 fix tests
pick mno7890 add search API documentation

# Rebase abc1234..mno7890 onto xyz5678 (5 commands)
#
# 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
# x, exec   = run command (the rest of the line) using shell
# b, break  = stop here (continue rebase later with 'git rebase --continue')
# d, drop   = remove commit

Change the pick keywords, save, and close the editor. Git replays the commits with your changes.

The Commands

Pick — Keep As-Is

pick abc1234 feat: add search feature

Keep the commit unchanged. The default for all commits.

Reword — Change the Message

reword abc1234 feat: add search feature

Git stops and opens your editor to change the commit message. The code stays the same.

# Before
pick abc1234 added search stuff

# Change to
reword abc1234 added search stuff

# Editor opens with the message — change it to:
# feat: add full-text search endpoint with pagination

Squash — Combine with Previous

pick abc1234 feat: add search endpoint
squash def5678 fix: handle empty search query
squash ghi9012 add search result pagination

All three commits become one. Git opens an editor with all three messages combined so you can write a single coherent message:

# This is a combination of 3 commits.
# This is the 1st commit message:
feat: add search endpoint

# This is the commit message #2:
fix: handle empty search query

# This is the commit message #3:
add search result pagination

# Edit this into a single message:
feat: add full-text search endpoint with pagination and empty query handling

Fixup — Squash Without Message

pick abc1234 feat: add search endpoint
fixup def5678 fix typo
fixup ghi9012 fix another typo

Like squash but discards the fixup commits' messages. The result keeps only the first commit's message. This is the most common cleanup operation.

Edit — Stop and Modify

edit abc1234 feat: add search and user profiles

Git stops after applying this commit. You can:

  • Modify files and amend the commit
  • Split the commit into multiple commits
  • Add or remove changes
# Git stops at the commit
# Make your changes
git add modified-file.js
git commit --amend

# Or split into multiple commits
git reset HEAD~1
git add src/search.js
git commit -m "feat: add search endpoint"
git add src/profiles.js
git commit -m "feat: add user profiles page"

# Continue the rebase
git rebase --continue

Drop — Remove a Commit

drop abc1234 debug: add console.log everywhere

The commit is removed entirely. The changes from that commit are gone.

Exec — Run a Command

pick abc1234 feat: add search endpoint
exec npm test
pick def5678 feat: add pagination
exec npm test

Git runs the command after applying each commit. If the command fails (non-zero exit), the rebase stops so you can fix the issue. This verifies that each commit passes tests independently.

Break — Pause the Rebase

pick abc1234 feat: add search
break
pick def5678 feat: add pagination

Git stops at the break point. You can inspect the state, run tests, or make changes before continuing with git rebase --continue.

Common Scenarios

Cleaning Up Before Merge

Starting with messy history:

pick a1 WIP: start auth feature
pick a2 fix import
pick a3 more auth work
pick a4 fix tests
pick a5 add password hashing
pick a6 typo fix
pick a7 add auth middleware
pick a8 update test assertions
pick a9 add documentation

Clean it up:

pick a1 WIP: start auth feature
fixup a2 fix import
fixup a3 more auth work
fixup a4 fix tests
squash a5 add password hashing
fixup a6 typo fix
pick a7 add auth middleware
fixup a8 update test assertions
pick a9 add documentation

Result: Three clean commits instead of nine messy ones:

  1. feat: add user authentication with password hashing
  2. feat: add auth middleware for protected routes
  3. docs: add authentication documentation

Reordering Commits

Move related commits together:

# Original order
pick a1 feat: add search endpoint
pick a2 fix: correct user validation
pick a3 feat: add search pagination
pick a4 test: add user validation tests
pick a5 test: add search tests

# Reordered — group by feature
pick a1 feat: add search endpoint
pick a3 feat: add search pagination
pick a5 test: add search tests
pick a2 fix: correct user validation
pick a4 test: add user validation tests

Warning: Reordering can cause conflicts if later commits depend on earlier ones. Git will stop and ask you to resolve conflicts if this happens.

Splitting a Commit

A commit did too many things:

edit abc1234 feat: add search and update user profiles and fix pagination

When Git stops:

# Undo the commit but keep the changes
git reset HEAD~1

# Commit each change separately
git add src/search.js src/searchService.js
git commit -m "feat: add search endpoint"

git add src/profiles.js
git commit -m "feat: update user profiles page"

git add src/pagination.js
git commit -m "fix: correct pagination offset calculation"

# Continue
git rebase --continue

Editing a Commit's Content

Fix a bug in an older commit:

edit abc1234 feat: add search endpoint
pick def5678 feat: add pagination
pick ghi9012 test: add search tests

When Git stops:

# Make the fix
vim src/search.js

# Amend the commit
git add src/search.js
git commit --amend --no-edit

# Continue — subsequent commits are replayed on top
git rebase --continue

Combining the First Two Commits

You cannot squash the first commit using normal interactive rebase. Use --root:

git rebase -i --root
pick a1 Initial commit
squash a2 Add project structure
pick a3 feat: add first feature

Autosquash with Fixup Commits

Create fixup commits that automatically squash during rebase:

# Make a change that fixes commit abc1234
git add src/search.js
git commit --fixup abc1234

# Git creates: "fixup! feat: add search endpoint"

# Later, rebase with autosquash
git rebase -i --autosquash main

Git automatically marks fixup commits with fixup and places them after their target:

pick abc1234 feat: add search endpoint
fixup xyz7890 fixup! feat: add search endpoint
pick def5678 feat: add pagination

Enable autosquash by default:

git config --global rebase.autoSquash true

Handling Rebase Conflicts

During Rebase

# Conflict occurs
# CONFLICT (content): Merge conflict in src/app.js

# Resolve the conflict
vim src/app.js  # Fix conflict markers

# Mark as resolved
git add src/app.js

# Continue
git rebase --continue

Aborting

# Cancel the entire rebase — return to the original state
git rebase --abort

Skipping a Commit

# Skip the current commit (its changes are lost)
git rebase --skip

Safety and Recovery

The Reflog Safety Net

Every rebase is recoverable:

# View the reflog
git reflog
# abc1234 HEAD@{0}: rebase (finish): returning to refs/heads/feature
# def5678 HEAD@{1}: rebase (squash): feat: add search
# ghi9012 HEAD@{2}: rebase (start): checkout main
# jkl3456 HEAD@{3}: commit: WIP more search  ← Before rebase

# Undo the entire rebase
git reset --hard HEAD@{3}

Creating a Backup Branch

# Before rebasing, create a backup
git branch backup/feature-search

# Rebase
git rebase -i main

# If something goes wrong
git reset --hard backup/feature-search
git branch -D backup/feature-search

Using Rerere

# Enable rerere to remember conflict resolutions
git config rerere.enabled true

# Now if the same conflict appears during rebase,
# Git resolves it automatically using the recorded resolution

Complete Working Example: Full Rebase Cleanup

#!/bin/bash
# Demonstrate interactive rebase cleanup workflow

# Create a demo repo with messy history
mkdir rebase-demo && cd rebase-demo
git init

# Initial commit
echo "# My App" > README.md
git add README.md
git commit -m "Initial commit"

# Create a feature branch with messy commits
git checkout -b feature/auth

echo "var jwt = require('jsonwebtoken');" > src/auth.js
mkdir -p src
git add -A
git commit -m "WIP start auth"

echo "// TODO: add validation" >> src/auth.js
git add -A
git commit -m "wip"

echo "var bcrypt = require('bcrypt');" >> src/auth.js
git add -A
git commit -m "add password hashing"

echo "typo fix" >> README.md
git add -A
git commit -m "fix typo"

echo "module.exports = { auth: true };" >> src/auth.js
git add -A
git commit -m "export auth module"

echo "var test = require('assert');" > tests/auth.test.js
mkdir -p tests
git add -A
git commit -m "add tests"

echo "// more tests" >> tests/auth.test.js
git add -A
git commit -m "fix test"

# Show messy log
echo "=== Before Rebase ==="
git log --oneline

# Interactive rebase (in a real workflow, you'd edit the todo list)
# For this demo, show what the cleaned result would look like
echo ""
echo "=== Rebase Plan ==="
echo "pick  <hash> WIP start auth"
echo "fixup <hash> wip"
echo "fixup <hash> add password hashing"
echo "fixup <hash> export auth module"
echo "fixup <hash> fix typo"
echo "pick  <hash> add tests"
echo "fixup <hash> fix test"
echo ""
echo "Result: 2 clean commits instead of 7 messy ones"
echo "  1. feat: add authentication with JWT and password hashing"
echo "  2. test: add authentication tests"

Common Issues and Troubleshooting

"Could not apply" error during rebase

A commit cannot be applied cleanly because it conflicts with the rewritten history:

Fix: Resolve the conflict markers in the affected files, git add them, and run git rebase --continue. If the conflict is too complex, use git rebase --abort to start over. Consider a different squash strategy.

Commits disappear after rebase

You dropped a commit accidentally or a squash consumed commits you wanted to keep:

Fix: Use git reflog to find the pre-rebase state and git reset --hard HEAD@{N} to restore it. Always check the rebase todo carefully before saving.

Rebase loop — same conflicts keep appearing

A reordered commit depends on code from a commit that now comes after it:

Fix: Reorder commits so dependencies come first. Or enable rerere (git config rerere.enabled true) to automatically resolve recurring conflicts.

"Cannot rebase: Your index contains uncommitted changes"

You have staged but uncommitted changes:

Fix: Commit or stash your changes before rebasing. git stash && git rebase -i main && git stash pop.

Interactive rebase on a branch pushed to remote

Rebasing changes commit hashes. If the branch was pushed, force-push is needed:

Fix: Use git push --force-with-lease after rebasing. This is safe for branches only you use. Never force-push shared branches (main, develop).

Best Practices

  • Rebase before merging to main. Clean up WIP commits, typo fixes, and debug artifacts. The feature branch is your draft — main should get the polished version.
  • Use fixup more than squash. Fixup silently merges into the previous commit. Squash opens an editor for message editing. For cleanup, fixup is faster.
  • Use --autosquash with --fixup commits. Creating fixup commits during development and autosquashing during rebase is the cleanest workflow.
  • Keep the reflog in mind. Every rebase is reversible through the reflog for at least 30 days. This should give you confidence to experiment.
  • Create backup branches before complex rebases. git branch backup/before-rebase takes a second and provides instant rollback.
  • Use exec npm test to verify each commit. Insert exec commands between picks to ensure every commit in the cleaned history is independently valid.
  • Never rebase published commits on shared branches. Rebasing rewrites history. Other developers who based work on the original commits will have conflicts.
  • Enable rebase.autoSquash globally. This makes --fixup commits automatically position themselves during interactive rebase without manual reordering.

References

Powered by Contentful