Skip to main content
/

GitHub Stacked PRs Are Real Now, and They Are Good

Jacob Coffee··1k words·10 min read·

I shipped a four-PR stack to pypi/warehouse with the new gh stack CLI. Here is the workflow, the sharp edges, and why I am keeping it.

GitHub shipped native stacked pull requests. A stack is a chain of PRs where each one targets the branch below it instead of main. Reviewers see one small diff per PR, CI runs on every layer, and merging the top PR lands the whole chain in one click. I used it to ship four PRs to pypi/warehouse and it was pretty fun.

Why though?

Big PRs rot. They are hard to review fully and breaking them up into chunks helps incrementally ship things (if they can be!) I had a chunk of work on PyPI's organizations pages: a new landing page, a homepage link to it, a callout on the org management page, and the same callout on org settings. As one PR that is a decent sized line diff touching templates, routes, SCSS, and tests.

Nobody reviews that well, or maybe people only have time to review smaller chunks in passing. By the time they have more.. well.. time to review the whole they they have to re-contextualize and it can become frustrating. It's also a nice bonus that I can ship 1-2 layers of the stack and see some progress (which gives the little dopamine boost!)

Pushing the stack to origin. All four PRs go at once!
Pushing the stack to origin. All four PRs go at once!

As a stack it is four PRs, each under a hundred or two hundred lines, each reviewable in a few minutes, and the later ones can depend on code from the earlier ones without anyone merging anything yet. Honestly the examples I've done to test this out are a little stretch from being silly - one (or two?) of the PRs is just a single line change with 3 lines and the tests to support it.

Before this you needed Graphite or something else (probably some shell script madness someone made.) Now the platform understands the shape: the PR page shows a stack map, branch protection applies against the real target branch, and a partial merge automatically retargets whatever is left.

Setup

gh extension install github/gh-stack

The extension offers gh stack alias to shorten the command to gs. Skip that if gs already means git status in your shell, which it does for me because typing laziness. ghs will probably be the stack alias for me.

My first three gs init runs printed a clean git status and did nothing else. Shell aliases beat anything on $PATH, so the new binary never ran.

The workflow

Starting fresh:

gh stack init landing-page      # creates and checks out the first branch
# commit as usual
gh stack add homepage-link      # next layer, branched from the previous one
# commit
gh stack add settings-callout
# commit
gh stack push                   # pushes every branch in one go
gh stack submit                 # opens all the PRs, bases wired up

The change from a normal flow: you stop pushing and opening PRs per branch. Commit as you go, then one push and one submit at the end.

If a branch already exists, gh stack init adopts it. I had already committed and pushed my first branch before installing the extension, ran gh stack init on it, and it picked the branch up as the bottom layer without complaint.

submit opens a terminal UI where you title each PR, choose Ready or Draft, and include or exclude layers. Nothing exists on GitHub until you press ctrl-s, so pressing escape to go finish something else costs nothing. Draft still runs CI on every layer, which is a decent way to get green checks before humans look.

The TUI for gh stack CLI
The TUI for gh stack CLI

Editing a lower layer

This is where stacks used to hurt. Review feedback lands on PR 1 while you are three layers up. With the CLI:

gh stack switch        # pick the lower branch
# fix, commit
gh stack rebase        # every layer above rebuilds on the new tip

I edited the bottom layer of my stack after three layers sat on top of it. One gh stack rebase later all four branches were consistent, including a rebase onto current main:

Rebasing branches in order, starting from orgs-promo/1-landing-page to orgs-promo/3-manage-orgs-callout
✓ Rebased orgs-promo/1-landing-page onto main
✓ Rebased orgs-promo/2-homepage-mention onto orgs-promo/1-landing-page
✓ Rebased orgs-promo/3-manage-orgs-callout onto orgs-promo/2-homepage-mention

init also offers to turn on git rerere, so a conflict you resolve once during a cascade stays resolved on the next one. Say yes.

The top of the GitHub pull showing the stack
The top of the GitHub pull showing the stack

Merging

Click merge on the highest PR you want to land. GitHub merges it and everything under it, bottom up. Merge a middle PR and the layers above stay open and get rebased onto the new main automatically. You review four small PRs and land them with one button.

The GitHub CI area showing the stack
The GitHub CI area showing the stack

Some sharp edges

  • One rule keeps a stack pleasant: commit to the layer the change belongs to. If you spot a typo from layer 1 while working on layer 3, switch down and fix it there, then cascade. Fixing it in layer 3 defeats the per-layer review.
  • Layers live in one working tree. If you keep one worktree per branch, drop that habit inside a stack; gh stack add creates and checks out branches in place. Separate worktrees still make sense for separate stacks. Pairs well with agents.
  • Uncommitted changes block gh stack switch when the file differs between layers. Commit or stash first, same as any branch switch.
  • The TUI is really cool but early work (it is a preview after all!)

Verdict

The best tooling change GitHub has shipped since Actions and codespaces IMO! It turned "one scary PR or four branches of manual rebase pain" into a workflow I set up in a minute and trusted the same night, on a production codebase. Install it, break your next big change into layers, and your reviewers will be super thankful. It is a little annoying to see the Slack channel spam, though… It'd be neat to be able to group stack creations into one webhook send to the Slack/Discord/Teams chat.

Slack spam!
Slack spam!