Working with stacked branches in git (Part 2)

In my previous post I described stacked branches and why I like to use them for medium to large features. That’s primarily because they make unblocking yourself easier, and the review process of your PRs easier for others.

One of the difficulties of stacked branches is that they’re simply more complex to work with than isolated branches. In the previous post I described how to change commits earlier in the stack of branches. In this post I describe how to handle other common scenarios when working with stacked branches.

Why use stacked branches?

Just to level set, I’ll describe briefly again why I think stacked branches should be your go-to approach for building medium to large features.

This section is essentially the same as from the previous post—if you’ve already that post (and I think you should 😉) then you can jump to the next section.

Stacked branches in Git refers to having multiple branches that depend on one another in a linear stack. Each of the commits in a branch in the stack build on the changes made in the previous branch. For example, in the Git diagram below, there’s three branches in the stack:

An example stack of branches

In the example above:

All three stack/ branches are related to the same general feature, with the subsequent branches building on top of the changes made in the previous branches. Now, given that all of the branches are related to the same overall feature, you might be thinking “why not just have a single branch with 5 commits”?

My main argument is that using stacked branches makes it easier for people to review your changes in a PR. By creating a PR for each branch segment in the stack, the resulting PRs will be:

  • Smaller in scope. By definition each branch will contain smaller changes than the whole stack, so each branch will generally be smaller, and therefore easier to review.
  • More coherent. The intention behind stacked branches is to keep each branch in the stack modular, which makes each branch easier to review than the whole stack.
  • Signposting the thought behind the changes. By creating a stack of PRs, you can effectively guide people on how to review the code, especially as each branch will be accompanied with a PR description explaining the changes.

The above reasoning focuses very much on the experience of PR reviewers rather than the commit author. In many cases, creating a well curated stack of branches will be more work for the author than doing all the work in a single branch. However, I argue that it’s worth that extra effort:

  • The easier your PRs are to review, it’s likely that the quicker you will get reviews.
  • Reviewers are more likely to catch real issues if the changes are smaller, more modularised, and coherent.
  • You can keep working on the next branch in the stack while you wait for reviews on earlier branches, so you’re never blocked.

You can also make an argument that creating modular commits achieves the same thing, and I would agree. However, that generally requires reviewers to review your branches commit-by-commit, which I find people just don’t do by default (especially as GitHub pushes you towards reviewing everything at once). Using stacked branches and stacked PRs plays relatively nicely with the “defaults” of GitHub, so I have found it to be more successful.

Having said all that, managing stacked branches, is definitely harder work than using most of the default Git workflows. There’s no substitute for getting comfortable with Git in general (I had my personal Git-epiphany after reading Think Like a Git), but in this post I’ll describe some of the core workflows I use when working with stacked branches.

Working with stacked branches

In the previous post, I mentioned various issues that you need to handle when working with stacked branches:

  • Reordering or amending commits within a stack
  • Rebasing a stack of PRs after changes on main
  • Pushing a stack of rebased PRs
  • Rebasing a stack after a PR is merged

I described various approaches to handling that first task in the previous post, and in this post I look at some of those remaining tasks.

Rebasing a stack of PRs after changes on main

When you’re working with stacked branches, you’re inevitably working on longer-lived features. It’s therefore inevitable that someone will merge something to master, and you’ll need to either merge in those changes or rebase on top.

Personally, I never merge main into my feature branches and always rebase my branches on top instead. The net result is essentially the same—the changes are reflected in your branch and you must handle any merge conflicts—but I find using merge creates a web of connections that’s much harder to follow.

That said, rebasing a branch can mean you must resolve more conflicts than you would with a merge. With a merge, you have one set of conflicts to merge and you’re done. In a rebase, each commit is “replayed” on top of main, so you must resolve any conflicts for each commit, which will mean more conflicts to resolve if you have changed the conflicting code multiple times in different commits. Despite this, rebasing is still my preferred approach.

Let’s say you’re working on the same feature/ stack as in my previous post. You currently have three branches in the stack, and are still working on it. Meanwhile, someone merges a PR to main, the commit “Another change” in the image below:

The initial stack with 3 branches feature/part1, feature/part2, feature/part3

You want to rebase your stack on top of main to ensure you handle any merge conflicts now, so that you can make a PR shortly.

The important feature that makes this simple is –update-refs. I’ve written about –update-refs previously, and mentioned it in passing in the previous post, but it’s crucial for working with stacked branches. The reason why will become obvious shortly. For now, let’s consider what we need to do.

⚠️ I’m showing you the hard way, just to emphasize the benefits of –update-refs, so don’t be put off. We’ll look at the easy approach instead shortly.

To rebase the feature/* stack on top of main without using –update-refs, we would need to:

Those final two points might surprise you if you’re not very comfortable with rebasing, but by default this would be necessary. For example, if we step through this approach, first we would rebase all the commits in the stack:

git rebase main

This “moves” all the commits that are part of the stack onto main, but as you can see below, it leaves the other branches in the stack pointing to the same (old) commits as before:

Result after performing a basic rebase

We then have to manually move those branch references to the correct (new) commit. The easiest way to do this is by force-creating a commit with the same name at the new commit. For example:

# These SHA commits are taken from the new stack
git branch –force feature/part-2 ed46a50e2c2bdfa00c9c6582ef511fac5e3af98c
git branch –force feature/part-1 80067aec4e77ffac22bf8e6dd33cb9838cb98e2f

# Alternatively, we could derive the correct commit as relative # references from the new feature/part-3 branch. # feature/part-32 means “the second commit before feature/part-3” for example git branch –force feature/part-2 feature/part-32 git branch –force feature/part-1 feature/part-3~3

After making this change, our stack has been cleanly rebased on top of main:

The stack is now rebased on top of main

So that approach works but it requires multiple commands, requires manually tailoring the commands depending on your stack and is easy to get wrong.

“Isn’t there an easier way?” I hear you cry!

Yes, there is, and it’s –update-refs. With –update-refs, Git will automatically “fix” those branch references itself, without you needing to do all those calculations yourself. First let’s back to our original stack:

The initial stack with 3 branches feature/part1, feature/part2, feature/part3

We now need to run just one command:

git rebase main –update-refs

and we can jump straight to the final situation we wanted, with all the branches in the correct place:

The stack is now rebased on top of main

When you’re working with stacked branches, –update-refs becomes practically a requirement, so that I basically never want to not use it. For that reason, I always enable –update-refs by default, across all my repositories:

git config –global rebase.updateRefs true

Enabling this by default means that you don’t even need to pass –update-refs to your rebase commands, it will just be implicitly added, so rebasing a stack of PRs on top of main becomes as simple as rebasing a single branch, simply:

git rebase main

There are some rare cases where I don’t want to use –update-refs. One example is if I have just created a “backup” of a branch by running git branch my-backup, before I do a risky rebase. In that case I use –no-update-refs to leave the backup branch un-touched.

Once you have –update-refs in the mix, rebasing the stack of branches is as easy as rebasing a single branch, and the other branches just come along for the ride. Pushing that stack to a remote, however, isn’t quite as easy.

Pushing a stack of rebased PRs

One of the realities of working with stacked branches is that you’re going to be periodically rebasing, as your total stack of branches is likely going to live longer than the average small branch. And consequently, you’re going to need to push that whole stack of branches periodically.

Until recently, I didn’t bother with anything particularly fancy for pushing these branches. I would simply call git push origin –force-with-lease <branch> for each branch I needed to push, something like this:

git push origin –force-with-lease feature/part-1;
git push origin –force-with-lease feature/part-2;
git push origin –force-with-lease feature/part-3;

Pretty ugly, but I just lived with it 😅

However, a question from a colleague about whether this could be made simpler, and a discussion in the comments of my –update-refs post made me look for a cleaner solution. After a bit of hacking around, I found a solution I’m pretty happy with, which pushes the full stack of commits to a remote repository with a single command: git push-stack!

> git push-stack
Enumerating objects: 20, done.
Counting objects: 100% (20/20), done.
Delta compression using up to 16 threads
Compressing objects: 100% (12/12), done.
Writing objects: 100% (18/18), 1.61 KiB | 1.61 MiB/s, done.
Total 18 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
To D:</span>repos</span>temp</span>temp69

  • 540f580...e12b308 feature/part-3 -> feature/part-3 (forced update) Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0) To D:</span>repos</span>temp</span>temp69
  • 44a77b9...98143f9 feature/part-2 -> feature/part-2 (forced update) Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0) To D:</span>repos</span>temp</span>temp69
  • 834f474...4480c40 feature/part-1 -> feature/part-1 (forced update)

The above output shows that I had feature/part-3 checked out, and when I ran the git push-stack command, it pushed all of the branches to the default remote, origin. I showed in a previous post how I built this command up, so for now I’ll just provide the Git alias configuration commands to run, so that you can create your own push-stack alias:

git config –global alias.default-branch "!git symbolic-ref refs/remotes/origin/HEAD | sed ’s@^refs/remotes/origin/@@’"
git config –global alias.merge-base-origin ’!f() { git merge-base ${1-HEAD} origin/$(git default-branch); };f ‘
git config –global alias.stack ’!f() { BRANCH=${1-HEAD}; MERGE_BASE=$(git merge-base-origin $BRANCH); git log –decorate-refs=refs/heads –simplify-by-decoration –pretty=format:"%(decorate:prefix=,suffix=,tag=,separator=%n)" $MERGE_BASE..$BRANCH; };f ‘
git config –global alias.push-stack ’!f() { BRANCH=${1-HEAD};  git stack $BRANCH | xargs -I {} git push –force-with-lease origin {}; };f ‘

This command makes it easy to push all the branches in your currently checked out stack to a remote. You can read more about the exact behaviour, assumptions, and options for push-stack in my previous post. git push-stack is particularly useful when you’ve made changes somewhere in the middle of your stack, or you’ve just rebased it. No matter how many branches are in your stack, simply run git push-stack and they’re all pushed to the remote.

Rebasing a stack after a PR is merged

If all is going to plan with your Git stack, each branch in your stack will be merged to main one at a time. Depending on how your repository is set up to merge (with or without a merge commit, with or without squashing), this could make it easier or harder to rebase your commits afterwards.

Most people have the biggest problems rebasing a stack when a repository uses squashing to merge a PR. This is because Git no longer “understands” that a given branch is now part of the main branch.

Let’s say we have the following stack, which has already been pushed to the remote:

The stack of branches prior to mere

The first PR, for branch feature/part-1, has been approved, and you merge it. As per the repository’s settings, the branch is squashed to a single commit when it’s merged to main, and the remote branch feature/part-1 is deleted. If you do a git fetch, the commit graph looks something like this:

The stack of branches after running git fetch, after the feature branch is merged

With feature/part-1 merged, you now need to rebase the remainder of the stack on top of origin/main. Unfortunately, if you run simply git rebase origin/main then you’ll be hit with a bunch of merge conflicts, as Git can’t tell that the commits “First sub-feature” and “Update for first-feature” have been merged into a single commit, “feature/part-1”.

To work around this you need to be more specific with your rebase command. You need to rebase all the commits between the top of the stack (feature/part-3) and feature/part-1onto origin/main. The key to this rebase operation is the –onto option. Assuming you have checked out feature/part-3, you can run:

git rebase feature/part-1 –onto origin/main

Assuming you have –update-refs enabled as I have previously encouraged, this moves the whole remainder of the stack for you:

$ git rebase feature/part-1 –onto origin/main
Successfully rebased and updated refs/heads/feature/part-3.
Updated the following refs with –update-refs:
refs/heads/feature/part-2

Leaving your local repository looking like this:

The stack of branches after rebasing onto origin/main

All that remains is to clean up by pushing the stack and cleaning up the now defunct feature/part-1 branch:

git push-stack
# We have to use -D here to force delete the branch,
# as Git won’t see it as having been merged
git branch -D feature/part-1

The final result is that our local repository looks like this:

The repository after rebasing the stack

and we’re ready to continue with our stack!

Just to reiterate, the magic here is the use of –onto in git rebase. You just need to know two things:

and then use this construct:

git rebase <base> –onto <onto>

With that, I think we’ve covered the majority of complexities that occur when working with stacked branches. If there are any challenging Git gymnastics you think I’ve glossed over or missed, do let me know in the comments!

Summary

In this follow up post to my previous post about using stacked branches, I described how to handle some common scenarios. In particular, I described how to rebase a stack of branches after commits to the main branch, how to push a stack of rebased branches, and how to rebase a stack after one of your branches has been merged. These, coupled with making local changes to branches, are the most common scenarios I encounter working with stacked branches. Hopefully the techniques I describe in this post help you to use stacked branches in your own work