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

I usually work with stacked PRs. It is a great way to organize my work and keep my commits small. This empowers separation of concern and limit scope of each PR. Reviewers can go through each PR and review them one by one. Smaller changes, better feedback!

I was using sapling. It worked really well in the first month, but the honeymoon ended too soon. Read more...

Using sapling, I can view stacked commits with sl smart logs, edit many files and 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! In the first month, 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 10 PRs and got a temporary ban:

$ 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 pushed the remaining commits to GitHub.

So I decided to bring the sapling’s workflow to git with git-branchless, git-autofixup, and writing my own command, git-pr. Together, they work really well with my stacked PRs workflow. And I can use my Git knowledge to fix problems.

To see the workflow with stacked PRs in git-branchless/git-autofixup/git-pr, let’s take an 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 (1) create a new user, (2) verify that the user doesn’t exist, and (3) 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:

  1. Update the cache package to add a new method.
  2. Add the email package. It uses the cache package to prevent sending duplicated emails.
  3. Implement the sign-up logic. It depends on the two changes above.


Click to see the previous workflow with sapling

The stacked PRs workflow with sapling

Here are my most use 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 3 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 rebase 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 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 the remote repository and associate one PR for each commit.

The stacked PRs workflow with git

Now let’s see how we work with stacked PRs in git. First, we need to install a few things: github-cli, git-branchless, git-autofixup, and git-pr.

  • Install github-cli then run gh auth login.
  • Install git-branchless then run git branchless init in your repository.
  • Install git-autofixup by downloading the script and put 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
    
    # don't forget to login with gh cli
    gh auth login
    

Initialize git-branchless

After install git-branchless, we need to go to the repository directory and run the init command:

$ cd my-project
$ git branchless init 
Created config file at /Users/i/my-project/.git/branchless/config
Auto-detected your main branch as: main

This will generate a config file with the following content. It indicates that git-branchless has added a bunch of alias to your repository.

$ cat /Users/i/my-project/.git/branchless/config
[branchless "core"]
	mainBranch = main
[advice]
	detachedHead = false
[log]
	excludeDecoration = refs/branchless/*
[alias]
	amend = branchless amend
	hide = branchless hide
	move = branchless move
	next = branchless next
	prev = branchless prev
	query = branchless query
	record = branchless record
	restack = branchless restack
	reword = branchless reword
	sl = branchless smartlog
	smartlog = branchless smartlog
	submit = branchless submit
	sw = branchless switch
	sync = branchless sync
	test = branchless test
	undo = branchless undo
	unhide = branchless unhide

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 (sl stands for “smart logs”):

$ git sl                      # alias: gg
◇ bf31e38 3d (main) update something on main
┃
◯ 4f69280 1d update cache package
┃
◯ e0dbbc8 50m implement email package
┃
● 00f1749 30m implement user signup

Checkout a commit using git checkout (alias gco). Moving between commits in a stack using git next and git prev:

$ git checkout bf31e38        # alias: gco
$ git next                    # checkout at 4f69280
$ git next 2                  # checkout at 00f1749
$ git prev 1                  # checkout at e0dbbc8

You can use aliases to make it shorter. My most frequently used alias is gg for git sl:

$ gg                  # git sl
$ gco 4f69280         # git checkout 4f69280

Delete your local branches and hide unused commit with “git hide” (git-branchless)

Notice that we no longer need to use git branches. From now on, we’ll use commit hash instead. Let’s delete all local branches and hide all unused commits:

$ git branch -D branch1 branch2       # delete all local branches
$ git hide d7edab6 6b865dd            # hide all unused commits
Hid commit: d7edab6 a draft change
Hid commit: 6b865dd temporary commit
To unhide these 2 commits, run: git undo

undo mistake with “git undo” (git-branchless)

$ git undo
Will apply these actions:
1. Unhide commit d7edab6 a draft change

2. Unhide commit 6b865dd temporary commit

Confirm? [yN] y

Keep the git undo command in the back of your mind in case you need it.

Reword a commit message with “git reword” (git-branchless)

$ git reword 4f69280

This will open the editor to edit the commit message. After saving, the stack will be updated:

$ git sl
◇ bf31e38 3d (main) update something on main
┃
◯ b61494d 1s update cache package
┃
◯ 2016397 1s implement email package
┃
● a980f8c 1s implement user signup

Moving commits around with “git move” (git-branchless)

You can rebase a commit on top of another commit with git move:

$ git move -s 2016397 -d bf31e38

This will rebase the stack including the email commit and the signup commit on to main. After the command, the stack will look like this:

$ git sl         # alias: gg
◇ bf31e38 3d (main) update something on main
┣━┓
┃ ◯ 2016397 1s implement email package
┃ ┃
┃ ● f798adb 1s implement user signup
┃
◯ b61494d 1m update cache package

Checkout the cache commit and make changes with “git amend” (git-branchless)

Let’s checkout the cache commit and make some changes:

$ git checkout 4f69280    # alias: gco
$ vim lib/cache/cache.go  # make some changes to the cache package
$ git add -A              # alias: ga
$ git amend               # amend the changes to the cache commit (git-branchless)

Now run git sl (alias gg) to see the stack:

$ git sl
◇ bf31e38 3d (main) update something on main
┃
◯ a9ca4ac 1s update cache package
┃
◯ b6d516d 1s implement email package
┃
● 8a40bd4 1s implement user signup

The two other commits has been rebased and stacked nicely on top of the cache commit.
Alias gco="git checkout"
Alias ga="git add -A"
Alias gaa="git add -A && git amend"

Resolving conflicts while rebasing

Sometimes, you may run into a conflict. The error message will suggest to use --merge:

Attempting rebase in-memory...
This operation would cause a merge conflict:
• (1 conflicting file) 2016397 implement email package
To resolve merge conflicts, retry this operation with the --merge option.

Let’s run git move with --merge, resolve the conflict, and continue with git rebase --continue (alias grbc):

$ git move -s 2016397 -d bf31e38 --merge   # retry with --merge
$ git status                               # alias: gs
$ vim lib/cache/cache.go                   # resolve conflict
$ git add -A                               # alias: ga
$ git rebase --continue                    # alias: grbc

Alias gs="git status"
Alias ga="git add -A"
Alias grbc="git rebase --continue"

Make changes to multiple commits with “git autofixup” (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 2 new commits with the fixup! prefix. They are associated with the corresponding original commits and will be used to update them later.

$ git add -A                                 # alias: gaa                     
$ git autofixup origin/main                  # alias: gfix
$ git sl                                     # alias: gg
◇ bf31e38 3d (main) update something on main
┃
◯ a9ca4ac 4m update cache package
┃
◯ b6d516d 2m implement email package
┃
◯ 8a40bd4 2m implement user signup
┃
◯ 5f4da9b 1m fixup! implement user signup
┃
● c606fe9 1m fixup! implement email package

Finally, run git rebase --interactive --autosquash origin/main (alias grom) to absorb the changes into the original commits:

$ git rebase --interactive --autosquash origin/main   # alias: grom
$ git sl                                              # alias: gg
◇ bf31e38 3d (main) update something on main
┃
◯ a9ca4ac 6m update cache package
┃
◯ 5bdd29e 1m implement email package
┃
◯ aa025d1 1m implement user signup

Alias gfix="git autofixup origin/main"
Alias grom="git rebase --interactive --autosquash origin/main"

Pull commits from remote and rebase your local commits on top of origin/main

I will call the alias gsync to do all the work:

# in .bashrc or .zshrc config
alias gu="git pull --rebase --prune $@ && git submodule update --init --recursive"          
alias gsync="git checkout main && gu && git sync && git checkout origin/main"

# call it
$ cd my-project
$ git add -A && git stash    # clean up the repository before running gsync
$ gsync

It will do the following steps:

  • Check out the local main branch.
  • Pull commits from remote repository to the local main branch.
  • Update git submodules.
  • Call git sync from git-branchless to rebase local stacked commits.
  • Finally, checkout origin/main.

Push all commits and create stacked PRs with “git pr” (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 sl
◇ bf31e38 3d (main) update something on main
┃
◯ a9ca4ac 6m update cache package
┃
◯ 5bdd29e 1m implement email package
┃
◯ aa025d1 1m implement user signup

$ git add -A && git stash    # clean up the repository before running git pr
$ git checkout aa025d1       # checkout the last commit of the stack
$ git pr                     # submit all PRs to GitHub

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

  • A review link “👉 REVIEW 👈”, which reviewers can click to access the corresponding commit and add comments.
  • A list of all PRs or commits for that stack.

Tips for using “git-pr”

  • Each commit in the stack will become a separate PR.

  • An attribute Remote-Ref: iOliverNguyen/1be640ba will be appended to your commit message. It must be keep separate to the commit body with a blank line.

    [draft][flow][compiler] current output of loop flow
    
    Summary
    ---
    Run tests inside the flow compiler package
    and check the `testoutput` directory.
    
    Remote-Ref: iOliverNguyen/1be640ba
    
  • You can not use # to input header in the message, because git will see it as comments and ignore. Instead, use ---- and ==== to make headers.

  • Add [draft] to the commit message title before running git pr to set the PR status to draft. Remove [draft] to change back.

  • No need to wait for other people PR to get merged to main, because your stack can include commits from other people. When run git pr, only your commits get submitted as new PRs.

Question: I accidentally amended all my changes to the last commit. How to split it?

Suppose we have this stack. And we were at the last commit, made changes to files related to both cache and email package:

$ git sl
◇ bf31e38 3d (main) update something on main
┃
◯ a9ca4ac 10m update cache package
┃
◯ b6d516d 10m implement email package
┃
● 8a40bd4 10m implement user signup

Let’s fix it:

$ git checkout 8a40bd4     # checkout the last commit                     (alias: gco)
$ git add -A && git amend  # and amend all the changes to the last commit (alias: gaa)

$ git checkout a9ca4ac                 # checkout the first commit (cache)
$ git checkout 8a40bd4 -- lib/cache    # copy all changed file in cache package
$ git add -A && git amend              # amend it to the commit

$ git sl                               # check the stack again, with new commit hash
◇ bf31e38 3d (main) update something on main
┃
◯ f4e0713 1s update cache package
┃
◯ 0980f8c 1s implement email package
┃
● f7d1e37 1s implement user signup

$ git checkout 0980f8c                 # checkout the second commit (email)
$ git checkout f7d1e37 -- pkg/email    # and do that again
$ git add -A && git amend

Now, all changes will be in place. The cache changes get amended to the cache commit and the email changes get amened to the email commit. The last commit no longer contains changes from other commits. 🎉

Bonus: Some useful aliases

Here are a list of my frequently used aliases:

alias g='git'
alias gg='git sl'                      # smart logs
alias gs='git status'
alias gp='git push -u'
alias ga='git add'
alias gaa='git add -A && git amend'
alias gcm='git commit'
alias gco='git checkout'
alias gcp='git cherry-pick'
alias grs='git reset --mixed HEAD~1 ; gg ; gs'
alias gcom='git checkout origin/main'

# checkout the main branch
alias gmain='git checkout main'

# checkout the latest commit from the main branch and show commit tree
alias gm='git checkout origin/main ; gg'

# commit the current changes as fixup commits
alias gfix='git autofixup origin/main'

# rebase the current branch on the origin/main branch, and squash fixup commits
alias grom='git rebase --interactive --autosquash origin/main'

# pull, rebase, and update submodules
alias gu='git pull --rebase --prune $@ && git submodule update --init --recursive'

# checkout the main branch, pull, rebase, and checkout the main commit
alias gsync='gmain && gu && git sync && gcom && gg'

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, at least we can still be able to fix it with our git knowledge and plenty of available tools.

Author

I'm Oliver Nguyen. A software maker working mostly in Go and JavaScript. I enjoy learning and seeing a better version of myself each day. Occasionally spin off new open source projects. Share knowledge and thoughts during my journey. Connect with me on , , , and .

Back Back