git rebase --onto and other fun

2021-03-04

 | 

~8 min read

 | 

1402 words

Update I wrote a follow up to this walking through similar examples in Git Rebase On Rebased Branches and Other Fun, Redux.

Recently, I ran into a bit of a pickle recently when I decided to plow ahead with new feature work before the work I’d just finished had been incorporated into the main branch.

The issue isn’t so much that I cut another branch (Feature B) off a feature branch (Feature A) - it’s that the Feature A eventually needed to be modified and we rebase - meaning that the chain of commits I was expecting to take me back to develop on Feature B are now deprecated and if I don’t fix it, will likely result in duplicative commits at best but more likely undoing the changes that led to the rebase in the first place.

There are various solutions available to fix this problem. The two I explored were:

  1. Cut a new branch off of Feature A and cherry pick the commits
  2. Rebase Feature B off of the new Feature A

It’s worth noting that while this post will explore resolving this particular issue, the solutions I found are broadly applicable - whether you need to move commits around in history, quickly remove commits from history, etc.

For the purposes of discussion, I’ll use the following example git log. It shows that from c59a0f29, we have two divergent paths:

  1. Our current tip for Feat-A is 394f56b5 and includes intermediate commits merged into develop. These were caught during the rebase.
  2. Our current tip for Feat-B, however, diverges earlier and the hashes for Feat-A in its chain are now outdated.
git-log
* 394f56b5 2021-02-23 | Commit B [Feat-A] [Stephen] (origin/feat-a, feat-a)* 3fde45d6 2021-02-22 | Commit A [Feat-A] [Stephen]*   e4053384 2021-03-04 | Merge pull request #846 from branch-1695 (origin/develop, origin/HEAD) [Levi]
|\
| * 439ea509 2021-03-04 | async action type inference [Feat-C] [Levi]
|/
| * a1fb29a0 2021-03-03 | Commit D [Feat-B] [Stephen] (HEAD -> feat-b, origin/feat-b)
| * 520d733d 2021-03-03 | Commit C [Feat-B] [Stephen]
| * 4c565b04 2021-02-23 | Commit B [Feat-A] [Stephen]| * 6940eeef 2021-02-22 | Commit A [Feat-A] [Stephen]|/
*   c59a0f29 2021-03-02 | Merge pull request #844 from bump-version (origin/main, develop) [Jonathan]

Cutting A New Branch

In some ways, this approach is exceedingly straightforward. And, if you keep the branches small and short lived, should be relatively painless.

$ git checkout A
$ git checkout -b new-B
$ git cherry-pick 520d733d a1fb29a0

cherry-pick can do a lot more than just accept one commit at a time (as demonstrated here where I pass in two). But even this is relatively basic compared to its actual capabilities. Check out the examples section in the git documentation for more inspiration!

Rebase’s --onto

The second approach is slightly more involved… or maybe it isn’t and it just feels that way because it’s new? Either way, let’s look at it!

What I currently have looks a bit like this:

What I want is something like this (the ' merely indicating a new hash, but one that’s not known yet):

To get there, we can use git rebase’s --onto option.

Rebase Refresher

Before we get into why and how --onto helps, let’s take a quick trip down memory lane with git rebase. It’s worth remembering that git rebase takes two arguments (the second is optional).

For example, imagine the following:

| F (HEAD, develop)
| E
| * D (example-feat)
| | C
| | B
|/
* A

Now, there are two ways to move the example-feat onto the tip of develop:

$ git rebase develop example-feat
# or ...
$ git checkout example-a
$ git rebase develop

Both will result in:

  * D' (HEAD, example-feat)
  | C'
  | B'
 /
| F (develop)
| E
* A

How --onto Fits In

In case of git rebase —onto we can change the point where our branch is starting not only to the last commit on parent branch, but we can choose specific commit where we start and also where we finish. This is true not only on one specific branch but for other branches (all valid commits) too. We can say that git rebase —onto in precise and elastic solution. It grants you control over what and where is being rebased.

The API for git rebase --onto can be described as:

git rebase --onto <new-parent> <old-parent> [until]

If until is not provided, it will default to HEAD.

So, let’s go back to our example and look at what we have and what we want.

We want to convert this:

git-log
* 394f56b5 2021-02-23 | Commit B [Feat-A] [Stephen] (origin/feat-a, feat-a)
* 3fde45d6 2021-02-22 | Commit A [Feat-A] [Stephen]
*   e4053384 2021-03-04 | Merge pull request #846 from branch-1695 (origin/develop, origin/HEAD) [Levi]
|\
| * 439ea509 2021-03-04 | async action type inference [Feat-C] [Levi]
|/
| * a1fb29a0 2021-03-03 | Commit D [Feat-B] [Stephen] (HEAD -> feat-b, origin/feat-b)
| * 520d733d 2021-03-03 | Commit C [Feat-B] [Stephen]
| * 4c565b04 2021-02-23 | Commit B [Feat-A] [Stephen]
| * 6940eeef 2021-02-22 | Commit A [Feat-A] [Stephen]
|/
*   c59a0f29 2021-03-02 | Merge pull request #844 from bump-version (origin/main, develop) [Jonathan]

Into this:

git-log
  * a1fb29a0\' 2021-03-03 | Commit D [Feat-B] [Stephen] (HEAD)
  * 520d733d\' 2021-03-03 | Commit C [Feat-B] [Stephen]
 /
* 394f56b5 2021-02-23 | Commit B [Feat-A] [Stephen] (origin/feat-a, feat-a)
* 3fde45d6 2021-02-22 | Commit A [Feat-A] [Stephen]
*   e4053384 2021-03-04 | Merge pull request #846 from branch-1695 (origin/develop, origin/HEAD) [Levi]
|\
| * 439ea509 2021-03-04 | async action type inference [Feat-C] [Levi]
|/
| * a1fb29a0 2021-03-03 | Commit D [Feat-B] [Stephen] (feat-b, origin/feat-b)
| * 520d733d 2021-03-03 | Commit C [Feat-B] [Stephen]
| * 4c565b04 2021-02-23 | Commit B [Feat-A] [Stephen]
| * 6940eeef 2021-02-22 | Commit A [Feat-A] [Stephen]
|/
*   c59a0f29 2021-03-02 | Merge pull request #844 from bump-version (origin/main, develop) [Jonathan]

Plugging this into our variables:

  1. new-parent is 394f56b5 (or origin/feat-a/feat-a)
  2. old-parent is 4c565b04
  3. until is a1fb29a0 (or HEAD, origin/feat-b, feat-b) - and therefore could be left off.
$ git rebase --onto 394f56b5 4c565b04 a1fb29a0

Which produces exactly the new git log that we wanted.

git-log
  * 90ec8fe5 2021-03-03 | Commit D [Feat-B] [Stephen] (HEAD)
  * 4d14bdd3 2021-03-03 | Commit C [Feat-B] [Stephen]
 /
* 394f56b5 2021-02-23 | Commit B [Feat-A] [Stephen] (origin/feat-a, feat-a)
* 3fde45d6 2021-02-22 | Commit A [Feat-A] [Stephen]
*   e4053384 2021-03-04 | Merge pull request #846 from branch-1695 (origin/develop, origin/HEAD) [Levi]
|\
| * 439ea509 2021-03-04 | async action type inference [Feat-C] [Levi]
|/
| * a1fb29a0 2021-03-03 | Commit D [Feat-B] [Stephen] (origin/feat-b)
| * 520d733d 2021-03-03 | Commit C [Feat-B] [Stephen]
| * 4c565b04 2021-02-23 | Commit B [Feat-A] [Stephen]
| * 6940eeef 2021-02-22 | Commit A [Feat-A] [Stephen]
|/
*   c59a0f29 2021-03-02 | Merge pull request #844 from bump-version (origin/main, develop) [Jonathan]

It’s worth calling out two things:

  1. The old branch is still there (see how origin/feat-b still exists).
  2. We’re in a detached state.

To fix this, we can checkout a new branch to make sure it persists.

$ git checkout -b feat-b-alt

And with that, we can push the new branch up to remote and carry on our merry way!

Conclusion

At the end of the day, we were able to successfully resolve the history for our branch and have our feature branch accurately reflect the history we were seeking.

My one gripe with these approaches is that they result in a new branch, severing the tie to the remote server. (Update: to avoid this problem, check out Git Rebase On Rebased Branches and Other Fun, Redux where I revist the problem and solve for this annoyance.)

One more thing: There are lots of reasons to use git rebase --onto - some of which Agnieszka Małaszkiewicz calls out in her excellent post, e.g., quickly removing commits from the current branch.

Fortunately, now we have a better understanding of how --onto works, which means it will be easier to apply it in different situations!

Additional Resources


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!