Managing Git Branches of Branches

TL;DR

rebasing can get tricky. git rebase --onto is good to be aware of!

Short story longer…

Here is a workflow that we are dealing with more and more as our team grows, and as we use epic branches for a collection of features that we want to ship together. The workflow is a bit simpler to demonstrate when talking about simpler feature branches, but the problem and solution apply very similarly to epic branches and features that would merge to those instead of dev.


1
2
3
4
5
git checkout -b feature_branch
# Do some development
git add .
git commit
git push origin feature_branch

At this point the feature branch is up for review, but other features that are dependent on feature_branch are in queue. So while feature_branch is in review a new branch can be created from it work can go forward.

1
2
3
4
5
git checkout feature_branch
git checkout -b dependent_branch
# Do some more development
git add .
git commit

Meanwhile, feedback or issues have been found on feature_branch and new commits need to be made in response. No problem, right!

1
2
3
4
5
6
git checkout feature_branch
# Do review fixes
git add .
git commit
git checkout dependent_branch
git rebase feature_branch

So far so good. Smooth sailing. However, choppy seas are coming. Once feature_branch is ready to merge to a main line a problem starts to emerge. We have a squash policy on pull requests to main line branches like dev, or main/master. In our case feature_branch will be squashed into a single commit automatically by Github. But if it were manual process this is what it would look something like.

1
2
3
git checkout dev
git merge --squash feature_branch
git commit -m 'Commit a whole feature as one commit - easy to revert, keeps history clean, etc'

Built on shifting sand?

Even now the problem may not be obvious. On the main branch everything is fine. A nice and tidy commit history even! But what about dependent_branch. It’s history has a bunch of commits (from feature_brach) that already exist as a squashed commit on dev. How do we get those now redundant, and conflicting as far as Git can tell, change sets out of the way? If we try a simple rebase of dependent_branch onto dev or try and merge dev into it, Git will be confused by the re-written/squashed history and basically it will consider all those change in dependent_branch as a conflict. It’s a tedious and error prone process to go through and basically re-do or de-conflicticize all of the changes in dependent_branch. Yikes!

A workaround that can sometimes be fine

Git branches are cheap, so one sort of simple brute-force work around approach is just to abandon dependent_branch. Manually create a patch and apply it off a fresh branch created from the up to date main line branch

1
2
3
4
5
6
git checkout dependent_branch
git diff > ~/Desktop/dependent_branch.diff
git checkout dev
git checkout -b new_dependent_branch
patch -p1 < ~/Desktop/dependent_branch.diff
# Pray for a clean apply.

Or, same idea but using merge --squash instead of diff and patch.

1
2
3
4
git checkout dev
git checkout -b new_dependent_branch
git merge --squash dependent_branch
# Pray for a clean merge

But not without some pitfalls of its own

The main problems with these approaches are, if there’s any actual conflicts, it can be even worse to fix than the rebase or merge approaches, and on top of that your Git history on dependent_branch is basically lost.

Of course this whole problem happens because of the re-written history during the squash of a feature_brach to a main line branch, but assuming that is in place for good reasons, and not something you are aiming to change, what’s the best solution / workaround? If your instinct tells you that “Git is full of magic tricks. surely there is something to be done?” your instinct would be correct!


Clarifying where the conflicts arise

First, a little bit more about why this happens:

Let O1 be “original” mail line branch - dev, master, main, whatever it’s name. O2 will be “updated original”, for example dev after a feature branch has been squash merged into it:

Say feature_branch looks like:

1
2
O 
\ - A - B - C

dependent_feature has a few extra commits on top of that:

1
2
3
O 
\ - A - B - C
\ - D - E - F

Github merges yourfeature_branch into dev squashing it down in the process, and in our configuration also pruning it (in the remote repository) giving you:

1
O - O2

If dependent_feature were on the remote repo and were to have a PR opened to merge to dev, let’s say, it’s would look sort of like this:

1
2
O - O2
\ - A - B - C - D - E - F

Github would flag it as conflicting and maybe it’s easier to see why at this point. of course that old rebase command is tempting, and seems like it would be a solution, but there is a catch. If you were to try to rebase dependent_feature to dev, Git is going to try to figure out the common ancestor between those branches. While it originally would have been C, if you had not squashed the commits down, Git instead finds O as the common ancestor. As a result, Git is trying to replay A, B, and C which are already contained in O2, and you’re going to get a bunch of conflicts.

The solution

For this reason, you can’t rely on a simple unadorned rebase command. Fortunately Git does have something to help. We still are going to use rebase, but we’re going to have to be more explicit about how we want the rebase to proceed by supplying the --onto parameter:

1
2
3
git rebase --onto dev HEAD~3
# instruct git to replay only the last
# 3 commits, D E and F, onto main.

Like this:

1
2
O - O2
\ - D - E - F

Modify the HEAD~3 parameter as necessary for your branches, and you shouldn’t have to deal with any redundant conflict resolution.

A slightly better (for me) solution

Some alternate syntax, if you don’t like specifying ranges, and given you probably haven’t deleted your unsquashed local copy of feature_branch yet you can do:

1
2
3
4
git rebase --onto main feature_branch dependent_feature
# replay all commits, starting at feature_branch
# exclusive, through dependent_feature inclusive
# onto main

Standard rebase issues apply

Chances are that will go smoothly, but if not, conflicts should be fairly manageable in the sense they will be actual conflicts, not pseudo conflicts that Git just thinks it needs your help with. And, of course, if you had previously pushed dependent_feature to a remote repository you will need to force the new history up there which is standard procedure for a rebase.

1
git push -f origin dependent_feature