git rebase: an interactive tour

2020-08-07

 | 

~12 min read

 | 

2379 words

A few months ago I wrote about wanting to get more familiar with git rebase when I was learning to write better commits. I didn’t follow up on my initial enthusiasm and make it a habit however. As a result, I quickly forgot what I’d learned. To avoid making a similar mistake again, I am created a small project and took notes along the way. Below are my notes about what I learned using git rebase on the command line.

What Is Rebase?

Rebasing is an alternative strategy to merging to accomplish a similar aim, namely: to integrate work of divergent branches.

Unlike a merge, rebasing does not create a new commit, but instead reorders the commits as if they all occurred in linear fashion.

The “Basic Rebase” example in the Git Book does a wonderful job of illustrating this.

The divergent history looks like this:

                   c4
                ↙️
c0 <- c1 <- c2 <- c3

A merge will create a new commit (c5):

                    c4
                ↙️       ↖
c0 <- c1 <- c2 <- c3 <- c5

In contrast, the rebase will ultimately result in a linear history with a c4'

c0 <- c1 <- c2 <- c3 <- c4'

While the history is cleaner, there’s no other difference between the two approaches:

Now, the snapshot pointed to by C4' is exactly the same as the one that was pointed to by C5 in the merge example. There is no difference in the end product of the integration, but rebasing makes for a cleaner history.

Why Use A Rebase?

If rebasing and merging end up in the same place, why go through the trouble? There are a few good reasons to consider rebasing:

  1. Cleaner history helps to show the evolution of the project. Even if the work was done in parallel, by seeing a linear story, it’s often easier to piece how the parts came together.
  2. Maintenance is easier. When using rebasing, a maintainer of a project will not need to do any work besides “fast-forwarding” in order to integrate a change since the commits are all linear.

Starting A Rebase

Initiating a rebase can be done in two ways:

  1. Specifying the hash, git rebase <sha>
  2. Relative commit from HEAD, git rebase ~HEAD-3

What’s actually being referenced here though? From the Git book (emphasis added):

For example, if you want to change the last three commit messages, or any of the commit messages in that group, you supply as an argument to git rebase -i the parent of the last commit you want to edit, which is HEAD2^ or HEAD3. It may be easier to remember the ~3 because you’re trying to edit the last three commits, but keep in mind that you’re actually designating four commits ago, the parent of the last commit you want to edit:

$ git rebase -i HEAD~3

A Quick Intro To The Options

If you enter an interactive rebase (using the --interactive or -i options), you’ll be greeted with a number of commands that are available. They are:

# 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
# 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.
#
# 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.
#
# Note that empty commits are commented out

Okay, but what are some different ways to use git rebase? In the following sections I’ll walk through several examples using a project I bootstrapped using create-react-app.

Here’s a one-line summary of my commit history1:

gl

Which prints out my initial git history (this is what we’ll be changing!):

* 4ea1355 2020-07-15 | docs: add readme (HEAD -> master) [Stephen]
* 49cd904 2020-07-15 | fix: typo [Stephen]
* d3df7ac 2020-07-15 | refactor: clean up nav options [Stephen]
* 4fdcfc0 2020-07-15 | feat: add menu items to nav [Stephen]
* b9610ff 2020-07-15 | style: prettify nav bar [Stephen]
* 4019550 2020-07-15 | feat: add navbar [Stephen]
* ab33790 2020-07-15 | Initialize project using Create React App [Stephen]

Let’s get started.

Change Commit Order

The first change we’ll make is rearranging history. I want the README to be the very first commit after initialization. This will ensure that anyone who forks my project from its very earliest state will at least have the README.

To get started, we target the parent of the last commit we’ll be modifying. In our case, that’s the very first commit:

git rebase -i ab33790

This will open up a new interactive rebase editor:

interactive-rebase-editor
pick 4019550 feat: add navbar
pick b9610ff style: prettify nav bar
pick 4fdcfc0 feat: add menu items to nav
pick d3df7ac refactor: clean up nav options
pick 49cd904 fix: typo
pick 4955f91 docs: add readme

# Rebase ab33790..4955f91 onto d3df7ac (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 <commit> = like "squash", but discard this commit's log message
# 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.
#
# 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.
#
# Note that empty commits are commented out

Note: From here on out, I’ll be trimming the output to exclude the guidance on how to use git rebase.

In this case, I want to keep all of the commits, but rearrange them to tell a different story:

+ pick 4955f91 docs: add readme
pick 4019550 feat: add navbar
pick b9610ff style: prettify nav bar
pick 4fdcfc0 feat: add menu items to nav
pick d3df7ac refactor: clean up nav options
pick 49cd904 fix: typo
-  pick 4955f91 docs: add readme

Once I save (:wq), the console will alert me to whether it succeeded or not:

Successfully rebased and updated refs/heads/master.

I can confirm it by looking at my git log again:

gl

Which shows an updated git history:

* dd5e7f5 2020-07-15 | fix: typo (HEAD -> master) [Stephen]
* ede17f5 2020-07-15 | refactor: clean up nav options [Stephen]
* 615c83c 2020-07-15 | feat: add menu items to nav [Stephen]
* 1259aaa 2020-07-15 | style: prettify nav bar [Stephen]
* ce16478 2020-07-15 | feat: add navbar [Stephen]
* 0434a4f 2020-07-15 | docs: add readme [Stephen]
* ab33790 2020-07-15 | Initialize project using Create React App [Stephen]

Squashing Commits

Another use case for rebase is to squash commits. Say you have several commits all related to the same topic (in this example, the nav bar). This is a perfect use case for squashing - so let’s see how it works.

First - we’ll open our interactive editor going back to the beginning of the navbar. For variety, we’ll use the relative targeting:

git rebase -i HEAD~5^

We want the rearrange the last 5 commits, so we target the parent of the 5th. Alternatively, we can target the parent directly.

git rebase -i HEAD~6

This will open the interactive editor:

interactive-rebase-editor
pick 0434a4f docs: add readme
pick ce16478 feat: add navbar
pick 1259aaa style: prettify nav bar
pick 615c83c feat: add menu items to nav
pick ede17f5 refactor: clean up nav options
pick dd5e7f5 fix: typo

Ideally, we want just one clean commit for the nav bar - one that includes all of the styling, the refactoring, and any fixes. So, we make the following adjustments:

pick 0434a4f docs: add readme
pick ce16478 feat: add navbar
-  pick 1259aaa style: prettify nav bar
-  pick 615c83c feat: add menu items to nav
-  pick ede17f5 refactor: clean up nav options
-  pick dd5e7f5 fix: typo
+  squash 1259aaa style: prettify nav bar
+  s 615c83c feat: add menu items to nav
+  s ede17f5 refactor: clean up nav options
+  s dd5e7f5 fix: typo

NB I used the abbreviations here to show how you can use squash and s interchangeably.

Saving the editor here actually opens up a new editor where there’s an opportunity to rewrite the commit message (or maintain the commit messages):

interactive-commit-message
# This is a combination of 5 commits.
# This is the 1st commit message:

feat: add navbar

# This is the commit message #2:

style: prettify nav bar

# This is the commit message #3:

feat: add menu items to nav

a lengthy discussion of what actually went on to make these changes
might be written here to communicate to your future self and
teammates describing the rationale behind these modifications.

# This is the commit message #4:

refactor: clean up nav options

# This is the commit message #5:

fix: typo

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# Date:      Wed Jul 15 14:48:11 2020 -0700
#
# interactive rebase in progress; onto ab33790
# Last commands done (6 commands done):
#    squash ede17f5 refactor: clean up nav options
#    squash dd5e7f5 fix: typo
# No commands remaining.
# You are currently rebasing branch 'master' on 'ab33790'.
#
# Changes to be committed:
#       modified:   src/index.js
#

What’s notable here is that my commit messages in my test example are lacking a body, so much of the rich information that might otherwise be available in a well crafted commit message is not shown, though the add menu items is a notable exception.

If we make no changes and save, then pull up our git history one more time:

gl

Notice that the commit history has been rewritten!

* 9ee5463 2020-07-15 | feat: add navbar (HEAD -> master) [Stephen]
* 0434a4f 2020-07-15 | docs: add readme [Stephen]
* ab33790 2020-07-15 | Initialize project using Create React App [Stephen]

And, if we look at the full git log:

git log

Prints out the full history, including the combined commit messages:

commit 9ee54633699554980093522ca18f723940d27e96 (HEAD -> master)
Author: Stephen <stephen.c.weiss@gmail.com>
Date:   Wed Jul 15 14:48:11 2020 -0700

    feat: add navbar

    style: prettify nav bar

    feat: add menu items to nav

    a lengthy discussion of what actually went on to make these changes
    might be written here to communicate to your future self and
    teammates describing the rationale behind these modifications.

    refactor: clean up nav options

    fix: typo

commit 0434a4feed3d550a277ecaa6b4ababff2f6f5610
Author: Stephen <stephen.c.weiss@gmail.com>
Date:   Wed Jul 15 15:22:41 2020 -0700

    docs: add readme

commit ab337901b16eeac2cd03778311a4298e16320492
Author: Stephen <stephen.c.weiss@gmail.com>
Date:   Wed Jul 15 14:37:03 2020 -0700

    Initialize project using Create React App

Change Last Commit

While changing the last commit could be done with a rebase, if the target is the most recent commit, it’s actually simpler to do by using the amend option on the commit command:

git commit --amend

Conclusion

I find git rebase to be an extremely powerful tool that is valuable in a number of varied situations. Part of the difficulty in learning how to use it appropriately is just how versatile it is.

Many of the examples above were inspired by the manual, 7.6 Git Tools - Rewriting History as well as Sam Lindstrom’s “A Beginner’s Guide to Squashing Commits with Git Rebase” on Medium.

With that, I’ll leave with the warning from the Git Manual itself:

Note: Don’t push your work until you’re happy with it

One of the cardinal rules of Git is that, since so much work is local within your clone, you have a great deal of freedom to rewrite your history locally. However, once you push your work, it is a different story entirely, and you should consider pushed work as final unless you have good reason to change it. In short, you should avoid pushing your work until you’re happy with it and ready to share it with the rest of the world.

Said even more explicitly:

Do not rebase commits that exist outside your repository and that people may have based work on.

If you follow that guideline, you’ll be fine. If you don’t, people will hate you, and you’ll be scorned by friends and family.

As someone who likes to push and sync with a remote repository regularly, this is a great reminder about how it’s worth viewing them quite differently.

Footnotes

  • 1 gl is an alias for git log --pretty=format:'%h %ad | %s%d [%an]' --graph --date=short" in my .zshrc.

Related Posts
  • Conventional Commits With Emojis
  • Git Commit: Fixup And Squash Automatically
  • Git: Git Flow Basics
  • Git Rebase: Resolving Conflicts After Squash On Stacked Diffs
  • Git Rebase: Difference Between Squash And Fixup


  • Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!