In this post I will discuss a Git workflow I have been using to manage
interdependent feature branches.
With it, each branch maps to a Pull Request (or analogous abstraction in the
Git service of your choice).
You use git rebase
to move around or "restack" dependent branches as you make
new changes.
1. Safety
People can be wary of git rebase
, and with good reason.
If you are unsure about what you are doing, it’s easy to mess up your checkout.
To that end, remember the following.
- Never rebase shared branches
-
Do not rebase any remote branch that other people are committing to. Feel free to rebase local branches and remote branches that you are certain you own. I find it useful to prefix branches with my name to inform collaborators that they are "private" (
git checkout -b $(whoami)/myfeature
). - Never force push on shared branches
-
Rebasing a branch rewrites its history. If you rebase a branch after pushing it to a remote, you need to use
git push --force
to update it. Doing so for shared branches breaks your collaborators’ local checkouts.
Generally speaking, understand what git rebase
does and be mindful of your
collaborators.
2. Rebasing stacks
My coworkers and I often have feature branches that depend on each other. Newer branches depend on changes made in older branches. Each branch corresponds to its own Pull Request, making for individually reviewable and mergeable units of code. We refer to these as review stacks.
For example, consider the following.
We have three feature branches in this scenario.
The changes in feature3
have a hard dependency on changes in feature2
,
which in turn have a hard dependency on feature1
.
A problem with this workflow is that it’s tedious to rebase these
branches. For example, suppose that main
moves and we need to move all of
these on top of that.
Doing that is quite a manual process:
|
|
|
|
|
|
|
An obvious improvement we can make here is to perform this work in a single rebase call.
3. Rebasing once
Rather than rebasing branches individually, we can start at the top of the stack and rebase all the commits at once. This is insufficient, though, because it does not update the other branches.
$ git checkout feature3
$ git rebase main
We must manually update those with git branch
,
passing -f
/--force
to move the existing branches.
|
|
|
This is still tedious. We can improve on this by moving the branches during the rebase rather than afterwards.
4. Moving during the rebase
We can move branches while rebasing with -i
/--interactive
.
Interactive rebase gathers the affected commits as a list of pick
instructions.
For example, git rebase -i main
on feature3
will give us an instruction
list like the following.
pick aaaaa1 Add functionality pick bbbbb1 Bind configuration pick ccccc1 Create handlers pick ddddd1 Delete placeholders pick eeeee1 Exercise corner cases
Interactive rebase supports the x
/exec
instruction,
which lets you execute shell commands.
By interleaving exec git branch -f
instructions between pick
instructions,
we can move branches as we rebase their commits.
pick aaaaa1 Add functionality
pick bbbbb1 Bind configuration
+exec git branch -f feature1
pick ccccc1 Create handlers
pick ddddd1 Delete placeholders
+exec git branch -f feature2
pick eeeee1 Exercise corner cases
Each exec git branch -f
instruction will update the branch to include
the prior `pick`ed commits.
$ git checkout feature3
$ git rebase -i main
pick aaaaa1 Add functionality
pick bbbbb1 Bind configuration
exec git branch -f feature1
pick ccccc1 Create handlers
pick ddddd1 Delete placeholders
exec git branch -f feature2
pick eeeee1 Exercise corner cases
This improves on the previous version but it requires remembering all the branches and matching them to commit titles. We can make it more convenient.
5. Making it convenient
Git’s rebase.instructionFormat
configuration allows changing the
format of the Rebase instruction list.
rebase.instructionFormat A format string, as specified in git-log(1), to be used for the todo list during an interactive rebase. [...]
The default for this is %s
, the commit subject.
The template also supports %d
:
%d: ref names, like the --decorate option of git-log(1)
This will print the names of branches and tags associated with a commit, if any. We can configure Git to use it like so,
$ git config --global rebase.instructionFormat "%s%d"
Our instruction list for rebasing feature3
on top of main
now looks like
this.
pick aaaaa1 Add functionality pick bbbbb1 Bind configuration (feature1) pick ccccc1 Create handlers pick ddddd1 Delete placeholders (feature2) pick eeeee1 Exercise corner cases
We no longer need to remember the names and positions of the other
feature branches.
Git tells us where we need to add exec git branch -f
instructions.
$ git checkout feature3 $ git rebase -i main
pick aaaaa1 Add functionality
pick bbbbb1 Bind configuration (feature1)
+exec git branch -f feature1
pick ccccc1 Create handlers
pick ddddd1 Delete placeholders (feature2)
+exec git branch -f feature2
pick eeeee1 Exercise corner cases
This helps but it requires manual work. We can automate this.
6. Making it automatic
Let’s give the reconfigured instruction list another glance.
pick aaaaa1 Add functionality pick bbbbb1 Bind configuration (feature1) pick ccccc1 Create handlers pick ddddd1 Delete placeholders (feature2) pick eeeee1 Exercise corner cases
We can write a program to search this text for pick
lines ending with
(…)
and add exec git branch -f
instructions.
Here’s a simplified version of a Perl script authored by Kris Kowal
to do this.
#!/usr/bin/env perl
while (<>) {
# An empty line indicates the end of the instruction list.
last if /^\n$/;
print;
# Look for pick instructions ending with (...).
next unless /^pick /;
next unless m/\(([^)]+)\)$/g;
# The parentheses can contain multiple branch names. All these
# branches must be updated.
for (split /, ?/, $1) {
next if / /; # no spaces in branch names
# Verify that the name refers to a known branch.
next unless `git show-ref --verify "refs/heads/$_" 2>/dev/null`;
print "exec git branch -f $_\n";
}
}
# Print the remaining content as-is.
while (<>) { print; }
Place this script on your $PATH
with the name restack
,
and configure your editor to run this script when it detects an interactive
rebase.
In vim
, this takes the following form.
autocmd Filetype gitrebase silent %!restack
With this configuration, interactive rebase instruction lists come pre-populated with branch updates.
$ git checkout feature3
$ git rebase -i main
pick aaaaa1 Add functionality
pick bbbbb1 Bind configuration (feature1)
exec git branch -f feature1
pick ccccc1 Create handlers
pick ddddd1 Delete placeholders (feature2)
exec git branch -f feature2
pick eeeee1 Exercise corner cases
Our workflow is now fully automated but there’s a smell to configuring your editor for a change in Git. We should fix that.
7. Making it nice: Restack
The sequence.editor
configuration allows changing the editor used for
interactive rebases.
sequence.editor Text editor used by git rebase -i for editing the rebase instruction file. [...]
We can stick a program in there that handles adding the exec branch -f
instructions before calling the real editor.
We can also have it look up branch names for commit hashes, obviating the need
to change rebase.instructionFormat
.
I did this and packaged it all up into Restack. With Restack, the setup above abbreviates to a single command.
$ restack setup
Following that, your interactive rebases will include branch updates in the instruction list.
For more, see https://github.com/abhinav/restack.
8. Conclusion
What started off as a tedious workflow ended up in my preferred way to interact with interdependent feature branches and their corresponding Pull Requests.
I recommend giving this workflow a try if you find yourself submitting massive Pull Requests, or Pull Requests with unrelated commits in them. Try it out with Restack or set it up by hand with the Perl script above.
9. Credits
Credit for optimizing this workflow all the way down to the Perl script goes to Kris Kowal. I was a rubber duck along the way, and packaged it up into Restack at the end.