Skip to content

Interactive Rebase in Git: Reshape Your Commit History

Lesson 2 of 7Intermediate → Advanced12 min readModern Git Workflows · RebasingVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

Interactive rebase is an ordinary rebase where you get to edit the instructions first. Git shows you the list of commits it is about to replay; you change the list; Git executes what you wrote.

That is the entire concept. Everything people do with it — squashing, rewording, reordering, splitting, dropping — is a matter of changing lines in a text file.

Terminal window
git rebase -i HEAD~4

HEAD~4 names the commit before the range you want to edit — the four commits after it are the ones listed. To include the very first commit in the repository, use --root; HEAD~N where N is the total number of commits refers to the root’s parent, which does not exist:

fatal: invalid upstream 'HEAD~4'

You can also specify a branch, which is usually clearer:

Terminal window
git rebase -i main

That lists every commit on your branch that is not on main — almost always exactly the set you want.

Git opens your editor with something like this:

pick de4df70 Add parser
pick 1aff95b fix typo
pick c413def Add validation
pick f195a48 WIP debug output
# Rebase 9fc05fd..f195a48 onto 9fc05fd (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 [-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
# ...
# These lines can be re-ordered; they are executed from top to bottom.

Two things to notice immediately:

Oldest first. This is the opposite of git log. The commit at the top is replayed first.

The lines are instructions. Change the verb at the start of a line to change what happens to that commit. Reorder the lines to reorder the commits. Delete a line to drop that commit entirely.

CommandShortEffect
pickpUse the commit unchanged
rewordrUse it, but open an editor for the message
editeStop after applying it, so you can amend the content
squashsCombine into the previous commit; both messages offered for editing
fixupfCombine into the previous commit; discard this one’s message
dropdRemove the commit
execxRun a shell command at this point
breakbStop here; resume with --continue
label / reset / mergel / t / mReconstruct branch topology, used by --rebase-merges
update-refuMove another branch ref to this point, used by --update-refs

Deleting a line does the same thing as drop. Being explicit is safer: an accidentally deleted line is indistinguishable from a deliberate one, and Git will happily discard a commit you meant to keep.

A branch with four commits, two of which should not survive review:

Terminal window
git log --oneline
f195a48 WIP debug output
c413def Add validation
1aff95b fix typo
de4df70 Add parser
9fc05fd Initial commit

The intent: fold the typo fix into “Add parser”, drop the debug commit, and give the validation commit a better message.

Terminal window
git rebase -i HEAD~4

Edit the list to:

pick de4df70 Add parser
fixup 1aff95b fix typo
reword c413def Add validation
drop f195a48 WIP debug output

Save and close. Git replays, stops to let you reword, and finishes:

Successfully rebased and updated refs/heads/main.
Terminal window
git log --oneline
00039a8 Add input validation
b02ce4b Add parser
9fc05fd Initial commit

Two commits instead of four, both meaningful. The typo fix is folded into the parser commit — verify it is genuinely there rather than lost:

Terminal window
git show HEAD~1:parser.py
p
typo fix

reword changes a message. edit stops the rebase so you can change the commit itself.

edit b02ce4b Add parser
pick 00039a8 Add input validation

Git applies the commit and then stops:

Stopped at b02ce4b... Add parser
You can amend the commit now, with
git commit --amend
Once you are satisfied with your changes, run
git rebase --continue

git status confirms where you are:

interactive rebase in progress; onto 9fc05fd
Last command done (1 command done):
edit b02ce4b Add parser
Next command to do (1 remaining command):

Now make changes and fold them in:

Terminal window
echo "more" >> parser.py
git add parser.py
git commit --amend --no-edit
git rebase --continue
Successfully rebased and updated refs/heads/main.

A commit that does two unrelated things can be divided. Mark it edit, then undo the commit while keeping its changes:

  1. Mark it for editing in the todo list.

  2. When Git stops, reset the commit but keep its changes unstaged:

    Terminal window
    git reset HEAD^

    A plain reset — no --hard — moves the branch back one commit and leaves the working tree alone. The changes are now uncommitted.

  3. Stage and commit the pieces separately:

    Terminal window
    git add parser.py
    git commit -m "Add parser"
    git add validate.py
    git commit -m "Add validation"

    git add -p is useful here if the two changes are in the same file.

  4. Continue:

    Terminal window
    git rebase --continue

Reordering is just moving lines. To make the validation commit come first:

pick c413def Add validation
pick de4df70 Add parser

Git replays them in the new order. Whether that works depends on whether the commits are independent — if the validation commit modifies code the parser commit introduces, replaying it first will conflict or fail. Reordering Commits covers dependency handling.

exec runs a shell command after the commit above it. The rebase stops if the command exits non-zero.

pick de4df70 Add parser
exec npm test
pick c413def Add validation
exec npm test

This is the practical way to verify that every commit on a branch builds — which matters if your team relies on git bisect, or integrates with rebase-and-merge so individual commits land on main.

There is a shortcut that adds an exec after every commit automatically:

Terminal window
git rebase -i --exec "npm test" main

Deciding which commit a fix belongs to is easier at the time than later. --fixup records the intent in the commit itself.

Terminal window
git commit --fixup=1d51084

This creates a commit whose message is fixup! Add parser — a marker naming its target.

Later, --autosquash reorders the todo list automatically:

Terminal window
git rebase -i --autosquash HEAD~4
pick 1d51084 Add parser
fixup 3b54d21 fixup! Add parser
pick febcef5 Add validation
pick f347f23 Add tests

Git moved the fixup commit next to its target and changed pick to fixup. Save without editing and the history collapses correctly.

Enable it permanently:

Terminal window
git config --global rebase.autoSquash true

git commit --squash=<commit> is the equivalent for squash rather than fixup, keeping both messages.

git commit --amend only reaches the most recent commit. Interactive rebase reaches any commit, as long as you include it in the range.

To fix the third commit back:

Terminal window
git rebase -i HEAD~3

Mark it edit, make the change, git commit --amend, then --continue. Every commit after it is replayed on top, so they all get new IDs too — a change to one commit necessarily rewrites all its descendants.

Two shortcuts avoid counting commits:

Terminal window
git rebase -i <commit>^ # edit <commit> and everything after it
git rebase -i main # edit every commit not on main

The first is the useful one when you know which commit is wrong: append ^ to name its parent, which is where the range must start.

The todo list is a file you can edit mid-rebase

Section titled “The todo list is a file you can edit mid-rebase”

While a rebase is paused, the remaining plan lives in the repository and can be changed:

Terminal window
git rebase --edit-todo

This reopens the editor on the remaining commands. Useful when a conflict reveals that your original plan was wrong — you can drop a commit you now know is redundant, or convert a pick into an edit so you can fix something at that point.

After editing, continue as normal:

Terminal window
git rebase --continue

You can also see where you are without changing anything. During a rebase Git provides two extra refs:

RefNames
REBASE_HEADThe commit currently being applied
ORIG_HEADWhere your branch pointed before the rebase started
Terminal window
git show REBASE_HEAD # what am I resolving?
git log --oneline ORIG_HEAD # what did the branch look like before?

git show REBASE_HEAD is the command to run before considering --skip — it tells you precisely which commit’s work you would be discarding.

Rebasing onto main versus reshaping in place

Section titled “Rebasing onto main versus reshaping in place”

Two different jobs use the same command, and mixing them makes conflicts harder to reason about.

Reshaping in place — squashing, rewording, reordering — with no change of base:

Terminal window
git rebase -i HEAD~5

The commits replay onto the same parent they already had. Conflicts here are only between your own commits, which is unusual and usually means the reorder was invalid.

Rebasing onto a newer base, optionally with reshaping:

Terminal window
git rebase -i main

Now you are doing both at once: moving onto main’s current tip and editing the plan. Conflicts may come from either source, and telling them apart mid-rebase is harder.

SituationCommand
Cancel the whole rebasegit rebase --abort
Change the remaining plan mid-rebasegit rebase --edit-todo
A commit is now emptygit rebase --skip
Finished, but the result is wronggit reset --hard <pre-rebase-id>

git rebase --abort restores the branch exactly. It always works while the rebase is in progress.

Once the rebase has finished, the reflog is the way back:

Terminal window
git reflog
eee8f01 HEAD@{0}: rebase (finish): returning to refs/heads/feature
84b5d12 HEAD@{1}: rebase (pick): D: first feature commit
68cbf80 HEAD@{2}: rebase (start): checkout main
a1d07c2 HEAD@{3}: commit: E: second feature commit

HEAD@{3} is the pre-rebase tip:

Terminal window
git reset --hard HEAD@{3}

Before opening a branch for review:

  1. See what you have.

    Terminal window
    git log --oneline main..HEAD
  2. Back it up.

    Terminal window
    git branch backup-$(git rev-parse --abbrev-ref HEAD)
  3. Reshape.

    Terminal window
    git rebase -i main

    Fold fixups into their targets, drop debugging commits, reword anything unclear, and order the commits so they read as a sequence of deliberate steps.

  4. Verify every commit builds.

    Terminal window
    git rebase --exec "make test" main
  5. Check the net change is unaltered.

    Terminal window
    git diff backup-feature HEAD

    This should print nothing. Reshaping history must not change the code.

  6. Push.

    Terminal window
    git push --force-with-lease
  7. Delete the backup once review is under way.

Step 5 is the safety check that makes the whole routine trustworthy: an empty diff proves you reorganised the history without altering the outcome.

Interactive rebase gives you the ability to reshape a branch. It does not tell you what shape to aim for.

A useful target: each commit should be a change a reviewer could evaluate on its own, and the sequence should read as the order in which someone would sensibly have done the work.

That usually means:

Separate mechanical changes from behavioural ones. A commit that renames a function across forty files is trivially reviewable. The same rename mixed with a logic change is not, because the reviewer has to find the real change among the noise.

Introduce before you use. Add the new helper in one commit, call it in the next. A reviewer reading forwards never encounters something undefined.

Keep refactors reversible. A commit that only moves code should produce no behaviour change, and it is worth saying so in the message so a reviewer knows what to check.

Delete in its own commit. Removing the old implementation after the new one is in place makes the switch obvious and the revert easy.

One reason per commit. If the message needs “and”, consider splitting.

A branch reshaped this way is faster to review, and its commits remain useful years later when someone runs git blame on a line and wants to know why it looks like that.

Rebasing a shared branch. The one with the widest impact.

Deleting a line by accident. Deleting drops the commit. Use drop explicitly so intent is visible.

Squashing in the wrong direction. squash and fixup fold into the commit above. A fixup on the first line has nothing to fold into and Git will refuse.

Using git reset --hard HEAD^ when splitting. Discards the changes you were splitting.

Forgetting the range excludes its endpoint. git rebase -i HEAD~3 edits three commits, starting from the one after HEAD~3.

Rewriting during review. Comments lose their anchors.

Not verifying afterwards. Run step 5. An empty diff against your backup is cheap certainty.

Reaching for --skip on a conflict. It drops the commit. Only use it when the change is genuinely already applied.

Interactive rebase is a script Git writes for you and lets you edit.

Git lists the commands it intended to run — pick each commit in order. You rewrite the script: change verbs, reorder lines, delete some. Then Git runs your version instead of its own.

The commits it produces are all new. You are not editing history; you are writing a new history that tells a better story about the same changes.

  • Interactive rebase shows the replay plan as an editable todo list, oldest commit first.
  • pick, reword, edit, squash, fixup and drop cover almost all real use.
  • squash combines messages; fixup discards the second one.
  • edit stops so you can amend, add commits, or split one commit into several with git reset HEAD^.
  • Reordering is moving lines, and only works when the commits are independent.
  • exec runs commands between commits; --exec adds one after every commit.
  • --fixup plus --autosquash records fold-in intent at commit time and applies it later.
  • --abort cancels; the reflog or a backup branch recovers after completion.

Build a messy branch and clean it up. Disposable repository only.

  1. Create the repository and four commits, deliberately messy:

    Terminal window
    mkdir ~/rebase-lab && cd ~/rebase-lab && git init
    echo "# Demo" > README.md && git add . && git commit -m "Initial commit"
    echo p > parser.py && git add . && git commit -m "Add parser"
    echo "typo fix" >> parser.py && git commit -am "fix typo"
    echo v > validate.py && git add . && git commit -m "Add validation"
    echo debug >> validate.py && git commit -am "WIP debug output"
  2. Back it up: git branch backup.

  3. Look at the plan without changing anything: git rebase -i HEAD~4, then quit your editor without saving. Confirm git log --oneline is unchanged.

  4. Now reshape. Run it again and set the list to:

    pick <id> Add parser
    fixup <id> fix typo
    reword <id> Add validation
    drop <id> WIP debug output
  5. Verify the fixup landed: git show HEAD~1:parser.py should contain both lines.

  6. Verify nothing else changed. git diff backup HEADpredict the output before running it.

  7. Practise recovery: git reset --hard backup and confirm all four commits return.

  8. Try edit: rebase again, mark the first commit edit, add a line, git commit --amend --no-edit, then git rebase --continue.

Step 6 should print nothing. If it does not, the reshape changed the code as well as the history — which is exactly the mistake this check catches.

Reordering deserves its own treatment, because commit dependencies make it the operation most likely to conflict.