Adventures with git Rebasing

Every now and then I run into a situation where I’m branched off of a topic branch and the topic branch gets rebased off of main or squashes commits. How do I cleanly rebase my branch off the topic branch? This situation looks like this:

a b c          main
  |  \
  |    d' e'   branch-a (after being rebased off main)
  |
  d e          branch-a-outdated (before rebasing main)
      \
       f g     branch-b (based off outdated branch-a)

In the above, branch-b had branched off of branch-a, then branch-a rebased off of main. For the sake of this discussion, let’s also assume there were merge conflicts from main that were resolved during this rebase. The goal is to now rebase branch-b so its two commits, f and g, branch off of e' from branch-a. This might appear trivial at first, but is trickier than it seems.

Let’s try a regular rebase

Many times, I have attempted to solve this situation with a regular rebase, only to be met with a confusing situation:

branch-b % git rebase branch-a
First, rewinding head to replay your work on top of it...
Applying: d
Using index info to reconstruct a base tree...
M       b.txt
Falling back to patching base and 3-way merge...
Auto-merging b.txt
CONFLICT (content): Merge conflict in b.txt
error: Failed to merge in the changes.
Patch failed at 0001 d
Use 'git am --show-current-patch' to see the failed patch

Resolve all conflicts manually, mark them as resolved with...

“Applying: d”?? Our branch started at commit f, so why is git trying to apply d?

The short answer is that git doesn’t know that d is already in branch-a in this case because when branch-a was rebased off of main, the d commit changed. This can be due to a merge conflict or because it was squashed or changed for any other reason.

When we look at the merge conflict, it’s for a file that wasn’t even touched in branch-b. We are forced to resolve the same conflicts that we already resolved when rebasing branch-a off of main! This inserts room for error, especially if we aren’t familiar with the changes from branch-a.

If you’ve ever rebased and wondered why you are having to resolve conflicts for files not in your current branch, there’s a chance this is what was occurring – the branch you are rebasing off of had rewritten history in some way that modified the content of its commits, and git no longer recognizes that the commit in your branch is from the branch off of which you are rebasing.

How about a cherry-pick?

Before we go on with the proposed solution, here is the commit diagram again so you don’t have to scroll up:

a b c          main
  |  \
  |    d' e'   branch-a (after being rebased off main)
  |
  d e          branch-a-outdated (before rebasing main)
      \
       f g     branch-b (based off outdated branch-a)

Remember that our goal is to have f and g branched off of e' from branch-a. Since we just saw that git rebase branch-a doesn’t work as we’d like, what else could we try? One solution that I used for years before I knew about the technique I’m about to show was to use git cherry-pick to play f and g directly on top of branch-a. This absolutely works but requires some confusing branch shuffling:

branch-b % git log
ABC123 g (HEAD -> branch-b)
XYZ321 f
...
branch-b % git branch -m branch-b-bak       # not necessary but nice if you're scared of git reflog like me
branch-b-bak % git checkout -b branch-b branch-a # check out new branch-b from branch-a
branch-b % git cherry-pick XYZ321^..ABC123  # copy the range of commits we want from the late branch-b
branch-b % git branch -D branch-b-bak       # delete that backup

The above works just fine, and I’m sure there are some improvements that could be made to the process, but there is still a much easier way to get the exact same results. Read on!

git rebase --onto for the win!

git, in its wisdom, knew that this issue would come up, so it provides a special kind of rebase where we can explicitly specify which commit we want the rebase to start from. That means that from branch-b, we can effectively say “rebase off of branch-a but only play my commits starting after commit X”. Here’s a simplified look at the syntax:

git rebase --onto branch-i-want-to-be-based-off
branch-or-hash-that-my-changes-are-currently-based-off branch-im-rebasing

So in our case, from branch-b, we would do:

% git log
ABC123 g (HEAD -> branch-b)
XYZ321 f
QWE123 e (branch-a-outdated)
...
% git rebase --onto branch-a QWE123 branch-b
First, rewinding head to replay your work on top of it...
Applying: f
Applying: g

This performed the same thing as git rebase branch-a, but we were able to specify which commit to start rebasing from and only apply f and g. Notice that the QWE123 SHA we specified is the commit directly before the two commits we want from branch-b. We could also have achieved the same results with:

% git rebase --onto branch-a branch-a-outdated branch-b

or:

% git rebase --onto branch-a HEAD~2 branch-b

Now, here are the results. Just what we wanted, and with one command.

% git log
g  (HEAD -> branch-b)
f
e' (branch-a)
d'
c  (main)
b
a

A Little Syntactic Sugar

I find it redundant to have to specify the current branch as the last parameter to git rebase --onto, since I always run it from the branch I’m rebasing, so I have an alias and function I’ve set up in my dotfiles:

alias gcurrent="git rev-parse --abbrev-ref HEAD"
function gro() { git rebase --onto $1 $2 $(gcurrent) }

This allows me to accomplish the above with just: gro branch-a QWE123. Simple!

Be sure to check out (pun) the git rebase docs for more info on all this.