Kaushik's Blog

Using Stacked Branches in Git

I use stacked branches in Git all the time.

Motivation

1. Scope explosion

Sometimes, I create a branch for a seemingly small task that balloons out of control, touching numerous files and introducing a large diff.

Breaking down the changes into small, meaningful commits is the first step. But if that produces numerous disparate commits, which could be more logically organized as separate features, it may make more sense to create a set of stacked branches. Each branch in the stack can be put up for review separately, easing the cognitive burden on the reviewer.

2. Staying unblocked

After completing a feature and putting it up for review, sometimes my next task requires those changes. I could either sit around twiddling my thumbs, waiting for the first feature to be reviewed and merged into main before creating a branch for the second task.

Or...I could simply make a new branch off of the one under review, and keep on cranking.

Recipes

1. Get your commit ducks in a row

So you have a whole bunch of small commits. First, get them in the order you want. This may involve breaking apart larger commits into smaller ones, so that commits can be reordered easily without introducing conflicts.

2. Create the stack of branches

Once you have all the commits in the desired logical ordering, you can make branches with another interactive rebase.

pick 4d866dc3 Update Docker build command to use correct base image tag
pick abaf78f7 Fix formatting of Dockerfile
pick 00bd401a Trim Docker tags during deployments
# logical end of branch 1
# Add an instruction to create a branch
exec git branch feat/JIRA-001/update-docker-stuff

pick 0e216f1a Intialize BufferSyncNode with formatted topics
pick 68a103ce Refactor: Create function to purge old frames from buffer
pick dab84a9e Refactor: Create function to get closest image from buffer
pick df28f717 Rewrite init function to use new socket abstractions and helper funcs
# logical end of branch 2
exec git branch feat/JIRA-001/optimize-buffer-sync-node-implementation

pick 03605221 Update cereal-IPC submodule with debug info
pick 366e5ba5 Create message bridge from server -> edge device
pick be938c04 Create message bridge on edge device to receive server msgs

The extra exec instructions tell git to stop after the previous instruction and run a branch creation command.

Now you have three branches! feat/JIRA-001/update-docker-stuff, feat/JIRA-001/optimize-buffer-sync-node-implementation, and the branch you were originally on (say, feat/JIRA-001/create-message-bridges). They're ready to be pushed for review.

3. Push the stack of branches

It's tedious to have to push each of the branches in the stack, one by one, to the remote.

This, adapted from here, does the trick:

git log --decorate=short --pretty='format:%D' origin/main.. | sed 's/, /\n/g; s/HEAD -> //' | grep -v '^origin/' | xargs git push --force-with-lease origin

Let's break that down:

HEAD -> feat/JIRA-001/create-message-bridges, origin/feat/JIRA-001/create-message-bridges


feat/JIRA-001/optimize-buffer-sync-node-implementation



feat/JIRA-001/update-docker-stuff

4. Rebase the stack if the main branch moves ahead

In a fast-forward workflow, if the main branch moves, your feature branches need to be rebased. But now, instead of having a single feature branch (feat/JIRA-001/create-message-bridges), you have a stack of feature branches.

By default, rebasing feat/JIRA-001/create-message-bridges onto main doesn't rebase the other branches in the stack. feat/JIRA-001/update-docker-stuff and feat/JIRA-001/optimize-buffer-sync-node-implementation would remain untouched; they'd have to be manually reset to the correct commit.

Wouldn't it be much nicer if, when we rebased the last branch in the stack, all the constituent branches got updated too?

git rebase --update-refs takes care of that, updating each of the dependent branches along the way. In an interactive rebase, you can see how it works:

pick 4d866dc3 Update Docker build command to use correct base image tag
pick abaf78f7 Fix formatting of Dockerfile
pick 00bd401a Trim Docker tags during deployments
update-ref refs/heads/feat/JIRA-001/update-docker-stuff

pick 0e216f1a Intialize BufferSyncNode with formatted topics
pick 68a103ce Refactor: Create function to purge old frames from buffer
pick dab84a9e Refactor: Create function to get closest image from buffer
pick df28f717 Rewrite init function to use new socket abstractions and helper funcs
update-ref refs/heads/feat/JIRA-001/optimize-buffer-sync-node-implementation

pick 03605221 Update cereal-IPC submodule with debug info
pick 366e5ba5 Create message bridge from server -> edge device
pick be938c04 Create message bridge on edge device to receive server msgs

You can even set this to be the default behaviour in Git 2.38 and later with

git config --global rebase.updateRefs true

For a deeper understanding of --update-refs, check out this article.


Footnote 1: The above post was 822 words long (including code). There are 822/6=137 days left in the year.

Footnote 2: I'm really playing fast and loose with this word count gimmick.

#git