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:
In the example above:
- Branch
stack/a
branches from commitmain2
and contains two commits:a1
anda2
- Branch
stack/b
is stacked on branchstack/a
and contains one commit:b1
- Branch
stack/c
is stacked on branchstack/b
and contains two commits:c1
andc2
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:
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:
- Rebase all the commits in
feature/part-3
from“First sub-feature”
to“Final sub-feature tweak”
inclusive ontomain
. - Force reset/move the
feature/part-1
to point to the appropriate commit in the rebased stack. - Force reset/move the
feature/part-2
to point to the appropriate commit in the rebased stack.
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:
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
:
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:
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:
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 runninggit 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 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:
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-1
onto 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:
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:
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:
<base>
: The “bottom” of the stack that you want to rebase. Can be a commit SHA or a branch. Thefeature/part-1
branch in our example<onto>
: Where you want to rebase your stack onto. Typicallyorigin/main
or equivalent.
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