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:
- Check if master was updated:
git log origin/master --oneline -5 - If not, cherry-pick or close-and-recreate to get content onto master
- 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}"
doneStep 2: Check PR States on GitHub
gh pr list --author @me --state all --limit 20 \
--json number,title,state,baseRefNameLook 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 logLook 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"
done4. Fetch and rebase onto fresh master
jj git fetch
jj rebase -s <bottom-of-stack> -d main@origin5. 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 branchesPrevention
- Always land bottom-up — never merge from the middle or top of a stack
- Always use
jj spr land— never click “Merge” on GitHub - Always
jj git fetchbefore pushing — stalemaster@origincauses SPR to create branches with hundreds of unrelated files - After landing, immediately rebase and update remaining PRs — don’t let the stack sit with stale bases
- 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 branchesjj 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 PRs — jj spr diff prompts
interactively without it.
Quick Reference
| Situation | Action |
|---|---|
| Land bottom PR | jj spr land -r <id> → jj git fetch → jj abandon → jj rebase -s → jj spr diff |
| PR shows wrong diff | jj spr close -r <change>, then jj spr diff -r <change> to recreate |
| Empty ghost change | jj abandon <id> |
| Orphaned PR (empty change) | gh pr close <N> --delete-branch then jj abandon |
| Stack on stale master | jj git fetch → jj rebase -s <bottom> -d main@origin |
| All PRs stale after reorganization | jj spr close -r <range> → reorganize → jj spr diff -r <range> |
| Remove one PR from stack | jj 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:
- scaffolding (#78945)
- manifest reader (#78980)
- PythonDagWriter (#78900 — originally part of a different stack)
- DagConfig (#78902)
- interactive CLI (#79179)
- 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:
- Close PR #79192 (orphaned, empty change
kmowsmtm) - Abandon empty changes
qtpoqtyoandkmowsmtm - Close PRs #78906–#78910 with
--delete-branch - Strip all
Pull Request:URLs frommxrmkpqwthroughqunnnzll - Rebase stack onto
main@origin - Recreate PRs with
jj spr diff -r mxrmkpqw::qunnnzll