2021-03-04
|~7 min read
|1397 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:
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:
394f56b5
and includes intermediate commits merged into develop
. These were caught during the rebase.* 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]
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!
--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.
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
--onto
Fits InIn 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:
* 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:
* 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:
new-parent
is 394f56b5
(or origin/feat-a
/feat-a
)old-parent
is 4c565b04
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.
* 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:
origin/feat-b
still exists).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!
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!
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!