Git, the easy way: changing history using rebase—part II

June 17, 2020 | in Engineering
| By Or Kamara

Welcome back to the second part of how to work with git, the easy way!

In the previous article, we discussed the basics of Rebase as well as two common scenarios that all of us might encounter when working with git. You can find the first part here. In this part, we will go over four more common scenarios and some conclusions.

Let’s jump right in!

Common scenarios (and how to handle them!)—part II

Let’s continue where we left off the last time with some common scenarios where you might want to change your code history, and how to do it using Git:

1. Split an old commit into two (edit)

Let’s say you just realized you’ve made a mistake when merging 2 commits (see part I for more details), and you want to revert the squash of 17a73c1 into e2952e7, so the new file will be in a different commit. The solution here would be to run all the commits to the specific point you need to edit, stop, do the changes, and continue. Or in other words, use the edit option.

We’ll start by running git rebase -i master again, but this time with the edit option on the commit we want like to split (the 2nd commit, `6871683`):

pick 682c27f feat: 1st commit
edit 6871683 feat: 2nd commit
pick 1b2f5b1 feat: 3rd commit

After exiting the rebase page, we should see:

Stopped at 6871683...  feat: 2nd commit
You can amend the commit now, with

  git commit --amend '-S'

Once you are satisfied with your changes, run

  git rebase --continue

So, git basically ran the 1st and the 2nd commits, stopped, and now the ball is in our hands. We can do all the changes we want!

This is our git graph right now:

* 6871683 (HEAD) feat: 2nd commit* 682c27f feat: 1st commit
* 24aa86a (master) Init: Git History

As mentioned before, we basically want to break the 2nd commit into 2 different commits, one per file. The first step would be to reset the changes of the 2nd commit by running git reset --soft HEAD^. The reset command changes that commit the branch HEAD is currently pointing at. By using the --soft option, we can keep the index and the working directory, while all of the files changed between the original HEAD and the commit will be staged.

This is the new git status. As you can see, both of the files are stashed:

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
 modified:   b.txt
 new file:   b2.txt

Now, let’s take b2.txt from the staged files list by running git restore --staged b2.txt:

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
 modified:   b.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
 b2.txt

With only b.txt as a staged file, we can now commit the first part of the 2nd commit. Then we stage b2.txt and commit the second part of the 2nd commit:

❯ git commit -m "feat: 2nd commit - part 1"
❯ git add b2.txt
❯ git commit -m "feat: 2nd commit - part 2"

Our git graph now looks like this:

* 96127c6 feat: 2nd commit - part 2
* 93260e5 feat: 2nd commit - part 1
* 9882b2f feat: 1st commit
* 24aa86a (master) Init: Git History

The final step would be to continue with the rebase and run the rest of the commits by doing 

git rebase --continue. The final git graph should look like this:

* 09fe03e (HEAD -> feat/git-history) feat: 3rd commit
* 96127c6 feat: 2nd commit - part 2
* 93260e5 feat: 2nd commit - part 1
* 9882b2f feat: 1st commit
* 24aa86a (master) Init: Git History

2. Modify all the files in a specific commit (edit)

You’re probably all familiar with the next scenario—you pushed all of your code, perfectly split into multiple commits, but then you understand that you had forgotten to run linter on your code. As we explained earlier, the solution shouldn’t be another commit named fix linting but actually to fix the linting for each and every commit.

If the change is a minor one, we can use the squash option, but if there are multiple changes across multiple commits, it will be for the best to run our linting script 1-by-1 on every commit (or on the specific commits we want to fix). For that, we can use the edit option again (as we did in the previous example).

Let’s run git commit rebase -i again while using the edit option on the 1st commit (a commit with multiple files):

edit 9882b2f feat: 1st commit
pick 93260e5 feat: 2nd commit - part 1
pick 96127c6 feat: 2nd commit - part 2
pick 09fe03e feat: 3rd commit

As before, git stops after running the 1st commit:

Stopped at 9882b2f...  feat: 1st commit
You can amend the commit now, with

  git commit --amend '-S'

Once you are satisfied with your changes, run

  git rebase --continue

Now we can run our linter (e.g. npm run lint) and check the status of our repo:

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
 modified:   a.txt
 modified:   a1.txt
 modified:   a2.txt
 modified:   a3.txt
 modified:   a4.txt
 modified:   a5.txt

As you can see, the linter script changed the content of 6 files (a*.txt). The only thing to do now is to amend the current changes (by staging and committing the changes) and continue with the rebase:

❯ git add a*
❯ git commit --amend --no-edit
❯ git rebase --continue
Successfully rebased and updated refs/heads/feat/git-history.

And the git graph should be:

* 8f404fe (HEAD -> feat/git-history) feat: 3rd commit
* 7e37b6c feat: 2nd commit - part 2
* f71ad8e feat: 2nd commit - part 1
* b50f18f feat: 1st commit
* 24aa86a (master) Init: Git History

3. Make sure that tests pass for every commit (exec)

When splitting your code into multiple commits, you should remember that every commit should stand by itself. In other words, after merging multiple commits into one of the main branches, you should be able to jump back to each one of them, while expecting the code to compile / all the tests to pass.

So how can you validate that before merging the commits?

Let’s run the out tests on each and every commit manually! Just kidding, of course—we should do it automatically 🙂 for that, we can use the exec option.

This option basically helps us by running shell scripts as part of the rebase process. For that, we just need to add new lines with the exec command wherever we want. For example, let’s run again git rebase -i master :

pick b50f18f feat: 1st commit
pick f71ad8e feat: 2nd commit - part 1
pick 7e37b6c feat: 2nd commit - part 2
pick 8f404fe feat: 3rd commit

Now to the fun part—running the tests (by executing the script ./run_tests.sh) after each commit:

pick b50f18f feat: 1st commit
exec ./run_tests.sh
pick f71ad8e feat: 2nd commit
exec ./run_tests.sh
pick 7e37b6c feat: 2nd commit - part 2
exec ./run_tests.sh
pick 8f404fe feat: 3rd commit
exec ./run_tests.sh

And this is the output when exiting the rebase windows:

❯ git rebase -i master
Executing: ./run_tests.sh
All tests passed!
Executing: ./run_tests.sh
5 tests failed :( 108 tests passed!
Executing: ./run_tests.sh
All tests passed!
Executing: ./run_tests.sh
All tests passed!
Successfully rebased and updated refs/heads/feat/git-history.

As you can see, git executed the tests script after running each commit—so, now we know that something in the commit feat: 2nd commit fails the tests. It’s also possible to add a single exec command after each commit by running:

$ git rebase -i --exec "./run_tests.sh"

Last (actually first) thing to know—how to abort

So this is not a scenario, but more a warning—the rebase command might be dangerous! Using it without understanding the effects of the commands, might ruin your work. That’s why I’ll start with 2 recommendations:

  • Before you start playing with the commits, always make sure you have a proper backup of your git repo. The simplest thing I like to do is to push all of my changes to a remote branch. If something bad happens, I can always come back to my original code (using git reset).
  • Make sure you understand the commands. Instead of using them for the first time on your actual code, test them before on a temp git repo.

Keep in mind that you can always stop the rebase process, and return back to the original state:

  • On the initial rebase screen—just delete all of the commit lines, and nothing will change.
  • In the middle of the rebase process—if for some reason you got lost, and you’re not sure what to do, you can always git rebase --abort. This command stops the rebase process, and returns the state of your repository back to the original one (before running git rebase -i).

Summing up

As we saw in the last few examples, git rebase is a powerful command, specifically when using the interactive way. There is an endless list of cases where we can use it, but I really hope I’ve managed to give you a basic understanding of when each one of the options is useful.

The main thing I would like you to take from this article is to use git rebase smartly and bravely. I know that the beginning might be a bit frightening, and you probably want someone to hold your hand while pressing the enter key, but that’s totally OK. As long as you keep practicing, it will become more of a routine. What’s more, I’m sure there are many more scenarios, tailored to your specific use cases, where this command is useful.

Enjoy!