Announcing jj-stack: stacked GitHub pull requests for Jujutsu

· teideal glic deisbhéalach ·

10 min read Original article ↗

I’m pleased to announce jj-stack, a new tool for managing stacked GitHub pull requests quickly and safely with Jujutsu.

Stacked changes let you (or your agent) split a large body of work into small, independently reviewable changes without waiting for each one to be reviewed or merged.

I built this because I wanted the stacked-review workflow I’d grown used to, with the local editing experience I’ve found so delightful in Jujutsu.

With jj-stack, your jj history defines the shape of your stack on GitHub: you can edit, reorder, split, or squash changes locally, then rerun submit to update the corresponding PRs safely.

---
config:
  block:
    padding: 32
---
block-beta
  columns 3
  L["Local jj"] space G["GitHub"]
  C["C: UI"] space P3["PR #3"]
  B["B: API"] space P2["PR #2"]
  A["A: model"] space P1["PR #1"]
  T["trunk()"] space M["main"]
  T --> A
  A --> B
  B --> C
  M --> P1
  P1 --> P2
  P2 --> P3
  A -.-> P1
  B -.-> P2
  C -.-> P3
  style L fill:none,stroke:none
  style G fill:none,stroke:none
Each local change has one pull request. Each PR shows the changes made relative to the one below it.

The main jj-stack page will get you running quickly. This post explains why I built it, the choices I made, and some version-control backstory that led me here.

Get started in moments

Install with uv:

If you’re signed in with the gh tool for command-line interaction with GitHub, jj-stack can use that login to authenticate. Run jj-stack doctor --fix inside your jj repo to check your setup and configure its PR book-keeping correctly. (As an example of the kinds of checks doctor performs, it looks for push permissions, because GitHub stacks currently work only when you have push permissions to the remote repo.)

There are only a few core stack commands:

  • submit takes your linear jj changes and submits them for review as a stack on GitHub. After you revise a stack, rerunning submit updates it on GitHub.

  • list shows all the stacks that jj-stack knows about in the repo, while view presents one in detail. (Pull requests are clickable hyperlinks if your terminal supports that.)

  • merge lands your work on GitHub after reviews and checks are green. It then rebases your local changes if required (e.g. after a squash merge). If GitHub finished a merge for you, through auto-merge, a merge queue, or the UI’s merge button, sync cleans up locally.

Scroll through the full transcript
# Create pull requests for A → B → C
 jj-stack submit
Selected: C: add UI (nvwmrnsq)
Submitted changes:
  nvwmrnsq  C: add UI: PR #3

  orqklzrw  B: add API: PR #2

  knmwrzxz  A: refactor shared model: PR #1

  trsrmlvw  base

Top of stack: PR #3
Created GitHub stack #1.
# Amend C in the scratch working copy
 printf 'keyboard navigation\n' >> ui.py
 jj squash
Working copy  (@) now at: xmqkktrn dac2361c (empty) (no description set)
Parent commit (@-)      : nvwmrnsq f6a098d7 C: add UI
# Update the same pull requests
 jj-stack submit
Selected: C: add UI (nvwmrnsq)
Submitted changes:
  nvwmrnsq  C: add UI: pushed, PR #3 unchanged

  orqklzrw  B: add API: already pushed, PR #2 unchanged

  knmwrzxz  A: refactor shared model: already pushed, PR #1 unchanged

  trsrmlvw  base

Top of stack: PR #3
Submit three changes, edit one, and submit again: each pull request stays associated with the right jj change.
Integrating into your toolset

When you install jj-stack, you can set it up to operate as a jj alias. Add this to your configuration with jj config edit --user:

[aliases]
stack = ["util", "exec", "--", "jj-stack"]

You can then run jj stack submit. The tool supports command-line completion for popular shells, including completion when used as a jj alias. For Zsh, add this to ~/.zshrc after compinit and any existing jj completion setup:

# The --jj-alias option means "this is installed under a jj alias named stack"
eval "$(jj-stack completion zsh --jj-alias stack)"

It’s easy to install the accompanying agent skill (read on for more details):

gh skill install bos/jj-stack jj-stack

GitHub permits each PR in a stack to contain several commits. jj-stack chooses a simpler model: one jj change becomes one PR. This gives the workflow a simple rule: the order of your local changes is the order of your GitHub PRs.

jj-stack also creates and manages the GitHub branches required to keep those PRs alive. You work with changes in jj; it keeps the bookkeeping branches out of sight.

These trade-offs will not suit everyone. If you need multi-commit PRs or want to manage and name every PR branch yourself (but why?), another tool may fit better. In exchange, jj-stack offers a smaller mental model and manages the complete stack lifecycle for you.

jj-spr

jj-spr is the closest alternative. Both tools map a jj change to a PR and keep that PR when you amend or rebase the change. jj-spr creates a parallel synthetic append-only history of your stack on GitHub, so reviewers can follow every update to each PR through GitHub’s commit list and review UI. jj-spr is currently incompatible with GitHub’s native stacks.

jj-stack updates the current version of each change and groups dependent PRs into a native GitHub stack. It manages the full lifecycle (reordering, whole-stack submission, merging, syncing local history, and cleanup) without a second parallel commit history. Reviewers can compare revisions of a PR through links in a PR history comment, but GitHub’s “Changes since your last review” view is currently empty for native GitHub stacks. (Yet another shortcoming of GitHub…)

jj-gh

jj-gh provides focused GitHub helpers that fit into a bookmark-driven workflow, including PR-aware logs, multi-commit PRs, CI retries, and auto-merge. That flexibility is valuable when you want to assemble your own workflow. jj-stack is more opinionated and takes on more of the work: it manages PR branches, submits the stack as a unit, and handles merging and cleanup. It doesn’t replace jj-gh’s log and CI conveniences.

gh stack

gh stack and jj-stack both manage native GitHub stacks, so your collaborators and reviewers get the same GitHub experience. gh stack models local stacks as named Git branches that may contain multiple commits, with dedicated commands for editing that branch chain. jj-stack derives the stack directly from your jj history.

Protecting your work and sanity

Keeping local jj history and a GitHub stack aligned is surprisingly hard: either side can change independently, and an interrupted operation can update only part of the stack. jj-stack checks both sides before it acts, and stops rather than guessing.

Just as jj works hard to not lose your work locally, jj-stack is careful to not lose your work in this loosely coupled distributed system. Commands that might change state on GitHub offer --dry-run previews. If it cannot make progress, it displays understandable error messages and helpful hints to get you back on track. Here are just a few real-world problems that it defends against, both by design and via extensive testing:

  • Your connection drops halfway through submitting a stack (this happened to me during a recent flight). GitHub may have received the new branches or created the PRs even though jj-stack never received the response. You’ll probably want to retry, and of course that must not create duplicate PRs, repeat a merge, or create two competing GitHub stacks. jj-stack carefully checks the current remote and GitHub state, recognises what has already succeeded, and completes only the missing work.

  • Someone rebases your stack using GitHub’s UI. GitHub creates new commits, which may remove the metadata jj uses to recognise change IDs. A simplistic sync could treat every PR as a new change or disconnect the existing PRs from your local work. jj-stack verifies the complete rebased stack by ancestry and file contents, and rebuilds it locally with your original change IDs. If the contents changed during the GitHub rebase (eek!), it stops rather than pretending the histories match.

  • You reorder or split changes after they have already been reviewed. Moving the bottom change in a stack to the top modifies every commit SHA and every PR base. Splitting a middle change introduces another PR in the middle of the stack. jj-stack keeps each existing PR attached to its logical change; preserves its reviews and discussion; creates PRs only for genuinely new changes; and updates the stack’s ordering. This involves navigating arcane stack-membership rules and footguns.

Some backstory

I’ve been weirdly in the orbit of version-control tools for literally decades. My first exposure was during my first year of college in Dublin, back in 1991, using DEC VMS and its built-in file versioning. I then discovered SCCS and RCS upon migrating to 4.3BSD Unix, and a year or two later started using CVS.

A few years later, I used an early distributed version control system called TeamWare while working at Sun. TeamWare was simultaneously way ahead of its time and a cursed amalgam of NFS and SCCS. It later inspired a much more sophisticated and powerful commercial tool, since largely forgotten but for becoming the forcing function behind Git and Mercurial.

Meanwhile, friends were working on Subversion. Unfortunately, it was intractably over-designed and slow from the get-go, so when the much simpler and faster Mercurial and Git projects kicked off, I jumped in on Mercurial.

Fast forward some more, and to my surprise I ended up at Facebook, where I migrated the company from git-svn to Mercurial, and kicked off years of work to keep our developer tools scaling ahead of the company’s enormous growth. That arc culminated in projects like Watchman, Sapling, and Mononoke, which together pushed the state of the art in modern source control.

Facebook open-sourced Watchman in 2013, and it has gone on to be widely used; Meta later released Sapling to a muted response; and it sadly hasn’t invested in external use of Mononoke, even though it is a key component of that entire stack. It’s a shame to see Sapling and Mononoke languish, because Git is a fiendishly poor fit for large monorepos, and a compelling alternative could have been highly influential.

While I was deep in trying to keep up with Meta’s scaling needs, some friends at Google had built a Mercurial front end to Piper, Google’s monorepo environment. That ultimately ended up providing inspiration for Jujutsu, which has become the first source-control tool in 20 years to offer a compelling alternative to Git.

After over a decade running teams of hundreds and then thousands of engineers, I returned to the startup world a little over a year ago. Having successfully avoided Git for two decades, I was naturally primed to try Jujutsu, and immediately found it to be a major improvement over Mercurial.

Years using Arcanist at Facebook had tuned my reflexes to stack-oriented development. I was pleasantly surprised that my team at Voxel was already using stacked changes, having used a commercial tool called Graphite for several years. So a few months ago, I began work on a tool to support stacked reviews for Jujutsu, just before GitHub announced that they were adding a native stack concept. The result of my efforts is jj-stack, a little over 10 years after my last commit to Mercurial.

Give it a try!

jj-stack is available now from PyPI. If you already use Jujutsu and GitHub, try the quick start and let me know how it fits your workflow.