Git Interactive Rebase: Squash, Fixup, Reword, Split, and Recovering from a Bad Rebase

Key takeaways

A branch full of "fix typo" and "WIP" commits is hard to review. The post explains each command in the rebase editor, workflows such as --fixup with autosquash, why "ours" and "theirs" swap during a rebase, and the rules for rewriting history that others may have pulled.

Why Interactive Rebase?

Before rebase:                         After rebase:
  abc123 WIP                           abc789 Add user authentication
  def456 WIP: actually works             ↑ clean, single commit
  ghi789 Fix typo                        ready to merge
  jkl012 Add user authentication

Interactive rebase lets you clean up messy development history before it becomes permanent in main.

Common use cases:

  • Combine WIP commits into meaningful commits before PR
  • Fix a bad commit message from 3 commits ago
  • Remove a commit that shouldn’t be in the branch
  • Reorder commits for logical clarity
  • Split one large commit into focused changes

I used to be genuinely nervous about interactive rebase — the fear of “breaking” a branch and losing work kept me merging messy WIP history into shared branches for longer than I should have. What actually changed that was internalizing one fact: git rebase -i doesn’t destroy anything until you explicitly delete a pick line or the rebase completes and you move on — every commit git ever creates stays reachable via git reflog for a good while afterward (the default expiry is 90 days for reachable commits), so git rebase --abort or a reflog-based recovery is almost always available as a safety net. Once that clicked, rebase stopped being a scary, one-way operation and became just another editing tool — the real risk isn’t rebasing itself, it’s rebasing commits other people already have, which is what the safety rules further down in this guide are actually about.


Basic Syntax

# Rebase last N commits interactively
git rebase -i HEAD~3    # Rebase last 3 commits

# Rebase from a specific commit (exclusive — that commit is not included)
git rebase -i abc1234

# Rebase onto another branch (rebase feature onto main)
git rebase -i main

# Rebase from the point where branch diverged from main
git rebase -i $(git merge-base HEAD main)

The $(git merge-base HEAD main) form is worth reaching for over a hardcoded HEAD~N the moment you’re not certain exactly how many commits your branch has diverged by — merge-base finds the actual common ancestor commit between your branch and main, so this always rebases exactly the commits unique to your branch, regardless of whether that’s 3 commits or 30. Using HEAD~5 when your branch actually has 8 unique commits silently leaves 3 of them out of the interactive editor entirely, which is an easy miscount to make on a branch you’ve been working on for a while and haven’t been precisely tracking the commit count of.


The Rebase Editor

When you run git rebase -i HEAD~4, an editor opens:

pick a1b2c3d Add user model
pick e4f5a6b Add auth routes
pick 7c8d9e0 WIP: half-working login
pick 1f2a3b4 Fix login endpoint

# Rebase a1b2c3d..1f2a3b4 onto 9f8e7d6 (4 commands)
#
# Commands:
# p, pick   = use commit
# r, reword = use commit, but edit commit message
# e, edit   = use commit, but stop for amending
# s, squash = use commit, meld into previous commit (keep message)
# 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
# d, drop   = remove commit
# l, label  = label current HEAD with a name

The detail that trips up almost everyone the first time: commits are listed oldest first, top to bottom — the exact opposite of git log’s usual newest-first ordering — because this list literally describes the order git will replay the commits in, from the base you’re rebasing onto, upward toward your current HEAD. Reordering, squashing “into previous,” and dropping all have to be read against that oldest-at-top orientation; assuming it’s newest-first (a natural assumption if you’re used to reading git log output) is a reliable way to squash a commit into the wrong neighbor or reorder things backward from what you intended.


Commands in Detail

squash / fixup — Combine Commits

# Before
pick a1b2c3d Add user model
pick e4f5a6b Add auth routes
pick 7c8d9e0 WIP: half-working login
pick 1f2a3b4 Fix login endpoint

# After editing — squash/fixup into one commit
pick a1b2c3d Add user model
pick e4f5a6b Add auth routes
pick 7c8d9e0 WIP: half-working login
f    1f2a3b4 Fix login endpoint     # fixup: combine, discard message

# squash will open another editor to combine messages
# fixup (f) silently drops the commit message — use for minor fixes
# Squash all WIP commits before pushing
git rebase -i HEAD~5
# Change all "pick" except the first to "squash" or "fixup"

squash/fixup always merges a commit into the one immediately above it in the list — never into an arbitrary earlier commit — which is worth stating explicitly since it’s the source of the most common squash mistake: marking a commit fixup when the commit you actually want it combined with isn’t its immediate predecessor in the current order. If the fix needs to land on a commit that isn’t adjacent, the commit has to be reordered next to its target first (covered below), and only then marked for squash/fixup — trying to skip that step produces a combined commit that merges the wrong two changes together.

reword — Fix a Commit Message

# Change "pick" to "reword" (or "r")
pick a1b2c3d Add user model
reword e4f5a6b fix thing         # ← message needs fixing
pick 7c8d9e0 Add auth routes

# Git will stop at the reword commit and open editor for the message

Worth knowing that reword (like every command that changes a commit) rewrites that commit’s hash, and every commit after it in the history — this is inherent to how git works, not a bug: a commit’s hash is derived partly from its parent’s hash, so changing any ancestor necessarily changes every descendant’s hash too, even if their actual content (diff, message) stays identical. This is exactly why rebasing published commits is disruptive to collaborators, covered in more depth in the Safe Rebase Rules section later — it’s not that rebase “breaks” anything technically, it’s that every commit downstream of the rewritten one now has a genuinely different identity than the one anyone else already has locally.

edit — Stop to Amend a Commit

pick a1b2c3d Add user model
edit e4f5a6b Add auth routes     # ← stop here
pick 7c8d9e0 Add login

# During the rebase, git stops at 'edit' commits:
# You can then:
git add forgotten-file.ts
git commit --amend               # Add to the commit
git rebase --continue            # Resume

edit is the one command in this list that genuinely pauses execution and hands control back to you at a real, normal repository state — mid-rebase but with that specific commit checked out and applied, so you can inspect it, add forgotten files, run tests against exactly that point in history, or split it (covered later) before continuing. It’s worth reaching for over trying to fix something after the fact with a follow-up commit, specifically when the fix genuinely belongs as part of that historical commit — a stray debug console.log added in commit 3 that should never have existed, for instance, is better removed via edit than left in place and “fixed” three commits later, which just adds noise to the history instead of cleaning it.

drop — Remove a Commit

pick a1b2c3d Add user model
drop e4f5a6b Accidental debug logs   # ← delete this commit
pick 7c8d9e0 Add auth routes

# Or just delete the line — same effect

Worth a genuine word of caution before reaching for drop: if any commit after the dropped one depends on changes it introduced, dropping it produces a conflict during the rebase (or, worse, a silent breakage if the code merges cleanly but no longer makes sense without the dropped change) — git has no way to know a later commit’s logic actually relied on the one you’re removing, it just tries to replay the remaining commits and conflicts if the diffs don’t apply cleanly. This is exactly why “or just delete the line” isn’t quite as casual as it sounds — worth actually reading what a commit changed before dropping it, not just judging by its message.

Reorder Commits

# Before (in rebase editor — just move lines)
pick a1b2c3d Add user model
pick e4f5a6b Add auth routes
pick 7c8d9e0 Add tests

# After (reordered — tests before auth routes)
pick a1b2c3d Add user model
pick 7c8d9e0 Add tests            # moved up
pick e4f5a6b Add auth routes

# Note: conflicts are possible if commits depend on each other

The same dependency risk from drop applies here in a subtler form: reordering only ever causes a genuine problem when the commits being swapped touch overlapping code, since git replays each commit’s diff in the new order and a diff that assumed a prior state (code that existed before the reorder, or didn’t exist yet after it) can fail to apply cleanly. Reordering commits that touch entirely unrelated files is always safe; the moment two commits modify the same lines, it’s worth thinking through whether the new order still makes logical sense before committing to the reorder, not just moving lines around until the editor looks tidier.


Common Workflows

Clean Up Before a PR

# You have 6 messy commits on your feature branch
git log --oneline origin/main..HEAD
# 1a2b3c4 fix typo
# 5d6e7f8 WIP 2
# 9g0h1i2 WIP: almost
# 3j4k5l6 add tests
# 7m8n9o0 add auth
# 1p2q3r4 setup

# Clean them up
git rebase -i origin/main

# In the editor:
pick 1p2q3r4 setup
pick 7m8n9o0 add auth
squash 9g0h1i2 WIP: almost
squash 5d6e7f8 WIP 2
pick 3j4k5l6 add tests
fixup 1a2b3c4 fix typo
# Result: clean 3-commit history — "setup", "add auth" (absorbing the two
# WIP commits that were actually continuing that work), and "add tests"

This is the workflow I reach for before opening almost every PR — six honest, chronological WIP commits are genuinely useful while I’m working (checkpoints I can diff against, revert to, or bisect through if something breaks mid-development), but they’re noise to a reviewer who has to read the final diff and understand the intent behind each logical change, not the messy, non-linear path I actually took to get there. The squashed history isn’t dishonest — it’s a cleaner narrative of what changed and why, told after the fact once the destination is known, which is exactly the audience a commit history serves once it’s permanent in main: future readers doing git blame or git log, not a record of your exact keystroke-by-keystroke development process.

Fix a Commit Message Mid-History

git rebase -i HEAD~5
# Change "pick" to "reword" on the commit with the bad message
# Git stops, opens editor — fix the message
# git rebase --continue

Worth knowing this is functionally identical to git commit --amend --message "..." on that one commit — reword is really just automating “stop the rebase right before this commit, let you amend its message, then keep going,” which is why it’s the right tool specifically when the message you need to fix isn’t the most recent commit (amend only ever touches HEAD) but something several commits back in history.

Split One Large Commit

git rebase -i HEAD~3
# Mark the large commit as "edit"

# When git stops:
git reset HEAD~1           # Unstage the commit (keep changes)
git add src/models/        # Stage part 1
git commit -m "Add user model"
git add src/routes/        # Stage part 2
git commit -m "Add auth routes"
git rebase --continue      # Continue

git reset HEAD~1 here does the specific, useful thing that makes commit-splitting possible: it moves the branch pointer back one commit without touching the working directory’s actual files — every change from the original commit is still sitting there, unstaged, ready to be selectively re-staged and committed as separate, focused pieces. This is the same underlying reset mechanic used for plenty of other “undo the commit, keep the work” situations (a soft reset, functionally); the practical value here is turning “one commit that touched the model layer, the routes, and a config tweak all at once” into three commits a reviewer can actually evaluate independently, each one small enough to reason about and revert on its own if needed.

git commit —fixup for Automated Squash

# Make a fixup commit for a specific earlier commit
git commit --fixup=abc1234    # Creates: "fixup! <original message>"

# Automatically squash all fixup commits
git rebase -i --autosquash HEAD~5
# fixup commits are automatically placed after their target and marked 'fixup'

This solves the exact “squash into a non-adjacent commit” problem flagged earlier — rather than manually reordering the todo list so a fix sits next to its target, git commit --fixup=abc1234 creates a normal commit whose message git recognizes as “this belongs right after abc1234,” and --autosquash reads that convention and rearranges the rebase plan for you automatically. This is genuinely the workflow worth adopting once you’ve internalized manual squashing: catch a bug in an earlier commit while working on something else, git commit --fixup=<hash> immediately without breaking your current flow to go fix history by hand, and clean everything up in one --autosquash rebase right before opening the PR.


Handling Conflicts During Rebase

# When a conflict occurs:
git status                    # See conflicting files
# Edit files to resolve conflicts
git add resolved-file.ts      # Stage resolved files
git rebase --continue         # Continue to next commit

# Skip a commit that causes unresolvable conflict (rare)
git rebase --skip

# Abort entirely and return to pre-rebase state
git rebase --abort

The mental model that made rebase conflicts stop feeling overwhelming for me: a rebase applies commits one at a time, in order, and a conflict pauses the whole process at exactly one commit — you’re never resolving “the entire rebase’s conflicts” at once, only ever the diff for the single commit currently being replayed. Resolving it and running --continue moves to the next commit, which may or may not conflict independently; a rebase across 10 commits might only actually conflict on 2 of them, and each conflict is a small, self-contained problem rather than one big tangled one. --skip is worth using sparingly and deliberately — it discards the currently-conflicting commit’s changes entirely rather than resolving them, which is right only when you’ve concluded that commit’s change is now genuinely redundant (superseded by something already applied), not as a quick way to make an annoying conflict go away.

”ours” and “theirs” are swapped during a rebase

When you resolve a conflict by picking one side wholesale, the names are the opposite of what most people expect. In a merge, --ours is the branch you are on and --theirs is the branch being merged in. A rebase works by checking out the new base and replaying your commits onto it, so while it runs, “ours” is the upstream branch you are rebasing onto, and “theirs” is your own commit being replayed:

# During `git rebase main` on your feature branch:
git checkout --ours   path/file.ts   # takes main's version (the new base)
git checkout --theirs path/file.ts   # takes YOUR feature commit's version
git add path/file.ts && git rebase --continue

Grabbing --ours on autopilot during a rebase therefore silently throws away your own change to that file. The conflict markers follow the same logic: the HEAD section is the upstream code, and the section labeled with your commit’s message is yours. If you routinely pick sides this way, pause on the first conflict of each rebase and check which version is which with git diff, or let a merge tool with labeled panes show it.

For long-lived branches that are rebased repeatedly onto a moving main, enabling git config --global rerere.enabled true records how you resolved each conflict and reapplies the same resolution the next time the identical conflict appears. That turns “resolve the same conflict on every rebase” into a one-time job.


Rebase vs Merge

# Workflow: keep feature branch up to date with main
# Option 1: Merge (creates merge commit)
git checkout feature
git merge main
# History: f1 → f2 → merge(f2, m3) → f3

# Option 2: Rebase (linear history)
git checkout feature
git rebase main
# History: m1 → m2 → m3 → f1' → f2' → f3'
# (commits are replayed on top of main)

# Most teams use:
git fetch origin
git rebase origin/main    # Update feature branch
# Then: create PR → squash merge or regular merge into main

The actual tradeoff behind “rebase vs merge” is honesty about history versus readability of history, and reasonable teams land on different answers. Merge preserves exactly what happened, including the messy reality of when branches diverged and reconverged — genuinely valuable for a long-lived branch where knowing “these commits were integrated together, on this date, as this feature” matters. Rebase produces a clean, linear story that reads well in git log but is, in a strict sense, a fiction — the commits are replayed with new hashes as if they’d been written sequentially against the latest main, when they weren’t. Neither is objectively correct; it’s worth agreeing on one convention per team and sticking to it consistently, since a repo mixing both styles freely tends to end up with a history that’s confusing in both directions — not clean enough to read linearly, not accurate enough to trust as a literal record.


Safe Rebase Rules

# ✅ Safe: rebase commits that are only on your local branch
git rebase -i HEAD~5          # Local commits only

# ✅ Safe: rebase your feature branch onto main (before others pull it)
git rebase origin/main

# ⚠️ Risky: rebase commits you've pushed to a shared branch
# Anyone who pulled those commits will have diverged history
# If you must:
git push --force-with-lease   # Safer than --force (fails if remote was updated)

# ❌ Never: rebase main, develop, or any shared branch
git checkout main
git rebase feature            # DO NOT DO THIS

--force-with-lease over plain --force is worth understanding as a real safety mechanism, not a more-cautious-sounding synonym: plain --force overwrites whatever is on the remote unconditionally, which means if a teammate pushed a commit to that branch after you last fetched, --force silently destroys their work along with your rebase. --force-with-lease checks that the remote branch is still exactly where you last saw it before pushing, and refuses (rather than clobbering) if it’s moved — this is the difference between “I’m confident nobody else touched this branch” and “git has actually verified nobody else touched this branch,” and the gap between those two is exactly where I’ve seen force-pushes cause real, recoverable-but-painful incidents on a shared branch.


Undo a Rebase

# git reflog shows all recent HEAD positions — even after rebase
git reflog
# HEAD@{0}: rebase finished
# HEAD@{1}: rebase: Add auth routes
# HEAD@{2}: checkout: moving from main to feature
# HEAD@{5}: commit: last good state   ← find this

# Reset to pre-rebase state
git reset --hard HEAD@{5}

git reflog is the tool that made me genuinely trust rebase enough to use it freely, and it’s worth understanding what it actually is: a local, per-repository log of every position HEAD has ever pointed to — every commit, checkout, rebase step, and reset — which persists even after a rebase rewrites history, since the old commits aren’t immediately deleted, just no longer referenced by any branch. This is what makes “undo a bad rebase” a genuinely reliable operation rather than a hopeful one: find the entry from right before the rebase started (usually easy to spot by its message), hard-reset to it, and you’re back to exactly where you were — the safety net referenced at the start of this guide, made concrete.


Quick Reference

CommandWhat it does
pickKeep commit as-is
rewordKeep commit, edit message
editStop here to amend
squashCombine with previous, keep both messages
fixupCombine with previous, discard this message
dropDelete this commit
--autosquashAuto-arrange fixup! commits
--abortCancel rebase, restore original state
--continueAfter resolving conflict, continue
--skipSkip problematic commit