Blog by Railsware

New Level of Control with git rebase ‑‑onto

Git rebase is a great tool to rearrange your commit history and make it tidier. But during my long history of using it, I collected a bunch of use cases that were hard to handle with a usual rebase. It turns out that rebase has a little-known option ‑‑onto which was a game changer for me.

In a nutshell, git rebase ‑‑onto allows you to move or copy a bunch of commits from one branch atop another. You can treat it as a git cherry-pick on steroids. Without further ado, let’s see it in action.

What’s the problem with rebase

Imagine, there is a branch feature1, that was created by your colleague. Your goal is to add some new stuff to it. You happily branched off of feature1 and implemented new functionality in commits C and D. Here is what Git history looks like:

A --- B feature1
       \     
        C --- D feature2

While you worked on your stuff, your colleague added some new functionality in feature1. But they also like to use rebase, so they modified commit B a bit. On the diagram below, it’s named B*. Here is what the working tree looks like now:

A --- B* --- E --- F feature1
 \     
  B --- C --- D feature2

You need to catch up with their changes, so you decided to rebase:

git rebase feature1

And now the bummer – you caught a conflict between B and B* and need to resolve it. Sound familiar?

Solving the issue with git rebase ‑‑onto

Before blaming your colleague, you need to realize that the issue is not with the change itself. The issue lies in the way standard rebase does its job. In the example above, rebase takes feature1 as a starting point and then tries to apply all new commits atop.

In other words, it takes commit F as a new starting point. Then, it finds the place where both branches have diverged. In our case, it’s commit A. So, rebase takes commits B, C, and D, and one by one, tries to add them on top of commit F. The diff for commit B cannot be applied to existing source tree and thus we need to resolve the conflict.

Ideally, we want to avoid resolving the conflict and just get the next history tree:

A --- B* --- E --- F feature1
                    \     
                     C' --- D' feature2

(Notice: C‘ and D‘ have the same changesets as C and D but we updated the naming to reflect changes in SHA)

So, we want to take commits C and D and attach them on top of feature1, ignoring the previous history altogether. This can be achieved by git cherry-pick, but it’s a tedious and error-prone job.

Instead, we can use git rebase ‑‑onto. We need to be on feature2 and execute this command:

git checkout feature2
git rebase --onto feature1 B

That means: take all commits after commit B (in our case it will be C and D) and place them atop of feature1.

After applying this command, we’ll get the desired working tree without the need to resolve the conflict between B and B*. We can still have a conflict if C or D conflicts with B*, E, or F, but that’s a normal case.

Instead of providing B as the second argument, we can use relative addressing with HEAD~N. For our example it will be:

git checkout feature2
git rebase ‑‑onto feature1 HEAD~2

That can be read as: take two last commits from my branch and put them atop of feature1.

Solving the problem of branching from a wrong place

There are many cases when you can start your work in the wrong place or for some reason you need to move your commits to a totally different place.

Here’s a widespread example. You branched off of main branch, but suddenly a product manager tells you that your stuff is critical for production and you need to change the base of your branch to hotfix instead. Here is a commit history:

       E --- F --- G feature
       /
A --- B main
 \
  C --- D hotfix

You need to get the next history instead:

A --- B main
 \
  C --- D hotfix
         \
          E' --- F' --- G' feature

(Notice: E', F', and G' have the same changesets as E, F, and G but we updated the naming to reflect changes in SHA)

If you use regular rebase, commit B will sneak into hotfix branch. In practice, main branch can have dozens of other features merged and you would bring all of them into hotfix.

But git rebase ‑‑onto does not have this issue. Here is a solution:

git checkout feature
git rebase ‑‑onto hotfix B

or the same with relative addressing:

git checkout feature
git rebase ‑‑onto hotfix HEAD~3

Actually, almost all cases of moving around commits can be handled with ‑‑onto option.

Dropping commits

For the sake of completeness, let’s discuss how to remove commits with ‑‑onto option. Here is an example of a working tree:

A ‑‑‑ B ‑‑‑ C ‑‑‑ D ‑‑‑ E  feature

We want to remove commits C and D to obtain the next commit history:

A ‑‑‑ B ‑‑‑ E'  feature

(Notice: E and E’ have the same changeset, but E' has another SHA due to a change in commit history)

Here is the solution:

git checkout feature
git rebase ‑‑onto B HEAD~1

A word of caution

Using rebase always has the risk of doing something wrong and losing your commits. If you are still new to this tool, be sure to follow the usual safety measures: keep SHA of the HEAD of your branch before doing rebase or be fluent with using git reflog.

Conclusion

As I said, git rebase ‑‑onto greatly improved my commit housekeeping experience and removed a lot of pain points I had before. Despite the logic of ‑‑onto option being quite simple, it took me some time to find a proper way and practical understanding of how to use it in various scenarios.

So I hope this article will save you some time and extend your Git tricks collection.

Exit mobile version