Git, the easy way: changing history using rebase—part I
Lately, I was working on a big feature that included several changes in many areas of the code. While trying to keep the PR for this feature easy to read, I split all the functionality into several commits. As time went by, I quickly realized that those splits are not enough—there were lots of changes I had to do in each one of those commits (as part of some problems I found or CR fixes).
At that moment, I was already familiar with the basics of the magical git rebase interactive command and that you can use it to “rewrite your history”. But the changes I had to do were more complicated than my basic knowledge, so I started to dig into the options of this command.
For those of you who are not familiar at all with the command, this is the output after running
git rebase -i BRANCH:
pick 271c66e feat: 1st commit pick 51588eb feat: 2nd commit pick 4b339f2 feat: 3rd commit # Rebase 24aa86a..4b339f2 onto 24aa86a (3 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>] ...
I totally remember the first time I ran this command. My reaction was something like: Okayyyy, what’s going on here? Someone promised me an interactive and easy way to change my commits, but all I see is a very complicated interface… I literally needed to invest some time reading about each one of those options, cause after exiting this screen, I might lose all the work I’ve done (#ScaredDeveloper).
Even when I tried to exit the screen and cancel everything, that wasn’t an easy experience…. Had to google how to do it, and found that I need to drop all the commit lines and there will be no changes, so easy (and ugly). My first thought was “but wait, I don’t really trust everything mentioned on google, what if I do so and all of my work disappears? 😨”
I then decided there is no other choice here—and I started investigating those commands.
In this article, I would like to elaborate on different problems that developers need to solve when working on PRs with multiple commits. I won’t explain the bits and pieces of those commands, as there is a number of articles explaining what’s going on behind the scenes. Instead, I’ll give some scenarios that all of us might encounter when working with git and suggest the proper
git rebase -i solution.
Since this is quite an extensive topic, I will split this blog post in two parts—in this part I will go over the basics of Rebase as well as two common scenarios. In the second part, I’ll present four more common scenarios and some conclusions.
Without further ado, let’s dig into the first part!
Rebase in a nutshell
So let’s start by looking at the next scenario—both you and your friend are working on a new exciting project. You just finished creating the repo (+ doing a first commit called
A), and now it’s coding time. In order to make it more efficient, you both decided to split the work. You’re responsible for the main feature and your friend is responsible for building the framework of the app.
So, the starting point for both of you is the same—the commit
A. After 5 hours of intense work, you want to finish and merge the changes to master. Your friend is the first one to merge while creating 3 new commits on the top of
D. Now, it’s your turn to merge into master all of your work under the
G. This is how it looks like:
E----F----G FeatureBranch / A----B----C----D master
In order to test the functionality you’ve built, you need to use some functions from the framework, more specifically, from commit
D. In other words, you need to change the base of your branch from commit
A to commit
D, just as if you created your branch from the latest:
E----F----G FeatureBranch / A----B----C----D master
This flow is exactly why git rebase is needed—to move a sequence of commits to a new base commit. Behind the scenes, git creates new commits and applies them to the specified new base.
More specifically, in this article, we’re going to talk about the interactive mode.
Running rebase with the
-i flag gives you the opportunity to alter individual commits as part of the basic rebase process—this is where the magic begins! Using the interactive mode, you can decide what is the desired action to take on each one of your commits—merge / split / rename / reorder and more.
We’ll try to understand each one of those options by taking common scenarios where git rebase might be useful.
Common scenarios (and how to handle them!)
Let’s take a look at some common scenarios where you might want to change your code history, and how to do it using Git:
1. Change an old commit message (reword)
So we created several code changes, split perfectly into different commits, but then we noticed that there is a typo in one of the commit messages:
* 3d320d9 (HEAD -> feat/git-history) feat: 3rd commit * 03d03e9 feat: 3nd commit * 271c66e feat: 1st commit * 24aa86a (master) Init: Git History
As you can probably see, the message of the 2nd commit is
3nd commit (oopsy)—let’s fix that.
If the typo was part of the last commit, we could have used the
git commit --amend command. But as the typo is in the middle of our git graph, we need to do something more complicated.
In this case, we’ll use the
git rebase -i master command (
master can be a different branch) and pick the
reword option for the 2nd commit (
pick 271c66e feat: 1st commit reword 03d03e9 feat: 3nd commit pick 3d320d9 feat: 3rd commit
After saving the file, a new editor will open for us with an option to edit the commit message:
feat: 3nd commit # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # Date: Sun May 17 16:28:13 2020 +0300 # # interactive rebase in progress; onto 24aa86a # Last commands done (2 commands done): # pick 271c66e feat: 1st commit # r 03d03e9 feat: 3nd commit # Next command to do (1 remaining command): # pick 3d320d9 feat: 3rd commit # You are currently editing a commit while rebasing branch 'feat/git-history' on '24aa86a'. # # Changes to be committed: # modified: b.txt #
Next thing for us is to change the first line to the new commit message that we want,
feat: 2nd commit, and save again.
The new git graph is fixed now:
* fdb5f71 (HEAD -> feat/git-history) feat: 3rd commit * e2952e7 feat: 2nd commit * 271c66e feat: 1st commit * 24aa86a (master) Init: Git History
2. Change the content of an old commit (squash, fixup)
You just got CR comments from your buddy, and there are small changes you need to do—add a new file and change the content of an existing one.
As you probably know, each commit should represent a block of logic, not just changes of files. One of the things we should avoid is to have a commit message like
CR fixes or even
really small CR fixes in our case. The preferred way is to squash all the commits together so, in the end, each commit has all the relevant changes (including the CR fixes).
How should we do that? This is where the squash / fixup flags come to help.
We should start by creating temp commits with the required changes:
* 17a73c1 (HEAD -> feat/git-history) CR fix: add new file to 2nd commit * 5641a86 CR fix: content of file in 1st commit * fdb5f71 feat: 3rd commit * e2952e7 feat: 2nd commit * 271c66e feat: 1st commit * 24aa86a (master) Init: Git History
In the example above we created 2 new commits:
5641a86—changes the content of the file we created on the 1st commit
17a73c1—adds a new file to the 2nd commit
But now, we want to avoid keeping the git graph looking like that, and to squash
e2952e7. For that, we should run the
git rebase -i master command. Like the last time, this is the first screen we’ll see:
pick 271c66e feat: 1st commit pick e2952e7 feat: 2nd commit pick fdb5f71 feat: 3rd commit pick 5641a86 cr fix: content of file in 1st commit pick 17a73c1 cr fix: add new file to 2nd commit
Now, let’s change the order of the lines according to the desired order of commits, and use the
squash option on the commits
pick 271c66e feat: 1st commit squash 5641a86 cr fix: content of file in 1st commit pick e2952e7 feat: 2nd commit squash 17a73c1 cr fix: add new file to 2nd commit pick fdb5f71 feat: 3rd commit
As a result of those changes, git will start running on the commits (from
fdb5f71), and melt the content of commits marked with squash into the previous commit. The git graph now looks exactly like we wanted!
* 1b2f5b1 (HEAD -> feat/git-history) feat: 3rd commit * 6871683 feat: 2nd commit * 682c27f feat: 1st commit * 24aa86a (master) Init: Git History
Another option we can use instead of
fixup. Those 2 are doing pretty much the same thing, except
fixup discards the commit’s log message.
One important note—sometimes, as part of squashing commits together, we might cause code conflicts. When git discovers conflicts, you’ll be asked to fix them, stage the changes and continue with the rebase process by running
git rebase --continue. Keep in mind that you can always run
git rebase --abort if you’re not sure what to do. This command stops the rebase process, and returns the status of your repository back to the original one (before running
git rebase -i).
That’s it for the first part of how to change your git history using Rebase! 😁 In the second part, we’ll go over four more common scenarios and offer suggestions on how to deal with them.