Working with stacked branches in git (Part 1)

In this post (and the subsequent post) I describe the Git workflow that I typically use when I’m working on medium-to-large features. For these features I like to use stacked branches and stacked PRs, where each branch is a small unit of the work that can be reviewed, but where each branch builds on top of previous commits in the stack. In this post I describe the overall workflow I use, as we as some of the approaches I use to make working with stacked branches easier.

Why use stacked branches?

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 (and the next) I’ll describe some of the core workflows I use when working with stacked branches.

Working with stacked branches

Given this is a post about stacked branches, I would be somewhat remis if I didn’t mention Graphite. Until recently, Graphite was primarily focused on providing a really nice stacked branch workflow when you’re working with GitHub. Of course, they’ve apparently pivoted to focus on AI now it seems (I’m shocked, shocked I tell you).

I haven’t personally used Graphite, because they didn’t have a Windows client for the longest time, and even now they require you install node first (grmbl).

If you’re not interested in using Graphite, then you’ll need to handle a bunch of common scenarios when you’re developing locally using stacked branches and when you’re pushing your branches and creating PRs.

  • 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 discuss approaches to handling the first of these tasks in this post, and tackle the remainder in the subsequent post. In some cases I’ll use the command line, while in others I’ll use a GUI, such as the Git GUI elements in JetBrains’ Rider. In both posts I assume that you’re familiar with the basics of working with Git, such as committing files and checking out branches, whether that’s via the command line or using a GUI.

Reordering or amending commits within a stack

Even when I’m working with stacked branches I will sometimes notice errors, omissions, or updates that I want to make to commits earlier in the stack. For example, imagine we’re starting from this existing stack of three branches in the feature/ stack:

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

While working on this stack I discover that I need to make a tweak in the first sub-feature code. There are multiple ways I handle this in practice, depending on the nature of the changes I want to make, though they all use interactive rebase in some way:

There are multiple other options available, but these are the approaches I use most often when I’m specifically working with stacked branches. We’ll look at each of these options in turn and how they work in practice.

Commit to HEAD and interactive rebase

The first approach simply uses a standard Git feature for rearranging commits: interactive rebase. This works best when the change you want to make is isolated from changes made in subsequent branches in the stack, so there’s no merge-conflict risk.

For example, we start by making the necessary change as an additional commit on the top of the stack i.e. on the currently checked-out branch at the HEAD. For our worked example, this adds the commit “Update for first feature” to the HEAD branch feature/part-3:

The updated stack after adding a new feature commit to the HEAD

We now need to “move” this commit down so that it’s part of the branch feature/part-1. We could do this either with the command line or with a GUI. With the command line, we could use a command like this:

git rebase feature/part-1^1 -i –update-refs

To explain this command, I’ll break it down piece by piece

When you run the interactive rebase command above, git opens an editor with a file that looks like this:

pick 834f474 First sub-feature
update-ref refs/heads/feature/part-1

pick 44a77b9 Second sub-feature update-ref refs/heads/feature/part-2

pick 0732570 Final sub-feature pick 540f580 Final sub-feature tweak pick c26fbac Update for first feature

# Rebase 40967d0..c26fbac onto 40967d0 (7 commands) # # Commands: # p, pick <commit> = use commit # r, reword <commit> = use commit, but edit the commit message # e, edit <commit> = use commit, but stop for amending # s, squash <commit> = use commit, but meld into previous commit # …

This gives a textual representation of the commits in the stack. You’re then free to rearrange, delete, reword, or perform a wide range of other operations on the commits. For this example, we simply need to rearrange the commits, and move the pick c26fbac commit, so that it appears just before update-ref refs/heads/feature/part-1:

pick 834f474 First sub-feature
# 👇 Move the commit here
pick c26fbac Update for first feature
update-ref refs/heads/feature/part-1

pick 44a77b9 Second sub-feature update-ref refs/heads/feature/part-2

pick 0732570 Final sub-feature pick 540f580 Final sub-feature tweak

# Rebase 40967d0..c26fbac onto 40967d0 (7 commands)

Save the file, close it, and the git interactive rebase continues:

> git rebase feature/part-1^1 -i –update-refs
Successfully rebased and updated refs/heads/feature/part-3.
Updated the following refs with –update-refs:
refs/heads/feature/part-1
refs/heads/feature/part-2

If you take a visual look at the result, you can see that the commit “Update for first feature” has moved from the top of the stack to being part of feature/part-1:

The Update for first feature has moved to be part of feature/part-1

This approach works well when the commit you want to add is self-contained and where the reordering won’t cause merge-conflicts. Unfortunately, that’s not always the case, and often it’s not clear whether it’s the case or not!

Using git absorb and interactive rebase

git-absorb is a “plugin” for Git that can automatically create “fixup!” commits for use with interactive rebase. It’s a port of Facebook’s hg absorb tool, and automates creating commits that can be auto-squashed into previous commits.

I discussed auto-squashing in a previous post, and looked at git-absorb in the subsequent post. If you’re not familiar with those tools I strongly recommend reading those posts, though I’ll give a quick recap here.

–autosquash is a feature of Git’s interactive rebase which can automatically rearrange commits during an interactive rebase, based on the commit names. For example, lets imagine you have the following stack of commits:

pick a43f263 Add initial interfaces
pick a643ac3 Add base types
pick a08d5fa Add implementation

You make an additional commit, but you actually want to amend the commit “Add base types”. You could add a new commit to the HEAD and then during an interactive rebase you could manually move it to the correct position and change the command to fixup. However, if you use the special commit message “fixup! Add base types” (i.e. the target commit message with a fixup! prefix), then you can use the –autosquash feature to automatically move the commit to the correct place. The Rider GUI even has built-in support for generating these fixup! messages natively:

Rider showing a list of commits in the commit message dialog

In this example, assuming you’ve added the message as expected, if you then run git rebase main -i –autosquash, the commit-list editor pops up with the fixup! commit correctly moved to the right place and changed to be fixup:

pick a43f263 Add initial interfaces
pick a643ac3 Add base types
fixup 48009ba fixup! Add base types
pick a08d5fa Add implementation

Or if you’re using the GUI, the same thing works in Rider (if you enable –autosquash by default):

Rider automatically handles autosquashing

Hopefully it’s clear why this all comes in handy when you’re working with stacked branches. If you want to amend a commit that’s earlier in the stack, you can create a fixup! commit, perform an interactive rebase, and the commit is fixed with little fanfare or issues.

As I described in my previous post, git absorb makes all this even easier and more automated!

Once you’ve installed git-absorb, you don’t have to try to craft the fixup! messages yourself. Simply make whatever changes you need, stage them with git add and then run git absorb. git-absorb will figure out which of the previous commits need to be modified to incorporate your changes and generate the relevant fixup! commits. So to reiterate, you make your changes, and run:

git add . # Include all the files in fixup commits. Alternatively you can include a subset.
git absorb # Generate fixup commits using the default settings

git-absorb will work out which commits need amending, and commit additional fixup! commits:

INFO committed, header: -1,1 +1,1, commit: 5395eafdbb2740dc76adb234b112914b491ffd44
INFO committed, header: -1,1 +1,1, commit: eacb60be2235b621f50158c68ae7a5e61a313081
INFO committed, header: -0,0 +1,1, commit: f5c5748a20bd6fa23b563844c1cddeeb683f1499

You can then run git rebase main -i –autosquash (or use a GUI as in the image below), and the commits are automatically moved to the right place:

Using git absorb to create fixup commits

I have obviously written multiple posts about –autosquash and git absorb previously, but I really want to highlight them here as they’re key tools for me when I’m working on stacked branches. –update-refs falls in the same bucket; if you’re working with stacked branches, you really need to take a look. What’s more, you probably want to enable –update-refs and –autosquash by default. You can do this by running:

git config –global rebase.autosquash true
git config –global rebase.updateRefs true

As I said at the start of the post, keeping a “clean” stack of commits and branches for PR reviewers is one of the big selling points of stacked branches, and these tools make it that much easier and lower overhead, so I strongly recommend giving them a try.

Interactive rebase, pause, and continue

Both of the approaches described about involve creating the edits at the HEAD and then doing an interactive rebase to move the commits to the “correct” place in the stack. This is often the lowest friction approach, but sometimes it can be more tricky.

I have found this approach tends to fall down most when code has changed multiple times in the same stack of commits. If you’re making some changes in feature/part-1 for example, and then make additional changes in feature/part-2, but then subsequently realise you had a bug in the feature/part-1 commit, it can be difficult to avoid merge conflicts. What’s more it can ultimately just get very confusing!

In this scenario, I sometimes like to use a “replay” approach using interactive rebase. With this approach, you start rebasing, and Git starts applying the commits, but then it pauses for you to make your changes. You can add commits or even amend existing commits before continuing. Git then continues applying the subsequent commits on top to rebase the rest of the stack.

For an example, we’ll go back to our feature/ git stack:

The Update for first feature has moved to be part of feature/part-1

We have feature/part-3 currently checked out, but we realise that we actually need to change something about the “First sub-feature” commit, and due to conflicts, the previous simpler approaches with git absorb etc won’t work here. So instead, we opt for a rebase and pause approach.

The first step is to initiate an interactive rebase, whether this is with the command line or an IDE:

git rebase -i –root

I’m rebasing from the root in the above example, but you can use any commit prior to the one you want to change in your stack.

This opens the standard interactive git document in your editor of choice:

pick 40967d0 First change
pick 834f474 First sub-feature
pick a6ad343 Update for first feature
update-ref refs/heads/feature/part-1

pick 21f86bc Second sub-feature update-ref refs/heads/feature/part-2

pick 8715850 Final sub-feature pick 2b95f7a Final sub-feature tweak

# Rebase 2b95f7a onto 160efe0 (8 commands) # # Commands: # p, pick <commit> = use commit # r, reword <commit> = use commit, but edit the commit message # e, edit <commit> = use commit, but stop for amending

The command we’re interested in here is edit. We simply change the command from pick to edit, and then after that commit has been applied, Git will pause the rebase:

pick 40967d0 First change
# 👇 Change this one to edit
edit 834f474 First sub-feature
pick a6ad343 Update for first feature
update-ref refs/heads/feature/part-1
...

After saving and closing the file, git starts to rebase, but then pauses:

Stopped at 834f474...  First sub-feature
You can amend the commit now, with

git commit –amend

Once you are satisfied with your changes, run

git rebase –continue

At this point, I make the changes I need to, and commit them in one of two ways:

In the first example, a new commit is created immediately after “First sub-feature”; in the second example, the “First sub-feature” commit is directly amended. Sometimes, I’ll do a git rebase HEAD~ which will essentially remove the previous commit, but keeps the working directory the same. This can be useful if you want to “see” the changes made in the previous commit while you’re working on your update.

Once you’ve made all your changes and you’re reading to continue rebasing, run git rebase –continue. You will likely need to deal with merge conflicts (that’s why we took the replay approach after all), but once they’re resolved, you should have the extra commit, “Adding the extra changes”, in the correct place:

The extra commit has been included in part 1

You’re probably getting the impression that interactive rebase is always the answer when doing anything with stacked branches, and to some extent, I think it really is! In the next post we’ll look at how it can help us with other common stacked branch scenarios, such as pushing the whole stack to a remote server, or rebasing the stack when one of the branches has been merged.

Summary

In this post I discussed stacked branches and stacked PRs, and why I like to use them when working on any moderately hard feature. The main benefit is to reviewers of my PRs, who can more easily understand a cohesive set of changes, in manageable chunks. The main benefit to me is faster reviews, and I’m not blocked on changes before I keep working.

I then showed some of the Git techniques I use when I need to make changes to a branch in the stack. In each case I use interactive rebase to make the changes, but whether I use git-absorb, simple commit reordering, or edit-and-continue depends on each case.