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:
git log --decorate=short --pretty='format:%D' origin/main..
: Print all the branch pointers betweenHEAD
andorigin/main
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
sed 's/, /\n/g; s/HEAD -> //'
: Clean up that output:- Replace comma with a newline character (for branches pointing to the same commit)
- Remove the
HEAD ->
on the first line.
grep -v '^origin/'
: Don't worry about pointers to remote branches.xargs git push --force-with-lease origin
: Supply those branch names as arguments togit push
.- When rebasing, the force-push is useful. It's not necessary for the initial push.
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.