GitHub

Commit Rebasing

Cleaning up commit history with interactive rebase

Rebasing means replaying a series of commits onto a different base commit. In an interactive rebase you also get to rewrite that series as it's replayed, which lets you combine related commits, fix bad messages, or drop commits entirely. Combining several commits into one is called squashing, and it's the most common reason we reach for a rebase. Let's say we have some commits titled like so:

Note

This is typically reserved for large branches with many commits that could use clarifying.

Terminal
$ git log --oneline
log
b592d03 feat: finalized constant
1e8fa47 feat: changed constant once more since it was wrong
c03d5ea feat: changed constant again since it was wrong
7f2b91c feat: changed constant since it was wrong
a46c438 feat: added a constant needed for motors
3b44697 style: renamed enum for individual motors to better fit

These are pretty useless commit messages and can confuse anyone looking at the history. So what we do is an interactive rebase. If you only need to change the last N commits, do this.

Warning

Rebasing rewrites history. The commits you rebase are replaced by new commits with new hashes. The originals aren't deleted, but they're no longer on your branch.

If someone branched off your branch, their work is now based on commits that aren't in your history anymore. Nothing of theirs is destroyed, but they'll get duplicated commits and messy conflicts the next time they merge.

Reach out to anyone working off your branch and make sure they aren't mid-change before proceeding.

Rebasing a few commits

First, make sure your working tree is clean. Rebase refuses to start when you have uncommitted changes, and staging them is not enough, git add doesn't help here. Running git status needs to come back empty, so either commit your work or stash it.

Terminal
# Stash uncommitted work if you aren't ready to commit it
$ git stash

# ...do the rebase, then bring your changes back
$ git stash pop

If you try to rebase with a dirty tree you'll get this.

log
error: cannot rebase: Your index contains uncommitted changes.
error: Please commit or stash them.

Let's say we only want to change the last N commits. Run this:

Terminal
$ git rebase -i HEAD~N

The terminal will open into your preferred text editor of choice. If it opens something you don't recognize, git is falling back to a default - set yours with git config --global core.editor "nvim".

log
pick a46c438 # feat: added a constant needed for motors
pick 7f2b91c # feat: changed constant since it was wrong
pick c03d5ea # feat: changed constant again since it was wrong
pick 1e8fa47 # feat: changed constant once more since it was wrong
pick b592d03 # feat: finalized constant

# Rebase 3b44697..b592d03 onto 3b44697 (5 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 no original merge commit was
#         specified); use -c <commit> to reword the commit message
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
#                       to this position in the new commits. The <ref> is
#                       updated at the end of the rebase
#
# 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 only edit the command word at the start of each line. Everything after the commit hash is just the subject line for your reference - git ignores it, so don't worry about keeping it tidy. Let's say we want to take all of these commits and squash them into one, and also throw away 1e8fa47 completely.

log
pick a46c438 # feat: added a constant needed for motors
squash 7f2b91c # feat: changed constant since it was wrong
squash c03d5ea # feat: changed constant again since it was wrong
drop 1e8fa47 # feat: changed constant once more since it was wrong
squash b592d03 # feat: finalized constant

That leaves you with a single commit containing the work from a46c438, 7f2b91c, c03d5ea, and b592d03. The changes from 1e8fa47 are gone entirely.

The lines execute top to bottom, and each squash melds into the nearest pick above it. So to end up with 2 distinct commits, use a second pick to start the next group.

plaintext
pick a46c438 # feat: added a constant needed for motors
squash 7f2b91c # feat: changed constant since it was wrong
pick c03d5ea # feat: changed constant again since it was wrong
drop 1e8fa47 # feat: changed constant once more since it was wrong
squash b592d03 # feat: finalized constant

This gives you exactly two commits:

  • the first holds a46c438 + 7f2b91c
  • the second holds c03d5ea + b592d03

1e8fa47 is discarded outright - it doesn't get squashed into anything. Note that a drop sitting between a pick and a squash doesn't break the group; b592d03 still melds into c03d5ea.

Note

The first line can never be a squash or fixup - there has to be a pick above it to meld into. If you get this wrong, git stops with error: cannot 'squash' without a previous commit, and you can fix it with git rebase --edit-todo.

squash vs fixup

Both combine commits - they only differ in what happens to the commit message.

CommandWhat it does to the message
squashOpens an editor with all the messages so you can write the final one
fixupSilently keeps only the message of the pick it melds into

Tip

Since the whole point here is usually to get rid of messages like "changed constant again", fixup is often what you actually want. Use squash when you want to write a proper combined message by hand.

You can also use reword to fix a single commit message without combining anything.

Rebasing an entire branch

The warnings above still apply. A good habit is to do the rebase on a throwaway copy of your branch first, so that if it goes badly, your original branch is untouched.

Terminal
# Switch to working branch
$ git switch feat/motor-interface
# Make a new branch off that working branch
$ git switch -c feat/motor-interface-rebase
# Rebase every commit that isn't already on main
$ git rebase -i main

The todo file looks exactly the same and is still ordered oldest to newest, so all the techniques above carry over unchanged. There is one important difference from HEAD~N though.

git rebase -i HEAD~N keeps your branch where it is and only rewrites the last N commits. git rebase -i main also moves your base, replaying your commits on top of whatever main currently points at. That means you pick up everyone else's recent work in the same operation, and it's where most conflicts come from. If you only want to clean up messages and not move the base, use HEAD~N.

Once the rebase looks right on the copy, get it back onto your real branch.

Terminal
# Check the history is what you expect
$ git log --oneline
# Move your real branch to the cleaned up history
$ git switch feat/motor-interface
$ git reset --hard feat/motor-interface-rebase
# Clean up the copy
$ git branch -d feat/motor-interface-rebase

Tip

Alternatively, just open the pull request from feat/motor-interface-rebase and leave the original branch alone entirely - this is the safest option if other people are branched off it.

When the rebase stops partway

Rebases stop as soon as a commit doesn't apply cleanly, which happens a lot, especially if you dropped or reordered commits that touch the same lines. Git will tell you which commit failed.

Terminal output
CONFLICT (content): Merge conflict in src/can/MIT_frame.cpp
error: could not apply b592d03... feat: finalized constant
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".

This is normal and not a sign you broke anything. You have three options.

Terminal
# Fix the conflict markers in the listed files, stage them, then carry on
$ git add src/can/MIT_frame.cpp
$ git rebase --continue
# Or throw away just the commit that won't apply
$ git rebase --skip
# Or give up and put everything back exactly how it was
$ git rebase --abort

git rebase --abort is always safe - it returns your branch to the state it was in before you started. If you're unsure what's going on, abort and start over rather than pushing something half finished.

Pushing after a rebase

Because rebasing gives every rewritten commit a new hash, your local branch and the remote branch have genuinely different histories. A normal git push will be rejected.

Terminal
$ git push --force-with-lease

Danger

Use --force-with-lease, not --force. --force-with-lease refuses to push if someone else has pushed to that branch since you last fetched, so you can't silently wipe out a teammate's work. Plain --force will happily destroy it.

Never rebase or force push main, or any branch that other people have open pull requests against.

If you make a mistake

Rebases are recoverable. Git keeps a log of everywhere HEAD has been for about 90 days, even for commits that aren't on any branch anymore.

Terminal
# Find the commit you were on before the rebase
$ git reflog

# Go back to it
$ git reset --hard <hash-from-reflog>

git reset --hard ORIG_HEAD is a shortcut that usually works immediately after a bad rebase. The one thing reflog can't bring back is uncommitted work destroyed by git reset --hard, so commit or stash before you start.

On this page