Working With Stacked PRs using git-branchless, git-autofixup, and git-pr

Work with stacked PRs easily within the git world

Oliver Nguyen
Better Programming
Published in
7 min readFeb 22, 2023

--

Photo by Rich Tervet on Unsplash

I usually work with stacked PRs. It is a great way to organize my work. I was using sapling, and it works really well. I can view stacked commits with sl smart logs, edit many files, make changes to many commits at the same time with sl absorb, have the stack automatically rebase after each change, and push these commits as multiple stacked GitHub PRs with a single sl pr command. It’s great! I was very happy with sapling. You can read more about it in my last article.

But the honeymoon ended when I wanted to push a stack with 17 commits to GitHub. I reached GitHub’s rate limit after creating ten PRs and got a temporary ban that looked liked this:

$ sl pr 2>&1 | go run ~/ws/conn/be/go/scripts/slpr
pushing 17 to https://github.com/myorganization/backend.git
created new pull request: https://github.com/myorganization/backend/pull/3310
...
created new pull request: https://github.com/myorganization/backend/pull/3319
abort: error creating pull request for f05689dca505b3ad1b526a220d7a15f46b4a9511: {
"message": "You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later.",
"documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#secondary-rate-limits"
}

This left my sapling repository in a bad state. The local commits and the GitHub PRs no longer match. Even after the ban lift, I could not push the remaining commits to GitHub anymore:

$ sl pr
pushing 8 to https://github.com/myorganization/backend.git
abort: `git --git-dir /Users/i/ws/conn/be/.sl/store/git push --force https://github.com/myorganization/backend.git 447c5d073cbadd4bcc251bf8bcd46d9ec4f728bd:refs/heads/pr3320 3f0d1e3103e5246e29806f44b87f4e9289749202:refs/heads/pr3320 7403ecce2590066177a23923bbd509598fe32781:refs/heads/pr3321 8750722250395b8d7e5a2e624a5c65a42ee817e0:refs/heads/pr3322 b543123ebadcca0b1de95293fd551752e1fa0c43:refs/heads/pr3323 263fde607355872bb47305168bf1907d673b0249:refs/heads/pr3324 5b590681dbe1394b7cae9a7d1e9f823205da3cc7:refs/heads/pr3325 17137b286aa5376615a77e58f3ef71bf02a3398f:refs/heads/pr3326` failed with exit code 1: stdout:
stderr: error: dst ref refs/heads/pr3320 receives from more than one src
error: failed to push some refs to 'https://github.com/myorganization/backend.git'

The problem is that I do not know the internals of sapling enough to be able to fix it. I had to switch back to git and manually push the remaining commits to GitHub. So I decided to bring the sapling’s workflow to git with git-branchless, git-autofixup, and write my own command, git-pr. Together, they work well with my stacked PRs workflow, and I can use my Git knowledge to fix problems if they happen.

To see how working with stacked PRs in sapling and in git-branchless/git-autofixup/git-pr, let’s come back with the last example from developing the user sign-up feature: user inputs their email address, password, and we send them a nice welcome email. From the backend perspective, we need to create a new user, verify the user doesn’t exist, and send the email. We need to touch the implementation of the cache package, add an email package, then finally implement the sign-up logic. This can be represented as a stack of PRs:

The Stacked PRs Workflow With Sapling

Here are my most used commands while working with sapling:

1. View the stacked commits and PRs

$ sl
o bf31e38d1 Today at 04:14 remote/main

│ @ 00f1749f6 30 minutes ago oliver
│ │ implement user signup
│ │
│ o e0dbbc80e 50 minutes ago oliver
│ │ implement email package
│ │
│ o 4f6928029 Yesterday at oliver
├─╯ update cache package

We have a stack with three commits.

2. Edit a commit message with sl metaedit

sl metaedit 4f6928029

Update the commit message for the cache commit. And automatically rebase all the commits above.

3. Make changes to multiple commits with sl absorb

sl goto 00f1749f6              # checkout the sign up code
vim features/signup/signup.go # make changes to signup package
vim lib/email/email.go # make changes to email package

sl absorb # magic 👻

With a single command sl absorb, the signup.go changes will be amended to the signup commit, the email.go changes will be amended to the email commit, and the commits will be automatically rebased onto each other.

4. Rebase a stack of commits with sl rebase -s

sl rebase -s 4f6928029 -d remote/main

The stack will be rebased onto the remote/main.

4. Undo mistakes with sl undo

sl undo

5. Push all commits and create stacked PRs with sl pr

$ sl pr
pushing 3 to https://github.com/myorganization/backend.git
created new pull request: https://github.com/myorganization/backend/pull/2810
created new pull request: https://github.com/myorganization/backend/pull/2811
created new pull request: https://github.com/myorganization/backend/pull/2812

Push all the commits to the remote repository and associate one PR for each commit.

Back to Git World

Now, let’s see how we can achieve similar results with git. We will need to install a few things: github-cli, git-branchless, git-autofixup, and git-pr.

  • Install github-cli with brew install gh then run gh login.
  • Install git-branchless with cargo install --locked git-branchless then run git branchless init.
  • Install git-autofixup by downloading the binary and putting it in your $PATH.
  • Install git-pr and put it in your $PATH:
git clone https://github.com/iOliverNguyen/git-pr
cd git-pr
go install .
export PATH=$PATH:~/bin/go # put git-pr in your $PATH

View Stacked Commits With “Git Sl” (Git-Branchless)

After running git branchless init in your repository, you will have access to a few useful commands to manage stacked commits. Let’s start with git sl to view the stack:

$ git sl
◇ bf31e38 3d (main) update something on main (#3102)

◯ 4f69280 1d update cache package

◯ e0dbbc8 50m implement email package

● 00f1749 30m (oliver/signup) implement user signup

Notice that I still create a branch oliver/signup to point to the last commit in the stack.

Restack Commits With “Git Restack” (Git-Branchless)

Let’s make some changes to the cache commit:

$ git checkout 4f69280    # check out the cache commit
$ git commit --amend # make some change to the commit message
$ git sl
◇ bf31e38 3d (main) update something on main
┣━┓
┃ ✕ 4f69280 1d (rewritten as a9ca4ac) update cache package
┃ ┃
┃ ◯ e0dbbc8 50m implement email package
┃ ┃
┃ ◯ 00f1749 30m (oliver/signup) implement user signup

● a9ca4ac 1m update cache package

Now run git restack to fix the stack:

$ git restack
Attempting rebase in-memory...
...
◇ bf31e38 3d (main) update something on main (#3102)

◯ a9ca4ac 2m update cache package

◯ b6d516d 0s implement email package

● 8a40bd4 0s (oliver/signup) implement user signup

All the commits after the cache commit will be rebased on the previous one, with the branch name pointing to the last commit. Compared to sapling, these commands achieve a similar effect as sl metaedit or sl amend.

Make Changes and Automatically Restack With “Git Amend” and “Git Reword” (Git-Branchless)

The above can be simplified by using git amend and git-branchless will automatically rebase subsequent commits. You can also edit a commit message (and automatically rebase) with git reword:

git amend
git reword a9ca4ac

By using these commands provided by git-branchless, we do not need to work with branches anymore. And git-branchless
with take care of all the rebasing for us.

Make Changes to Multiple Commits With “Git Autofixup”

Let’s make some changes:

git checkout oliver/signup     # checkout the sign up code
vim features/signup/signup.go # make changes to signup package
vim lib/email/email.go # make changes to email package

Now, add the changes as fixup! commits. You will notice that there are two new commits with the fixup! prefix. They are associated with the corresponding original commits and will be used to update them later.

$ git add .
$ git autofixup origin/main
$ git sl
◇ bf31e38 3d (main) update something on main (#3102)

◯ a9ca4ac 4m update cache package

◯ b6d516d 2m implement email package

◯ 8a40bd4 2m implement user signup

◯ 5f4da9b 1m fixup! implement user signup

● c606fe9 1m (oliver/signup) fixup! implement email package

Finally, run git rebase --interactive --autosquash to absorb the changes into the original commits:

$ git rebase --interactive --autosquash origin/main
$ git sl
◇ bf31e38 3d (main) update something on main (#3102)

◯ a9ca4ac 6m update cache package

◯ 5bdd29e 1m implement email package

◯ aa025d1 1m implement user signup

Together, these commands achieve a similar effect of sl absorb.

Undo Mistake With “Git Undo” (Git-Branchless)

git undo

Push All Commits and Create Stacked PRs With Git Pr

When everything is ready, let’s run git pr to push all the commits to GitHub. A PR will be created for each commit. They will be stacked onto each other:

git pr

Here’s what a PR in the stack will look like:

  • A review link, which reviewers can click to access the corresponding commit and add comments.
  • A list of all PRs for that stack.

Recap

🎉 That’s it! Now we can work with stacked PRs easily within the git world, compatible with other familiar git commands. And when something goes wrong, we can fix it with our git knowledge and plenty of available tools.

Want to Connect?

I'm Oliver Nguyen, a software maker working mostly in Go and JavaScript.

Visit my blog at olivernguyen.io or connect with me on
GitHub, LinkedIn, and Twitter.

--

--

Oliver Nguyen
Oliver Nguyen

Written by Oliver Nguyen

A software developer sharing about Go and JavaScript ⎯ olivernguyen.io

No responses yet