Interactive Rebase and Clean Commits
Turn a messy branch into commits a reviewer will thank you for. Every todo verb, splitting a commit, the --fixup and --autosquash workflow, and rebase --exec.
Turn a messy branch into commits a reviewer will thank you for. Every todo verb, splitting a commit, the --fixup and --autosquash workflow, and rebase --exec.
Your branch works. The tests pass. Then you open the pull request
and read back what you actually committed: wip, checkout form,
fix typo, actually fix typo. The code is fine; the history is
embarrassing, and the history is what a reviewer has to read.
Interactive rebase fixes that without losing a line of work. By the end of this lesson you can turn six scrappy commits into three deliberate ones — squashed, reordered, reworded, even split in half — and you will know the workflow most professionals use to keep a branch tidy while they write it.
A lab notebook.
Every dead end and half-thought, in the order it happened. Commit as messily and as often as you like — nobody is reading it.
An argument.
Here is the change, in the order that makes it make sense. One coherent change per commit, and every commit builds.
The distinction is not decoration. git bisect walks your history
one commit at a time hunting for the one that broke something — if
half your commits do not build, it lands on noise. git revert
undoes exactly one commit, which only helps if that commit is one
coherent thing rather than "wip" plus three unrelated fixes.
Interactive rebase is how you get from the first artefact to
the second.
Plain rebase replays your commits somewhere else. Interactive rebase replays them too, but first it hands you the running order and lets you edit it. Point it at the branch you will merge into:
git rebase -i mainOr count back a fixed number of commits:
git rebase -i HEAD~5Either way, a file opens in your editor — the todo list. Here it is for a branch with six messy commits:
pick a1b2c3d wip
pick e4f5a6b checkout form
pick c7d8e9f fix typo
pick 0a1b2c3 validate card number
pick 4d5e6f7 actually fix typo
pick 8a9b0c1 Wire the form to POST /api/orders
# Rebase 7e0d4c9..8a9b0c1 onto 7e0d4c9 (6 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 [-C | -c] <commit> = like "squash" but keep only the
# previous commit's log message, unless -C is used, in which
# case keep only this commit's message; -c is same as -C but
# opens the editor
# x, exec <command> = run command (the rest of the line) using
# shell
# b, break = stop here (continue rebase later with
# 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# create a merge commit using the original merge commit's
# message (or the oneline, if none was specified)
# u, update-ref <ref> = track a placeholder for the <ref> to be
# updated to this position in the new commits
#
# These lines can be re-ordered; they are executed from top to
# bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.You edit the verb at the start of each line, save, and close the
editor — in Vim, that is Esc then :wq. Git executes
the list from top to bottom.
There are eight verbs worth knowing, and you use three of them constantly.
pick keeps the commit as it is. Every line starts this way,
so leaving one alone means "no change."
reword keeps the changes and reopens the message in your
editor — for when the code is right but the sentence is asdf.
edit stops the rebase with that commit checked out, handing
you the shell. Amend it, split it, then git rebase --continue.
squash melds the commit into the one above and opens an
editor holding both messages to combine.
fixup melds it the same way but discards this message. This
is the everyday one: fix typo has nothing to say for itself.
drop removes the commit and its changes.
break takes no commit — on its own line it pauses the rebase
there so you can look around.
exec runs a shell command at that point; a non-zero exit
stops the rebase where it stands.
A todo list using most of them:
reword 51ac9d2 wip
fixup 6b0e447 fix typo
edit 9d3f81a add the cart summary
exec npm test
break
drop 2c7ab60 add debug loggingMoving a line reorders it, which is how a stray fix comes to
sit beside the commit it fixes. Deleting a line does the same
thing as drop.
Take the branch from earlier: six commits, three of them real work and three apologies.
git switch feature/checkout
git rebase -i mainThat reopens the todo list from above. Edit it to this — note
actually fix typo moved up two lines, so it lands beside the
form work it belongs to:
reword a1b2c3d wip
fixup e4f5a6b checkout form
fixup c7d8e9f fix typo
fixup 4d5e6f7 actually fix typo
reword 0a1b2c3 validate card number
pick 8a9b0c1 Wire the form to POST /api/ordersSave and close. Git stops twice for the rewordings, and
git log --oneline main..feature/checkout reports:
d3f21a7 Wire the form to POST /api/orders
b90c4e5 Validate the card number before enabling submit
5ae1f30 Add the checkout form and its layoutThree commits, each a complete thought, none mentioning typos. Same code, entirely different story.
Sometimes the problem is the opposite: one commit does two
unrelated things. Splitting it feels like magic and takes five
commands. Start a rebase and mark the offending commit edit:
git rebase -i HEAD~3The rebase stops with that commit applied and HEAD on it. Undo
the commit, keeping every change in your working tree:
git reset HEAD~Now stage the first coherent piece. git add -p walks the diff
hunk by hunk, asking y or n for each:
git add -p src/checkout/form.ts
git commit -m "Extract the price formatter"Commit whatever is left as the second commit, then hand control back to the rebase:
git add .
git commit -m "Format the order total in the summary panel"
git rebase --continueOne commit went in, two came out, and the rest of the branch replays on top of them.
Everything so far is repair work after the fact. The better habit is to label each mess as you make it and let Git sort them out later.
Spot a mistake in a commit you already made
Do not write fix typo. Find that commit's hash with
git log --oneline.
git commit --fixup=b90c4e5
Git writes the message for you: the literal prefix fixup!
plus the target commit's subject line. It sits harmlessly at
the tip of your branch.
Keep working, and keep labelling
Several fixups against several different commits is fine. Each one names its own target.
git rebase -i --autosquash main
Git reads those prefixes, matches each to the commit whose
subject it names, and hands you a todo list already reordered
with fixup filled in. Read it, confirm, save unchanged.
Concretely:
git log --oneline
git commit --fixup=b90c4e5The message Git writes is fixup! Validate the card number before enabling submit. git commit --squash=b90c4e5 does the same
with a squash! prefix, for when you do want the two messages
merged rather than the second one discarded.
Then, when the branch is ready:
git rebase -i --autosquash mainTo find the commit in your branch that does not build, have Git run your tests on every commit in it:
git rebase --exec "npm test" mainGit rebuilds the branch and runs npm test after each commit. The
first failure stops the rebase on the guilty commit, working tree
at exactly that point in history — fix it, git add, then
git rebase --continue. Without -i there is no list to edit:
Git writes one itself and runs it.
The other flag reaches the commit you otherwise cannot: rebase edits commits after a starting point, which leaves a repository's very first commit out of range.
git rebase -i --rootThat initial commit now appears at the top of the list like any
other, ready to reword.
A rebase in progress is a paused machine with three buttons. You met these in the plain-rebase lesson; they behave the same here.
git rebase --continue # resolved it, carry on
git rebase --skip # discard the commit being applied
git rebase --abort # put the branch back exactly as it was--abort is the one to remember: at any point while the rebase is
paused it puts your branch back on the commit it started from, as
though nothing happened.
Be careful with --skip — it does not skip the conflict, it drops
the commit being applied, and all of its changes leave your
branch. Use it only when those changes already exist further up
the branch.
Nothing is truly gone afterwards either: squashed and dropped
commits are still in the repo, and git reflog finds them. That
safety net gets a lesson of its own later on.
Two rules keep this useful rather than obsessive.
The first is about ownership. Tidying your own unpushed branch is
free and nobody can tell. Tidying a branch someone else has pulled
is the golden rule violation from the rebase lesson. Between them
sits your own pushed feature branch, where a rewrite plus
git push --force-with-lease is normal — right up until a
colleague starts building on it.
The second is how far to go. The bar is not "beautiful", it is "reviewable": one coherent change per commit, a message that says what the change does, and a commit that builds. If the next edit would only satisfy your own tidiness, you are done.
Every command from this lesson in one place:
git rebase -i main # edit commits since main diverged
git rebase -i HEAD~5 # edit the last five commits
git rebase -i --root # include the very first commit
git rebase -i --autosquash main # apply fixup!/squash! commits
git rebase --exec "npm test" main # test every commit
# verbs: pick reword edit squash fixup drop break exec
# moving a line reorders it; deleting a line drops the commit
git commit --fixup=<hash> # mark as a fix for <hash>
git commit --squash=<hash> # same, but keep this message too
git config --global rebase.autoSquash true # autosquash by default
# splitting a commit marked 'edit', before --continue
git reset HEAD~ # uncommit, keep the changes
git add -p <file> # stage one piece, hunk by hunk
git commit -m "First half" # commit it, then repeat for the rest
git rebase --continue # carry on after a stop
git rebase --skip # drop the commit being applied
git rebase --abort # undo the whole rebaseYou can now hand over a branch that reads like it was written by someone who knew where they were going — which, after a rebase, is exactly true.
The natural next step is moving work between branches rather than
reshaping it in place. Stashing work in progress covers the
changes you need to put down for ten minutes, and the lesson
after it takes up git cherry-pick for lifting a single commit
onto a different branch. Between them they cover almost every "I
committed that in the wrong place" moment.
First, though, practice where there are no stakes. Make a scratch branch, write five deliberately terrible commits, and rebase them into two. Do it a few times and the todo list stops being a wall of text and becomes what it is: a short program you write to rewrite your own history.