Jujutsu + SPR: Stacked PRs Guide

How SPR Tracks PRs

SPR’s only tracking mechanism is the Pull Request: https://... line in jj commit descriptions. There is no cache, database, or config file. Every jj spr diff reads descriptions, matches URLs to GitHub PRs, and pushes synthetic commits accordingly.

SPR also creates synthetic base branches for stacked PRs (spr/<user>/master.<title>). These branches contain a synthetic commit whose tree matches the parent change’s tree, making GitHub show only each PR’s own diff.

The Mental Model

Local jj                          GitHub
─────────                         ──────
change-A  ──PR URL──>  PR #1 (base: master)
  │
change-B  ──PR URL──>  PR #2 (base: spr/.../master.<title-B>)
  │
change-C  ──PR URL──>  PR #3 (base: spr/.../master.<title-C>)

Each PR targets a synthetic base branch. At land time, jj spr land retargets to master before squash-merging.

Landing: Always Bottom-Up, Always via jj spr land

# 1. Land the bottom PR
jj spr land -r <change-A>
 
# 2. Sync
jj git fetch
 
# 3. Abandon the now-empty change and rebase remaining stack
jj abandon <change-A>
jj rebase -s <change-B> -d main@origin
 
# 4. Update remaining PRs (refreshes synthetic base content)
jj spr diff -r <change-B>::<change-C>

Repeat for each PR in the stack.

What Goes Wrong

Scenario 1: Merging on GitHub Instead of jj spr land

If a PR targets a synthetic base branch and you click “Merge” on GitHub:

  • The squash-merge goes to the synthetic base branch, not master
  • Master is NOT updated
  • Your code lives on a dead-end branch

Recovery:

  1. Check if master was updated: git log origin/master --oneline -5
  2. If not, cherry-pick or close-and-recreate to get content onto master
  3. Clean up orphan branches: delete the synthetic base branch manually

If a PR targets master directly (first in stack, or single PR), a GitHub merge works but skips SPR’s cleanup. You’ll need manual cleanup.

Scenario 2: Out-of-Order Merges

When PRs were created as a stack (A → B → C) but merged in a different order (e.g., A merged, then C, then B), the synthetic base branches become stale because the content they were based on has changed.

Symptoms:

  • PRs show huge diffs on GitHub (including parent content)
  • Local changes become empty after rebase (content already on master)
  • Ghost changes clutter the jj log

Scenario 3: PRs Targeting Stale Synthetic Bases After Parent Merge

After landing the bottom of a stack and running jj spr diff on the remaining range, the next PR still targets its synthetic base branch — not master. This is normal and expected. The diff is correct, and jj spr land retargets to master automatically at merge time.

Do NOT use gh pr edit --base master — it bypasses SPR’s synthetic commit management.

Diagnosing a Stale Stack

Step 1: Map Changes to PRs

# List all changes with their PR URLs
for c in $(jj log -r 'ancestors(@, 20) & ~ancestors(trunk(), 1)' \
  -T 'change_id.short() ++ "\n"' --no-graph); do
  desc=$(jj log -r "$c" --no-graph -T 'description.first_line()')
  url=$(jj log -r "$c" -T 'description' --no-graph | grep 'Pull Request:' | head -1)
  echo "$c: $desc"
  echo "  PR: ${url:-none}"
done

Step 2: Check PR States on GitHub

gh pr list --author @me --state all --limit 20 \
  --json number,title,state,baseRefName

Look for:

  • OPEN PRs targeting synthetic base branches — may be stale if the parent was already merged
  • OPEN PRs for empty local changes — orphaned, should be closed
  • CLOSED/MERGED PRs still referenced in commit descriptions — stale URLs to strip

Step 3: Identify Ghost Changes

jj log

Look for (empty) changes. These had their content merged to master via another path. They should be abandoned.

Recovery Procedure

When the stack is in a bad state (stale bases, ghost changes, orphaned PRs):

1. Close stale/orphaned PRs

Prefer jj spr close — it closes the PR, strips the Pull Request: URL from the commit message, and deletes both head and synthetic base branches:

# Close one PR cleanly (strips URL + deletes branches)
jj spr close -r <change>
 
# Close a range
jj spr close -r <bottom>::<top>

If jj spr close isn’t available (e.g., sandboxed environment), fall back to gh pr close <N> --delete-branch then manually strip URLs.

2. Abandon empty ghost changes

jj abandon <empty-change-id>

3. Strip Pull Request URLs (only if using gh pr close)

Only needed if PRs were closed via gh pr close or the GitHub UI, since those don’t strip URLs. jj spr close handles this automatically.

for c in <change-ids>; do
  desc=$(jj log --no-graph -r "$c" -T 'description')
  clean=$(echo "$desc" | grep -v 'Pull Request:' | sed '/^$/N;/^\n$/d')
  jj desc -r "$c" -m "$clean"
done

4. Fetch and rebase onto fresh master

jj git fetch
jj rebase -s <bottom-of-stack> -d main@origin

5. Recreate PRs

jj spr diff -r <bottom>::<top>

6. Verify

jj log                # clean graph
jj spr list           # fresh PRs
gh pr list --author @me --state open  # correct base branches

Prevention

  1. Always land bottom-up — never merge from the middle or top of a stack
  2. Always use jj spr land — never click “Merge” on GitHub
  3. Always jj git fetch before pushing — stale master@origin causes SPR to create branches with hundreds of unrelated files
  4. After landing, immediately rebase and update remaining PRs — don’t let the stack sit with stale bases
  5. Never use gh pr edit --base master — let SPR manage base branches

Reorganizing a Stack with Existing PRs

SPR supports partial reorganization — you don’t need to close all PRs when restructuring a stack. The key primitives:

  • jj spr close -r <change> — closes one PR, strips URL from commit message, deletes head + synthetic base branches
  • jj spr diff -r <range> — creates PRs for changes without URLs, updates PRs for changes with URLs (in a single run)

Removing a change (squash into another)

Only the removed change’s PR needs to close:

jj spr close -r <change-to-remove>
jj squash --from <change-to-remove> --into <target>
jj spr diff -m "squashed change" -r <bottom>::<top>

Splitting a change into two

The original PR stays on one half. New half gets a new PR automatically:

jj split -r <change>
jj spr diff -m "split change" -r <bottom>::<top>

Reordering changes

Close PRs for moved changes (position changes break synthetic bases):

jj spr close -r <change-being-moved>
jj rebase -r <change> --after <target>
jj spr diff -m "reordered" -r <bottom>::<top>

Full restructuring

When everything changes, close all and recreate:

jj spr close -r <bottom>::<top>
# ... reorganize freely ...
jj spr diff -r <new-bottom>::<new-top>

Always use jj spr close, not gh pr close. The former strips URLs and deletes branches; the latter does neither.

Always pass -m when updating existing PRsjj spr diff prompts interactively without it.

Quick Reference

SituationAction
Land bottom PRjj spr land -r <id>jj git fetchjj abandonjj rebase -sjj spr diff
PR shows wrong diffjj spr close -r <change>, then jj spr diff -r <change> to recreate
Empty ghost changejj abandon <id>
Orphaned PR (empty change)gh pr close <N> --delete-branch then jj abandon
Stack on stale masterjj git fetchjj rebase -s <bottom> -d main@origin
All PRs stale after reorganizationjj spr close -r <range> → reorganize → jj spr diff -r <range>
Remove one PR from stackjj spr close -r <change> → squash/abandon → jj spr diff -m "update" -r <range>

Case Study: The dbt-renderer Storm (2026-03-04)

Merged 6 PRs to master over a day in this order:

  1. scaffolding (#78945)
  2. manifest reader (#78980)
  3. PythonDagWriter (#78900 — originally part of a different stack)
  4. DagConfig (#78902)
  5. interactive CLI (#79179)
  6. schema parameterization (#78905)

Meanwhile, 5 more changes were stacked above #78905’s content locally. After #78905 was merged, the remaining 5 PRs (#78906–#78910) were all targeting old SPR synthetic bases. Two changes became empty ghosts.

Recovery plan:

  1. Close PR #79192 (orphaned, empty change kmowsmtm)
  2. Abandon empty changes qtpoqtyo and kmowsmtm
  3. Close PRs #78906–#78910 with --delete-branch
  4. Strip all Pull Request: URLs from mxrmkpqw through qunnnzll
  5. Rebase stack onto main@origin
  6. Recreate PRs with jj spr diff -r mxrmkpqw::qunnnzll