Interactive Rebasing
What Will We Learn?
By the end of this chapter, you’ll know:
- What interactive rebase actually is, and how it differs from the plain rebase we covered last chapter
- A full, replayable walkthrough — set up a real repo, make real commits, and interactively rebase them yourself
- When to reach for interactive rebase versus a plain
git rebase - Best practices and a few nuances that’ll save you from a messy history (or a messy afternoon)
What is Interactive Rebasing?
Plain git rebase main replays your commits on top of a new base, one after another, exactly as they are. Interactive rebase (git rebase -i) does the same replaying, but before it starts, it hands you a checklist — a “todo list” — where you decide what happens to each commit along the way.
Instead of just pick-ing every commit as-is, you can tell Git to:
| Command | What it does |
|---|---|
pick | Keep the commit as-is (the default) |
reword | Keep the commit’s changes, but let you edit its message |
edit | Pause at this commit so you can amend it (add changes, split it, etc.) |
squash | Merge this commit into the one above it, and combine both messages |
fixup | Same as squash, but discard this commit’s message entirely |
drop | Remove the commit completely |
In other words: plain rebase answers “where should these commits live now?” — interactive rebase also answers “what should these commits actually look like?” It’s the tool you reach for to turn a messy, exploratory commit history into a clean, reviewable one before you open a pull request.
A Practical Walkthrough
We’ll build this from scratch so you can follow along exactly on your own machine. We’re recreating the same “before rebase” scenario from the general rebase chapter: main has commits A → B → E, and feature branches off at B with commits C → D.
Setting Up Files For Interactive Rebase
1. Create a folder and initialize a Git repo.
mkdir rebase-demo
cd rebase-demo
git init -b mainYou should see:
Initialized empty Git repository in /path/to/rebase-demo/.git/2. (Optional) Set a local identity for this repo, if you haven’t set one globally:
git config user.name "Your Name"
git config user.email "[email protected]"3. Create file.txt and make commit A.
echo "Line A: Project initialized" >> file.txt
git add file.txt
git commit -m "A: Initialize project"Output:
[main (root-commit) a1b2c3d] A: Initialize project
1 file changed, 1 insertion(+)
create mode 100644 file.txtYour commit hash (a1b2c3d) will be different — that’s expected and fine, just note it down mentally as “commit A” as you follow along.
4. Make commit B.
echo "Line B: Add basic structure" >> file.txt
git add file.txt
git commit -m "B: Add basic structure"Output:
[main b2c3d4e] B: Add basic structure
1 file changed, 1 insertion(+)5. Branch off feature right here — this is our divergence point:
git checkout -b featureOutput:
Switched to a new branch 'feature'6. Make commits C and D on feature branch:
echo "Line C: Start login feature" >> file.txt
git add file.txt
git commit -m "C: Start login feature"
echo "Line D: Finish login feature" >> file.txt
git add file.txt
git commit -m "D: Finish login feature"7. Switch back to main and make commit E — this simulates other work landing on main while you’re off working on feature:
git checkout mainecho "Line E: Update license" >> file.txt
git add file.txt
git commit -m "E: Update license"Output:
[main e5f6g7h] E: Update license
1 file changed, 1 insertion(+)8. Confirm the setup by viewing both branches:
git log --oneline --graph --allYou should see something shaped like this (your hashes will differ):
* a813936 (HEAD -> main) E: Update license
| * 829634e (feature) D: Finish login feature
| * 6424485 C: Start login feature
|/
* 6f0d8d4 B: Add basic structure
* ef63b5a A: Initialize projectThat’s our exact “before rebase” scenario: main at A → B → E, feature diverged at B with C → D.
flowchart TB
subgraph before["Before Rebase"]
direction RL
B1((B)) --> A1((A))
E1((E)) ----> B1
C1("C") --> B1
D1("D") --> C1
main1["main"] -.-> E1
feat1["feature"] -.-> D1
end
style main1 fill:#888,stroke:#888,color:#fff
style feat1 fill:#c2255c,stroke:#c2255c,color:#fff
gitGraph
commit id: "A: Initialize project"
commit id: "B: Add basic structure"
branch feature
commit id: "C: Start login feature"
commit id: "D: Finish login feature"
checkout main
commit id: "E: Update license"
Interactive Rebase
Now let’s make the demo realistic. In practice, you rarely have a perfectly tidy pair of commits — you’ve usually got a typo fix or a leftover debug commit mixed in. Let’s add two more commits (call F and G) to feature on purpose:
git checkout feature
echo "Line F: oops, fix typo in login" >> file.txt
git add file.txt
git commit -m "oops fix typo"
echo "Line G: temp debug print, remove me" >> file.txt
git add file.txt
git commit -m "temp: debug print, remove before merge"So feature now has four commits since it diverged from main: C, D, the typo fix, and the leftover debug commit.
The “before rebase” scenario is now more realistic:
flowchart TB
subgraph before["Before Rebase"]
direction RL
B1((B)) --> A1((A))
E1((E)) ----> B1
C1("C") --> B1
D1("D") --> C1
F1("F") ---> D1
G1("G") --> F1
main1["main"] -.-> E1
feat1["feature"] -.-> G1
end
style main1 fill:#888,stroke:#888,color:#fff
style feat1 fill:#c2255c,stroke:#c2255c,color:#fff
gitGraph
commit id: "A: Initialize project"
commit id: "B: Add basic structure"
branch feature
commit id: "C: Start login feature"
commit id: "D: Finish login feature"
checkout main
commit id: "E: Update license"
checkout feature
commit id: "oops fix typo"
commit id: "temp: debug print, remove before merge"
We want to clean this up before it goes anywhere near a pull request:
C’s message could be clearer — we’ll reword it- the typo fix really belongs inside
D, not as its own commit — we’ll fixup it intoD - the debug commit should never have been committed — we’ll drop it entirely
1. Start the interactive rebase, targeting main as the new base:
git rebase -i mainThis opens your default Git editor (commonly Vim; if you’re not sure what yours is, run git config core.editor to check) with a todo list that looks like this:
pick 6424485 C: Start login feature
pick 829634e D: Finish login feature
pick e83b028 oops fix typo
pick 4d9950b temp: debug print, remove before merge
# Rebase a813936..4d9950b onto a813936 (4 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# d, drop <commit> = remove commitThis list only shows the four commits unique to feature (C, D, and our two extras) — Git already knows A, B, and E are shared, so they’re never part of the rebase itself.
2. Edit the todo list to match what we planned. Change pick to reword on the first line, leave D as pick, change the typo-fix line to fixup, and change the debug-print line to drop:
reword 6424485 C: Start login feature
pick 829634e D: Finish login feature
fixup e83b028 oops fix typo
drop 4d9950b temp: debug print, remove before mergeWarning
Don’t blindly copy this. Your commit hash is different. Just change what’s asked.
Save and close the file (:wq and Enter, if you’re in Vim).
3. Git immediately pauses on the reword line to reword BUT we have merge conflict here:
Auto-merging file.txt
CONFLICT (content): Merge conflict in file.txt
error: could not apply fdce220... C: Start login feature
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply fdce220... C: Start login featureYou have to resolve conflict before Git applies reword. You file.txt is marked:
Line A: Project initialized
Line B: Add basic structure
<<<<<<< HEAD
Line E: Update license
=======
Line C: Start login feature
>>>>>>> 6424485 (C: Start login feature)Edit it to match:
Line A: Project initialized
Line B: Add basic structure
Line E: Update license
Line C: Start login featureNow, add file.txt and continue the rebase process:
git add file.txt
git rebase --continueGit auto opens a second editor window for reword, pre-filled with:
C: Start login feature
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.Replace the message with something clearer:
C: Add initial login form and validationSave and close this file too. output:
[detached HEAD 756d920] C: Add initial login form and validation
1 file changed, 1 insertion(+)
Successfully rebased and updated refs/heads/feature.4. Git finishes replaying the rest of the list automatically (because we don’t have more merge conflict) — D gets picked normally, the typo-fix commit gets folded into D without asking for a message (that’s what fixup means — squash would have paused to let you merge the two messages), and the debug commit is dropped entirely. You’ll see:
Successfully rebased and updated refs/heads/feature.5. Verify the result:
git log --oneline --graph --all* eefcfbd (feature) D: Finish login feature
* n2o3p4q C: Add initial login form and validation
* a813936 (main) E: Update license
* 6f0d8d4 B: Add basic structure
* ef63b5a A: Initialize projectfeature now has exactly two clean commits sitting on top of main’s latest commit (E) — the reworded login commit, and D with the typo fix quietly folded in. The debug commit is gone completely. That’s interactive rebase in a nutshell: not just moving commits, but shaping them into what they should have looked like in the first place.
flowchart RL
main["main"] -.-> E
subgraph after["After Rebase — feature onto main"]
B((B)) --> A((A))
E((E)) --> B
C((C')) --> E
D((D')) --> C
feature["feature"] -.-> D
end
style main fill:#888,stroke:#888,color:#fff
style feature fill:#c2255c,stroke:#c2255c,color:#fff
General or Interactive Rebase: What to Choose?
flowchart TD
A["Do you just need to catch up with main?"] -->|"Yes, nothing to clean up"| B["Plain: git rebase main"]
A -->|"No, commits need cleanup too"| C["Messy messages, leftover fixups, or debug commits?"]
C -->|"Yes"| D["Interactive: git rebase -i main"]
C -->|"No, just reordering local WIP"| D
As a rule of thumb:
- Use plain rebase when your commits are already fine and you just want them replayed on a newer base — the everyday “keep my branch up to date” move.
- Use interactive rebase when the commits themselves need work — squashing fixups, rewording unclear messages, dropping junk, or reordering commits before a PR.
They’re not mutually exclusive, either — a common flow is to interactively clean up your commits first, then do (or let the interactive rebase itself do) the replay onto the latest main in the same step, exactly like we did above.
Best Practices and Nuances
- The golden rule still applies, doubly so here. Interactive rebase rewrites history more aggressively than a plain rebase — never run it on a branch anyone else has already pulled or built on.
- Reordering isn’t free. You can reorder lines in the todo list to change commit order, but if a later commit depends on an earlier one (e.g.
Dedits a functionCintroduced), reordering them can produce conflicts or even break the code at intermediate steps. Only reorder commits you know are independent. fixupvssquash— pick based on whether the message matters. Usefixupfor “this commit has zero value on its own” (typo fixes, leftover debug lines). Usesquashwhen both commit messages have information worth preserving together.editis your escape hatch for “I need to change the code, not just the message.” When Git pauses on aneditline, your working directory is exactly as it was right after that commit — amend it withgit commit --amend, then rungit rebase --continuewhen you’re done.- You can abort at any point. If the todo list edits or a conflict mid-rebase get confusing,
git rebase --abortputs you back exactly where you started, no harm done. --autosquashis worth knowing once you’re comfortable. If you commit withgit commit --fixup=<hash>, Git tags the commit for you — thengit rebase -i --autosquash mainautomatically arranges it right after its target in the todo list, so you don’t have to move lines around by hand.- Test after the rebase finishes, not just after each step. Even a clean, conflict-free interactive rebase can silently change behavior when commits are squashed together or reordered — a passing rebase isn’t the same as passing tests.