Manage PR stacks/chains on Github
I use this tool to help managed stacked pull requests on Github, which are notoriously difficult to manage manually. Here are a few examples:
This tool assumes that:
It then looks for all PRs containing this containing this identifier and builds a dependency graph in memory. This can technically support a "branched stack" instead of a single chain, but I haven't really tried the latter style. With this graph built up, the tool can:
Building from source is the only option at the moment:
# Install Rust
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Configure `PATH`
$ export PATH="$HOME/.cargo/bin:$PATH"
# Install `gh-stack`
$ cargo install gh-stack
$ export GHSTACK_OAUTH_TOKEN='<personal access token>'
$ gh-stack
USAGE:
gh-stack <SUBCOMMAND>
FLAGS:
-h, --help Prints help information
SUBCOMMANDS:
annotate Annotate the descriptions of all PRs in a stack with metadata about all PRs in the stack
autorebase Rebuild a stack based on changes to local branches and mirror these changes up to the remote
log Print a list of all pull requests in a stack to STDOUT
rebase Print a bash script to STDOUT that can rebase/update the stack (with a little help)
# Idempotently add a markdown table summarizing the stack
# to the description of each PR in the stack.
$ gh-stack annotate 'stack-identifier'
# Same as above, but precede the markdown table with the
# contents of `filename.txt`.
$ gh-stack annotate 'stack-identifier' -p filename.txt
# Print a description of the stack to stdout.
$ gh-stack log 'stack-identifier'
# Automatically update the entire stack, both locally and remotely.
# WARNING: This operation modifies local branches and force-pushes.
$ gh-stack autorebase 'stack-identifier' -C /path/to/repo
# Emit a bash script that can update a stack in the case of conflicts.
# WARNING: This script could potentially cause destructive behavior.
$ gh-stack rebase 'stack-identifier'
This is a quick overview of the ways this tool could be used in practice.
Write some code, create local commits/branches:
$ git checkout -b first
# Write code
$ git add -A; git commit -m 'first'
$ git checkout -b second
# Write code
$ git add -A; git commit -m 'second #1'
# Write code
$ git add -A; git commit -m 'second #2'
$ git checkout -b third
# Write code
$ git add -A; git commit -m 'third'
Your Git tree now looks like:
* 42315c4 U - (third) third
|
* 6db2c28 U - (second) second #2
|
* 5746a83 U - second #1
|
* e845ded U - (first) first
|
* 8031011 U - initial commit
Push each branch:
$ git push origin first:first second:second third:third
* [new branch] first -> first
* [new branch] second -> second
* [new branch] third -> third
Create a PR for each new branch (starting at first
), and:
[EXAMPLE-17399]
here). This identifier (currently) is required to be unique across all GitHub repositories accessible to you (including all public repositories).base
for each PR to the branch preceding it. Here, first
's PR is set to merge into master
, second
's PR is set to merge into first
, and third
's PR is set to merge into second
.Log all PRs in the stack:
$ gh-stack log 'EXAMPLE-13799'
#1: [EXAMPLE-13799] PR for branch `first` (Base)
#2: [EXAMPLE-13799] PR for branch `second` (Merges into #1)
#3: [EXAMPLE-13799] PR for branch `third` (Merges into #2)
Annotate all PRs with information about the stack:
$ gh-stack annotate 'EXAMPLE-13799'
1: [EXAMPLE-13799] PR for branch `first`
2: [EXAMPLE-13799] PR for branch `second`
3: [EXAMPLE-13799] PR for branch `third`
Going to update these PRs ☝️ Type 'yes' to continue: yes
Done!
This (idempotently) adds a table like this to the description of every PR in the stack:
Make changes to a branch that rewrites commits in some way (amend, remove a commit, combine commits):
$ git checkout first
# Do some work
$ git add -A; git commit --amend -m 'amended first'
History has now diverged, and this will cause conflicts with dependent PRs when first
is (force-)pushed.
* e7cb9c6 U - (HEAD -> first) amended first
|
| * 42315c4 N - (origin/third, third) third
| |
| * 6db2c28 N - (origin/second, second) second #2
| |
| * 5746a83 N - second #1
| |
| * e845ded N - (origin/first) first
|/
|
* 8031011 U - (origin/master, master) initial commit
Use the autorebase
subcommand to fix this inconsistency (it requires a path to a local checkout of the repository):
$ gh-stack autorebase --repo /tmp/test EXAMPLE-13799
Checking out Commit { id: 803101159653bf4bf92bf098e577abc436458b17, summary: "initial commit" }
Working on PR: "first"
Cherry-picking: Commit { id: e7cb9c6cdb03374a6c533cbf1fc23a7d611a73c7, summary: "amended first" }
Working on PR: "second"
Cherry-picking: Commit { id: 5746a83aed004d0867d52d40efc9bd800b5b7499, summary: "second #1" }
Cherry-picking: Commit { id: 6db2c2817dfed244d5fbd8cbb9b8095965ac9a05, summary: "second #2" }
Working on PR: "third"
Cherry-picking: Commit { id: 42315c46b42044ebc4b57a995a75b97699f4855a, summary: "third" }
["b45e5838a93b33411a5f0c9f726bc1987bc71ff5:refs/heads/first", "93170d2199ed9c2ae30d1e7492947acf477fb035:refs/heads/second", "a85a1931c44c3138d993128591af2cad2ef6c68d:refs/heads/third"]
Going to push these refspecs ☝️ Type 'yes' to continue: yes
Enumerating objects: 12, done.
Counting objects: 100% (12/12), done.
Delta compression using up to 8 threads
Compressing objects: 100% (8/8), done.
Writing objects: 100% (11/11), 907 bytes | 453.00 KiB/s, done.
Total 11 (delta 3), reused 0 (delta 0)
remote: Resolving deltas: 100% (3/3), done.
To github.com:timothyandrew/test.git
+ e845ded...b45e583 b45e5838a93b33411a5f0c9f726bc1987bc71ff5 -> first (forced update)
+ 6db2c28...93170d2 93170d2199ed9c2ae30d1e7492947acf477fb035 -> second (forced update)
+ 42315c4...a85a193 a85a1931c44c3138d993128591af2cad2ef6c68d -> third (forced update)
Updating local branches so they point to the new stack.
+ Branch first now points to b45e5838a93b33411a5f0c9f726bc1987bc71ff5
+ Branch second now points to 93170d2199ed9c2ae30d1e7492947acf477fb035
+ Branch third now points to a85a1931c44c3138d993128591af2cad2ef6c68d
All done!
This restores local history to a flat list and pushes the tip of each branch up to update the PRs themselves.
* a85a193 N - (HEAD, origin/third, third) third
|
* 93170d2 N - (origin/second, second) second #2
|
* 61f64b6 N - second #1
|
* b45e583 N - (origin/first, first) amended first
|
* 8031011 U - (origin/master, master) initial commit
If conflicts are encountered, autorebase
will pause and allow you to fix the conflicts before resuming.
This is a quick summary of the strategy the autorebase
subcommand uses:
merge_base
between the local branch of the first PR in the stack and the branch it merges into (usually develop
). This forms the boundary for the initial cherry-pick. This is a heuristic and is not suitable for all situations, especially when changes have already been pushed or PRs are merged directly on GitHub. Accept an explicit boundary for the initial cherry-pick to avoid ambiguity here.develop
). We're going to cherry-pick the entire stack onto this commit.HEAD
.HEAD
.git push -f
.Use at your own risk (and make sure your git repository is backed up), especially because:
autorebase
command is in an experimental state; there are possibly edge cases I haven't considered.