jjpr
jjpr manages stacked pull requests for Jujutsu repositories. It pushes bookmarks, creates and updates PRs/MRs, merges them, and syncs the stack on GitHub, GitLab, and Forgejo.
See Installation to get started.
Stacked PRs are chains of small pull requests that branch off of each other. They are better structured and easier to review than one large PR, and they let the developer focus on one feature by working ahead of the reviewer.
Commands
jjpr submit and jjpr watch are what most users want. submit
pushes up the current stack of bookmarks as a stack of PRs to the
forge. watch runs in a loop, creating PRs for new bookmarks,
promoting drafts when CI passes, and merging from the bottom up once
each PR is approved.
Other lower-level commands are there for manual control and debugging.
- merge: merge a stack from the bottom up, one-shot.
- status: show the current stack and PR state.
- auth: test or set up forge authentication.
- config: manage config files.
Reference
Installation
Requires Rust 1.91+ when building from source. Runtime requires jj 0.36+ and a colocated jj/git repository with a supported remote.
Homebrew
brew tap michaeldhopkins/tap
brew install jjpr
cargo-binstall
cargo binstall jjpr
Pulls a pre-built binary if one is published for your platform; falls back to building from source.
crates.io
cargo install jjpr
From source
git clone https://github.com/michaeldhopkins/jjpr
cargo install --path jjpr
Verifying
jjpr --version
Confirms the binary is on your $PATH and reports the installed
version.
Next: authentication
jjpr needs an API token (or gh / glab credentials) to talk to the
forge. See Forge support for token env vars and
self-hosted setup, and auth for verifying that
jjpr can authenticate from the current repo.
watch
jjpr watch runs in a loop and manages the full lifecycle of your
stack. It creates draft PRs, promotes them when CI passes, merges
them when approved, and syncs the rest of the stack after each
merge.
jjpr watch # auto-detect the stack from working copy
jjpr watch <bookmark> # watch the stack ending at <bookmark>
jjpr watch --timeout 60 # stop after 60 minutes
jjpr watch --no-ci-check # merge without waiting for CI
What it does
Each cycle:
- Creates draft PRs for bookmarks in the stack that don’t have one yet.
- Marks drafts as ready when their CI checks pass. Reviewers are not added automatically.
- Merges PRs from the bottom up once they’re approved and mergeable.
- Syncs the stack after each merge: rebases downstream, pushes updated bookmarks, retargets PR bases.
- Reports blockers. When a PR needs approval but has no reviewers, the loop says so and continues.
Press Ctrl+C to exit. The next run resumes from wherever the stack
is now.
Watch keeps waiting as long as a PR is making progress toward merge, including through slow CI. A PR stuck on pending checks or a missing approval is not treated as a stall — waiting on a review is the normal case, not a failure.
It does stop on its own in four situations, so it will not poll a broken repository forever:
- Everything merged. The stack is done.
- No progress for five consecutive polls. Nothing merged, nothing created, nothing promoted. Watch reports which bookmarks remain and exits rather than spinning.
- Ten consecutive polls that hit an error. A failing
jjcommand or forge call is retried, and the count is shown as it climbs (Submit error (3/10)). Ten in a row means the failure is not transient — a revoked token or a deleted repository will not fix itself — so watch gives up and says so. A single poll that succeeds end to end clears the count. - The watched bookmark disappears. The stack merged, or the bookmark was deleted.
Plus the two you control: --timeout elapsing, and Ctrl+C.
While waiting between polls, a terminal shows a live spinner so you can tell watch is still running. When output is piped or captured (CI, logs), the spinner is omitted and watch instead prints a periodic timestamped line.
Flags
| Flag | Effect |
|---|---|
--timeout <MINUTES> | Exit after this many minutes regardless of state |
--no-ci-check | Treat PRs with non-passing CI as mergeable |
--merge-method <method> | squash, merge, or rebase (overrides config) |
--required-approvals <N> | Override the config’s approval threshold |
--reconcile-strategy <strategy> | rebase or merge for post-merge stack syncing |
--reviewer <users> | Comma-separated reviewers; requested per scope each iteration |
--reviewer-scope <scope> | bottom (default), leaf, or all |
--ready | Create new PRs as ready instead of as drafts (skips the promote phase) |
--base <branch> | Override the auto-detected stack base |
--remote <name> | Override the git remote name |
--no-fetch | Skip git fetch before starting |
Sample session
Two bookmarks set up as a stack, then a single jjpr watch
invocation handles the rest:
$ jj bookmark set auth
$ jj bookmark set profile
$ jjpr watch
Watching stack for 'profile'...
Creating PR (draft) for 'auth'...
https://github.com/o/r/pull/42
Creating PR (draft) for 'profile'...
https://github.com/o/r/pull/43
Marked 'auth' as ready (CI passing)
'profile' (#43): needs review approval but has no reviewers
hint: run `jjpr submit --reviewer <username>` to request reviewers
Merging 'auth' (PR #42, squash)...
https://github.com/o/r/pull/42
Waiting for 'profile':
- Insufficient approvals (0/1)
profile: Approval received (1/1)
Merging 'profile' (PR #43, squash)...
Done. 2 PRs merged.
When no bookmark exists
If you run jjpr watch before setting any bookmark in the working
copy’s ancestry, it waits for one to appear:
Waiting for a bookmark in the working copy's ancestry...
hint: jj bookmark set <name>
Run jj bookmark set <name> in another shell and the loop picks it up
within a few seconds.
Which stack watch follows
Whichever stack you target at startup — named explicitly, or inferred from the working copy the first time — is the stack watch follows for the rest of the run. It tracks that stack by bookmark, so moving your working copy or editing other commits while watch runs does not redirect it.
If the watched stack goes away — it fully merges, or its bookmark is removed — watch stops and says so:
Watched stack 'my-feature' is no longer present — it has merged, or the
bookmark was removed. Stopping.
It will not silently switch to whatever stack you happen to be on. To
watch a different stack, run jjpr watch <bookmark> again.
Blocks that watch will not wait out
Watch polls through anything that can resolve on its own: pending CI, an unresolved mergeability check, a missing approval. It stops on anything that cannot.
A PR belonging to a GitHub native stack is one of those. GitHub refuses API merges of stacked PRs, and that does not change by waiting, so watch reports it and exits instead of polling forever. See GitHub native stacks in the merge docs for the ways out.
Divergent changes
Watch submits through the same planner as jjpr submit, so it refuses for the
same reason: if two commits in the stack share a change ID, nothing is pushed and
watch stops with the message described in
submit. Resolve the divergence and start watch
again.
One watcher per repo
Run only one jjpr watch per repository. Two watchers poll the forge
twice as often, which wastes your API rate limit. If a watch is already
running on the repo, a second one exits:
jjpr watch is already running on this repo in another window. Exiting.
The running watch records a heartbeat in .jj/jjpr-watch.json and
refreshes it each poll; a second watch sees the fresh heartbeat and
exits. If a previous watch was killed, the heartbeat goes stale and a
new watch takes over.
Reviewers and scope
--reviewer alice,bob requests reviewers each iteration. Default scope
is bottom — the request lands on the lowest live PR. As that PR merges,
the next iteration’s bottom (which is now what was middle) gets the
request. This means a reviewer is always being asked to review the PR
that will land next, not the entire stack at once.
--reviewer-scope leaf requests on the topmost live PR; all requests
on every PR (the pre-0.21 default).
Ready vs. draft
By default, watch creates new PRs as drafts and promotes them to
ready when their CI checks pass. This gives you a “hold while CI runs”
window. Pass --ready to skip both: new PRs are created as ready and
the promote phase is a no-op.
submit
jjpr submit is the manual control point. It pushes bookmarks and
creates or updates PRs on the forge. Use it when you don’t want the
watch loop, or when you’re debugging.
jjpr submit # push and update everything in the stack
jjpr submit --reviewer alice,bob # request reviewers on the bottom PR (default scope)
jjpr submit --reviewer alice --reviewer-scope all # request on every PR
jjpr submit --reviewer alice --reviewer-scope leaf # request on the topmost PR
jjpr submit --draft # create new PRs as drafts
jjpr submit --ready # mark existing draft PRs as ready
jjpr submit --base coworker-feat # override auto-detected base branch
What it does
- Pushes all bookmarks in the stack to the remote.
- Creates PRs for bookmarks that don’t have one yet.
- Updates PR base branches to maintain the stack structure.
- Updates PR bodies when commit descriptions have changed.
- Adds or updates the stack-awareness comment on multi-PR stacks. Single-PR stacks don’t get a comment, so they look like a normal PR to reviewers.
Submit is idempotent. Run it as often as you want. After rebasing,
editing commit messages, or restacking with jj rebase, re-run jjpr submit. It pushes the new commits, fixes PR base branches, and syncs
descriptions. If everything is already up to date, it reports “Stack
is up to date.”
When no bookmark is specified, jjpr infers the target from the working
copy’s position. It finds which stack overlaps trunk()..@ and
submits up to the topmost bookmark.
If pushing new commits to an already-approved PR whose base resets approvals on push (GitHub’s “dismiss stale reviews”, GitLab’s “reset approvals on push”, Forgejo’s “dismiss stale approvals”), jjpr reports the approvals the push dropped so the loss isn’t silent:
Pushing 'auth'...
https://github.com/o/r/pull/42
⚠ dismissed 1 approval on #42 — base 'main' resets approvals on push
Flags
| Flag | Effect |
|---|---|
--reviewer <users> | Comma-separated list of reviewers to request |
--reviewer-scope <scope> | Which PRs receive requests: bottom (default), leaf, or all |
--draft | Create new PRs as drafts |
--ready | Mark existing draft PRs as ready |
--base <branch> | Override auto-detected base branch |
--remote <name> | Override the git remote name |
--no-fetch | Skip git fetch before starting |
--dry-run | Print what would happen without doing it |
PR titles and bodies
Title and body come from the first commit’s description in each
bookmark’s segment. A trailing block of git trailers (Co-authored-by:,
Signed-off-by:, and similar) is stripped from the body so commit
attribution doesn’t show up as the PR description.
The body is wrapped in HTML comment markers. Text you add above or below the markers (screenshots, notes, test plans) is always preserved.
When you re-submit, jjpr reconciles the managed section between the markers against the commit message. It records a fingerprint of whatever it last wrote there, which lets it tell two situations apart:
- You changed the commit message, so the PR is stale. jjpr updates the managed section.
- You edited the text between the markers directly on the forge. jjpr leaves it alone.
If both the commit message and the on-forge text changed since jjpr last wrote them, jjpr can’t tell which you meant to keep, so it leaves the PR untouched rather than overwrite your edit and prints a note telling you so. To make the change take, edit the commit message (the source of truth for the managed section).
PRs created before fingerprinting get a fingerprint recorded on the next submit. A pre-fingerprint PR whose managed text was hand-edited away from its commit message is left untouched, never overwritten.
If you remove the markers from the PR body entirely, jjpr stops updating that PR’s description.
The PR title is not automatically updated after creation. If you change the commit’s first line, jjpr warns you about the drift.
Drafts
--draft creates new PRs as drafts. Existing PRs are unaffected.
--ready converts every draft PR in the stack to ready-for-review.
The two flags are mutually exclusive.
Reviewers
--reviewer alice,bob requests reviewers. By default the request lands
only on the bottom PR (the one closest to your default branch),
because that’s where review attention is needed first in a stack. As
the bottom merges, the next iteration of submit (or watch) targets
whichever PR is now lowest. --reviewer-scope leaf requests on the
topmost PR; --reviewer-scope all requests on every PR.
In 0.20.x and earlier, requests went to every PR by default. If you
relied on that, pass --reviewer-scope all explicitly.
Idempotent: a reviewer who’s already on a PR isn’t re-requested.
Stacking on someone else’s branch
If a commit in your stack’s ancestry has a remote bookmark that isn’t
one of your own, jjpr treats it as a foreign base and targets your
first PR at that branch instead of the default branch (e.g., main).
The status output reflects this:
auth (1 change, PR open, push up to date)
https://github.com/o/r/pull/42
profile (1 change, not pushed yet)
no PR yet — run `jjpr submit`
(based on coworker-feat)
Use --base <branch> to override auto-detection. Useful when the
coworker hasn’t pushed yet.
Conflict check
Before pushing, jjpr checks for unresolved conflicts in the stack. Conflicts halt the operation:
Error: cannot push — some commits have unresolved conflicts:
pnnmmvmu (feat/deferment-roles): add Billings::DueDatePolicy specs
To resolve: jj edit pnnmmvmu, fix the conflicts, then re-run jjpr submit.
Divergent changes
A change is divergent when two commits carry the same change ID — usually the
result of two processes rewriting the same change concurrently. jj marks these
??.
If both copies are in the stack you are submitting, jjpr stops before pushing anything:
Error: Refusing to submit: this stack contains a divergent change.
change qkpwpsrsmuto is on 2 commits: 8bf5f5f34cfe, f24f394275ab
bookmarks: feat-b, feat-a
jjpr will not publish both copies as separate pull requests. Resolve the
divergence first — `jj abandon <commit>` to drop one, or `jj duplicate` to
give it its own change id — then re-run.
Nothing has been pushed.
The two copies are genuinely different commits, so publishing them would mean
two pull requests for what you still have as one unresolved change — and a
created PR notifies reviewers and is awkward to retract. jjpr merge already
refuses on divergence; submit and jjpr watch behave the same way.
The check is scoped to the stack being submitted. A divergent change elsewhere in the repository does not block you.
jjpr status marks the segment that carries it, so you can see which one to
resolve.
Reshaping a native stack
If your PRs are registered as a GitHub native stack (with gh stack submit or
from the web UI), most of submit works normally. Pushing rewritten commits is
fine: the stack follows each branch’s new head. Creating PRs, updating
descriptions, and stack comments all work.
The exception is retargeting. GitHub rejects a base change on any PR that belongs to a stack, even a change to the base it already has. jjpr only needs to retarget when the stack’s shape changes, so amending commits and re-pushing is unaffected. Reordering, inserting, or dropping a bookmark is not.
jjpr checks this before pushing anything and stops:
Error: the stack's shape changed, but its PRs are in a GitHub native stack.
#239 ('sub0730-c') needs its base moved from 'sub0730-b' to 'sub0730-a'
GitHub does not allow retargeting a PR that belongs to a stack, so jjpr cannot
apply that. Nothing has been pushed.
To continue, dissolve the native stack and let jjpr manage the bases:
gh stack unstack 240
jjpr submit
Or keep the native stack and restructure it with gh instead:
gh stack unstack 240 && gh stack init <branches in the new order>
The check runs in --dry-run too. A dry run reports the refusal rather than
printing a base update that could not succeed.
Stack-awareness comment
Multi-PR stacks get a comment on every PR linking the others:
This PR is part of a stack:
1. [`feat/auth`](https://github.com/o/r/pull/41)
1. **`feat/profile` <-- this PR**
1. [`feat/settings`](https://github.com/o/r/pull/43)
The list always reflects the current local stack in base-to-top order. As you rebase, split, or reorder commits, the order is recomputed every submit so each PR’s comment stays in sync with the others.
When a PR in the stack is merged or closed and its bookmark is no longer in the local graph, it moves into a collapsible history block at the bottom of the comment, rendered with strikethrough:
1. **`feat/profile` <-- this PR**
1. [`feat/settings`](https://github.com/o/r/pull/43)
<details><summary>2 earlier closed/merged PRs</summary>
1. ~~[`feat/foundation`](https://github.com/o/r/pull/39)~~
1. ~~[`feat/migration`](https://github.com/o/r/pull/40)~~
</details>
Up to seven historical entries are shown, sorted most-recent first by the forge’s merge timestamp. Older entries past the cap stay in the embedded data (so future submits can reconstruct full history) but aren’t displayed:
<summary>7 earlier closed/merged PRs (+3 older entries hidden)</summary>
The comment also embeds a base64-encoded JSON payload of the stack state inside an HTML comment marker. jjpr reads this on the next submit to inherit fossil metadata (PR numbers, merge timestamps) for PRs whose local bookmarks have been cleaned up. Don’t edit it; jjpr rewrites the whole comment on every submit.
Single-PR stacks don’t get a comment.
merge
jjpr merge is the one-shot alternative to jjpr watch. It merges
whatever it can right now and exits.
jjpr merge # merge the inferred stack
jjpr merge <bookmark> # merge the stack ending at <bookmark>
jjpr merge --merge-method rebase # use rebase merge method
jjpr merge --no-ci-check # merge even if CI hasn't passed
jjpr merge --dry-run # preview without merging
What it does
For each PR in the stack, starting from the bottom, jjpr checks:
- PR is not a draft.
- CI checks pass (configurable).
- Required number of approvals reached (configurable).
- No changes requested.
- No merge conflicts.
If the bottommost PR is mergeable, jjpr merges it, fetches the updated default branch, syncs the remaining stack, pushes all remaining bookmarks, and retargets the next PR’s base. Then it checks the next PR and continues until blocked or done.
By default, the remaining stack is synced by rebasing downstream
commits onto the new base. Switch to merge-based reconciliation with
reconcile_strategy = "merge" (see
Configuration). That creates merge commits
instead, avoiding force pushes.
Flags
| Flag | Effect |
|---|---|
--merge-method <method> | squash, merge, or rebase (overrides config) |
--required-approvals <N> | Override the config’s approval threshold |
--no-ci-check | Treat PRs with non-passing CI as mergeable |
--reconcile-strategy <strategy> | rebase or merge for post-merge stack syncing |
--base <branch> | Override the auto-detected stack base |
--remote <name> | Override the git remote name |
--no-fetch | Skip git fetch before starting |
--dry-run | Print what would happen without merging |
CLI flags override the config file.
Retry on transient errors
Merge API calls retry automatically on transient HTTP errors (502, 503). If the forge returns a 405 “merge already in progress”, jjpr polls the PR state for up to 30 seconds to confirm completion. No user action needed; this is transparent in normal operation.
GitHub native stacks
GitHub’s own stacked pull requests are a separate feature from jjpr’s stacks.
If a PR has been registered as a member of a GitHub native stack, with
gh stack submit or from the web UI, GitHub refuses to merge it through the
API endpoint jjpr uses and returns a 403.
jjpr checks for this before merging anything, so a stack it cannot finish is reported up front rather than partway up:
Blocked at 'profile' (#221):
- In native stack #223 (2 of 4); GitHub refuses API merges of stacked PRs. Run `gh stack merge 221`, which lands #221 and everything below it
This stack is registered as a GitHub native stack, which jjpr cannot merge.
Merge it with `gh stack merge`, or dissolve the native stack with
`gh stack unstack 223` to hand merging back to jjpr.
Two things to know about the remedies:
gh stack merge <pr>merges that PR and every PR below it in one atomic operation. It needs the extension:gh extension install github/gh-stack.gh stack unstack <stack>removes every PR it can from the stack, not only the one you name. PRs that have already merged stay in it. Once the open PRs are unstacked,jjpr mergeworks on them normally.
jjpr watch stops on this rather than polling. Stack membership does not
clear on its own, so waiting would never resolve it.
jjpr submit is mostly unaffected: pushing to a stacked PR, creating PRs,
and updating descriptions and comments all work. The one exception is
retargeting a PR’s base, which GitHub rejects for any stacked PR. See
Reshaping a native stack.
This applies to GitHub only. GitLab and Forgejo have no equivalent feature.
Reconcile failures
After each merge, jjpr reconciles two things: local state (fetch, rebase, push the remaining stack) and forge state (refresh PR list, retarget the next base, update stack-info comments). If either fails, jjpr stops at the next PR rather than merging it. Merging without a local rebase would mix in the previous PR’s changes; merging against stale forge state can target the wrong base.
Preserving approvals
A merge-commit or rebase-merge landing leaves the merged commit in trunk’s history, so the remaining stack already sits on trunk. jjpr skips the rebase and force-push entirely and only retargets the next PR’s base:
Merging 'auth' (#42, merge)...
Fetching remotes...
Remaining stack already based on main; skipping rebase
Updating #43 base to 'main'...
This matters when the base branch resets approvals on push (GitHub’s “dismiss stale reviews”, GitLab’s “reset approvals on push”, Forgejo’s “dismiss stale approvals”): the skipped force-push would otherwise drop a standing approval on the descendant PR for nothing. A squash landing orphans the descendant’s parent, so the rebase and force-push are genuinely required — and jjpr reports how many approvals that push drops:
Merging 'auth' (#42, squash)...
Fetching remotes...
Rebasing remaining stack onto main...
Pushing 'profile'...
⚠ dismissed 2 approvals on #43 — base 'main' resets approvals on push
Updating #43 base to 'main'...
Conflicted rebase or merge
A rebase or merge that jj completes can still leave conflicts: jj records them in the commit rather than failing. jjpr checks every bookmark after syncing and pushes only the ones that came out clean, stopping at the first conflicted one.
Merging 'auth' (#42, squash)...
Rebasing remaining stack onto main...
Pushing 'profile'...
Blocked at 'settings' (#44):
- Local sync failed
Note: local state is out of sync with the forge:
Rebase of 'settings' onto 'main' has conflicts; skipping push
Bookmarks below the conflict are pushed normally. Resolve with jj resolve,
then re-run jjpr merge.
Local sync failed
Failed fetch, failed rebase, conflicted push, or a divergent change ID.
Merging 'auth' (#42, squash)...
Fetching remotes...
Rebasing remaining stack onto main...
Pushing 'profile'...
Warning: failed to push 'profile': conflicted commits
Updating #43 base to 'main'...
Blocked at 'profile' (#43):
- Local sync failed
Run `jjpr merge` again once the issue is resolved.
Note: local state is out of sync with the forge:
Failed to push 'profile': conflicted commits
To accept the forge state (discard local divergence):
jj git fetch
jj bookmark set profile -r profile@origin
Or to fix local state and push it to the forge:
jj git fetch && jj rebase -s <root-change-id> -d main
# resolve any conflicts, then:
jjpr submit
<root-change-id> is the oldest commit in the next segment (jjpr fills
this in for you). Using the bookmark tip’s change ID rebases only the
tip and strands earlier commits under the old base.
The forge-side parts of reconcile (next PR’s base retarget, stack-info comment update) still run before jjpr stops. Only the merge itself waits.
Forge reconcile failed
The forge merge succeeded but a follow-up API call (list_open_prs, update_pr_base, comment update) returned an error. Recovery is usually to retry; persistent failures point at network or permission issues.
Blocked at 'profile' (#43):
- Forge reconcile failed
Note: forge reconcile failed:
Failed to retarget #43 base to 'main': 502 bad gateway
Retry with `jjpr merge` (or wait for `jjpr watch` to retry).
jjpr watch keeps polling through both kinds of failure and resumes
automatically once the next reconcile succeeds, so persistent watch
sessions self-heal.
status
jjpr status (and bare jjpr) shows the stack containing your
working copy and its PR/MR state, down to your default branch —
including a coworker’s branch you’ve stacked on. It’s read-only. It
fetches the latest state but doesn’t push or modify anything.
jjpr # current stack (inferred from working copy)
jjpr status # same
jjpr st # same, using the short alias
jjpr status profile # scope to the stack containing 'profile'
jjpr status --all # show every local stack
st is a short alias for status, matching jj’s own jj st.
The default scope matches submit, merge, and watch: the stack
inferred from the working copy. Pass a bookmark to scope to a specific
stack, or --all to see every local stack at once.
Flags
| Flag | Effect |
|---|---|
--all | Show every local stack instead of only the current one. Mutually exclusive with a positional bookmark. |
--no-fetch | Skip git fetch before reporting |
Output
Each segment shows its bookmark, a direct link to the PR, and the PR’s mergeability, CI status, and review state:
auth (1 change, PR open, push up to date)
https://github.com/o/r/pull/42
✓ mergeable ✓ CI passing ✓ 1 approval
profile (2 changes, PR open, push needs updating)
https://github.com/o/r/pull/43
✗ conflicts ✗ CI failing ⚠ changes requested ✗ 0 approvals
Draft PRs show just the link. Their CI and review detail stays hidden until the PR is marked ready:
payments (1 change, PR draft, push up to date)
https://github.com/o/r/pull/44
A segment you haven’t submitted yet has no PR:
cleanup (2 changes, not pushed yet)
no PR yet — run `jjpr submit`
With --all, multiple independent stacks are labeled:
Stack 1:
auth (1 change, PR open, push up to date)
https://github.com/o/r/pull/42
✓ mergeable ✓ CI passing ✓ 1 approval
profile (2 changes, PR open, push up to date)
https://github.com/o/r/pull/43
✓ mergeable ✓ CI passing ✓ 1 approval
Stack 2:
payments (1 change, PR draft, push up to date)
https://github.com/o/r/pull/44
checkout (3 changes, PR open, push needs updating)
https://github.com/o/r/pull/45
✗ CI pending ✗ 0 approvals
Approvals at risk from a squash landing
When the base branch resets approvals on push (GitHub’s “dismiss stale
reviews”, GitLab’s “reset approvals on push”, Forgejo’s “dismiss stale
approvals”) and one of your approved PRs is stacked on top of another
open PR, a squash landing of the lower PR force-pushes a fresh commit
onto yours and drops its approvals. status warns ahead of time:
api (2 changes, PR open, push up to date)
https://github.com/o/r/pull/124
✓ mergeable ✓ CI passing ✓ 2 approvals
⚠ a squash-landing of #123 would dismiss 2 approvals
The note is conditional on how the lower PR lands: a merge-commit landing keeps your approval, because jjpr skips the needless rebase in that case (see merge).
PRs in a GitHub native stack
A PR registered as a member of a GitHub native stack cannot be merged by jjpr,
because GitHub refuses the merge endpoint for stacked PRs. Status flags those,
so the mergeability line above it is not read as an invitation to run
jjpr merge:
auth (1 change, PR open, push up to date)
https://github.com/owner/repo/pull/345
✓ mergeable ✗ 0 approvals
⚠ in native stack #348 (1 of 3), so jjpr cannot merge it; `gh stack merge 345` lands it
The position is 1-based from the bottom of the stack, and the note says what
the command would land, because merging a stacked PR lands every PR below it
too. At position 1 that is one PR; at position 3 the note reads
lands it and the 2 below.
This reads the stack membership GitHub embeds in the PR data jjpr already fetches, so it costs no extra request. See GitHub native stacks for the ways to land such a stack, and Reshaping a native stack for what submit can and cannot do with one.
Divergent changes
When two commits carry the same change ID — a divergent change, which jj marks
?? — the segment carrying it is flagged:
feat-a (1 change, #41 open)
feat-b (1 change, #42 open) ?? divergent
Status always shows the stack, even when it is in this state; it is where you
look to find out which segment to fix. jjpr submit, jjpr watch and
jjpr merge all refuse while a divergent change is in the stack — see
submit.
Resolve with jj abandon <commit> to drop one copy, or jj duplicate to give it
its own change ID.
Branches that aren’t yours
status shows the whole stack down to your default branch, including a
coworker’s branch you’ve stacked on. Those segments are attributed to
their author, show the base’s mergeability, and are marked so you know
submit, watch, and merge leave them alone (only status shows
them; the mutating commands act only on your own bookmarks):
auth-ui (1 change, PR draft, push up to date)
https://github.com/o/r/pull/205
auth-api (2 changes, PR open, push needs updating)
https://github.com/o/r/pull/204
✓ mergeable ✓ CI passing ✓ 1 approval
platform-refactor (3 changes, PR open by @dana, jjpr won't submit or merge it)
https://github.com/o/r/pull/198
✓ mergeable
The rich segment above appears when you have a local bookmark at the
coworker’s commit. If instead you rebased straight onto their remote
branch (jj rebase -d their-branch@origin, no local bookmark), it shows
up as an attributed base footer:
my-feature (2 changes, PR open, push up to date)
https://github.com/o/r/pull/210
(based on their-branch — PR open by @dana)
https://github.com/o/r/pull/198
When a branch has already merged, its segment says so and — if the remote branch is gone but the local bookmark lingers — points you at the cleanup:
cycle-events (1 change, PR merged by @dana)
https://github.com/o/r/pull/512
✓ merged on 2026-04-20; remote branch deleted, local bookmark is stale
clean up: jj bookmark forget cycle-events
If the stack you’re on is entirely someone else’s — you’re sitting on
their branch with nothing of your own yet — status recognizes that
instead of pretending there’s a stack to act on:
On cycle-events — someone else's merged branch:
cycle-events (1 change, PR merged by @dana)
https://github.com/o/r/pull/512
✓ merged on 2026-04-20; remote branch deleted, local bookmark is stale
clean up: jj bookmark forget cycle-events
Nothing of yours to submit here.
Glossary
| Field | Meaning |
|---|---|
push up to date / push needs updating / not pushed yet | Whether your local commits are reflected on the pushed PR branch: matching, pushed but since changed, or never pushed |
PR link (https://.../pull/42) | Direct link to the PR or MR on the forge |
PR open / PR draft | The PR’s state on the forge |
no PR yet | This segment has not been submitted; run jjpr submit |
PR open by @user / PR merged by @user | Someone else’s PR — the author is named. Only status shows these; the mutating commands ignore them |
jjpr won't submit or merge it | This segment is someone else’s; submit/watch/merge leave it alone |
✓ merged … local bookmark is stale | The PR merged and its remote branch is gone, but a local bookmark remains; the line below shows how to remove it |
✓ mergeable / ✗ conflicts | Whether the forge reports the PR can merge without conflicts |
✓ CI passing / ✗ CI pending / ✗ CI failing | Aggregate check status for the head commit |
✓ N approvals / ✗ 0 approvals | Count of approving reviews (the required threshold comes from config) |
⚠ changes requested | At least one reviewer has requested changes |
⚠ a squash-landing of #N would dismiss … | A lower PR’s squash landing would force-push this approved PR and drop its approvals |
⚠ in native stack #N … | This PR belongs to a GitHub native stack, which jjpr cannot merge; the note names the command that can and what it would land |
auth
jjpr auth checks or explains forge authentication. Use it when a
forge call returns 401 or 403, or when you’re setting up a new
machine.
jjpr auth test # test forge authentication for the current repo
jjpr auth test --remote origin # pick a remote when the repo has several
jjpr auth setup # show auth setup instructions
test
Detects the forge from your remote URL, resolves the token (env var or CLI fallback), makes an authenticated API call, and reports the result. On success, prints the authenticated user. On failure, prints the error and a hint about which env var to set.
When detection itself fails, the error says which of the reasons it was rather than a single catch-all: not a jj repo, unreadable config, no supported remote, or more than one. In the last case it names the remotes it found:
Error: multiple forge remotes found: origin, mirror. Use --remote to specify one.
--remote <NAME>
Use a specific git remote instead of detecting one. Only needed when a
repo has more than one remote pointing at a supported forge, which is
otherwise ambiguous. Setting forge in .jj/jjpr.toml also resolves
it, and takes precedence.
setup
Prints setup instructions for the detected forge: which env var the
token reads from, how to scope it, and how stored credentials from
gh or glab are picked up automatically. If no forge is detected
(running outside a jj repo, for instance), prints instructions for
all supported forges — setup is what you run before a repo is
configured, so it falls back rather than failing.
It accepts the same --remote <NAME> flag as test.
config
jjpr config manages config files. Use the field reference in
Configuration for what to put in them.
jjpr config init # create global config at ~/.config/jjpr/config.toml
jjpr config init --repo # create repo-local config at .jj/jjpr.toml
init
Writes a config file populated with defaults. Without --repo, writes
the global config at ~/.config/jjpr/config.toml (or
$XDG_CONFIG_HOME/jjpr/config.toml if that’s set). With --repo,
writes .jj/jjpr.toml in the current repo.
If the target file already exists, init refuses to overwrite it.
Configuration
jjpr reads configuration from two optional TOML files.
| Location | Created by | Purpose |
|---|---|---|
~/.config/jjpr/config.toml (or $XDG_CONFIG_HOME/jjpr/config.toml) | jjpr config init | Global defaults |
.jj/jjpr.toml (inside the repo’s .jj/ directory) | jjpr config init --repo | Repo-local overrides |
If neither file exists, jjpr uses built-in defaults. CLI flags override config files. Repo-local config overrides global config.
Global config
merge_method = "squash"
required_approvals = 1
require_ci_pass = true
reconcile_strategy = "rebase"
stack_nav = "comment"
Repo-local config
Repo-local config goes in .jj/jjpr.toml. Because .jj/ is gitignored,
the file is per-clone.
forge = "forgejo"
forge_token_env = "FORGEJO_TOKEN"
stack_nav = "description"
Field reference
merge_method
How the forge combines the PR when it lands.
squash(default): all commits in the PR collapse into one commit on the target branch. Linear history.merge: a merge commit is created. The individual commits from the PR branch are preserved.rebase: commits are rebased onto the target branch individually with no merge commit. Linear history, each commit kept separately.
required_approvals
Number of approving reviews required before merging. Default 1.
require_ci_pass
If true (default), CI checks must pass before merging. Override with
--no-ci-check on a single invocation.
reconcile_strategy
How the remaining stack is synced after a PR is merged.
rebase(default): rebases downstream commits onto the new base. Rewrites history. Pushes become force-pushes.merge: creates merge commits on downstream branches that incorporate the updated base. Pushes stay fast-forward (no force-push events on GitHub) but the history grows merge commits.
stack_nav
Where to show the stack navigation block.
comment(default): a separate comment on each PR.description: embedded in the PR body. More visible to reviewers. Updates the body on eachsubmit.
forge
Forge type. One of github, gitlab, or forgejo. When set,
auto-detection is skipped. Use this for self-hosted instances that
auto-detection can’t recognize. Repo-local only.
forge_token_env
Name of the environment variable that holds the API token. When
unset, jjpr falls back to the forge’s default (GITHUB_TOKEN,
GITLAB_TOKEN, or FORGEJO_TOKEN). Repo-local only.
[identity]
Which commits count as yours. jjpr scopes submit, merge, and
watch to your own work, so it has to decide who authored a commit.
It already knows your local user.email, and it will ask the forge
for the verified emails on your account when the local one isn’t
enough. This section covers what neither of those finds.
[identity]
emails = ["[email protected]", "[email protected]"]
logins = ["my-second-account"]
emails— author emails that are yours, beyonduser.emailand anything fetched from the forge.logins— forge logins that are yours, for a second account jjpr can’t enumerate from the authenticated one.
Both default to empty and are additive: entries here are unioned with what jjpr discovers, never a replacement for it.
You need this in two situations. The first is commits authored under
an email you no longer use — a machine still configured with an old
address, or history carried over from another job. The second is a
token without the user scope: fetching your verified emails needs
it, and a repo-only token (which is what gh stores by default for
some setups) can’t. jjpr degrades to this config rather than guessing,
and says so:
A bookmark in the working copy isn't recognized as yours —
likely authored under a different email. Add it with
`[identity] emails = ["..."]` in the jjpr config, or name it explicitly.
“Name it explicitly” is the alternative: pass the bookmark to the
command (jjpr submit my-bookmark) instead of letting jjpr infer it
from the working copy.
Configuring forges
Forge-specific authentication and self-hosted instance setup live in Forge support.
Forge support
jjpr auto-detects the forge from your remote URL and talks directly to
the forge’s API over HTTP. Neither gh nor glab is required, but
jjpr picks up their stored credentials when they’re present.
| Forge | Token env var | CLI fallback |
|---|---|---|
| GitHub | GITHUB_TOKEN or GH_TOKEN | gh auth login (reads stored credentials) |
| GitLab | GITLAB_TOKEN | glab auth login (reads stored credentials) |
| Forgejo / Codeberg | FORGEJO_TOKEN | none |
Auto-detection recognizes github.com, gitlab.com, and
codeberg.org, plus Enterprise subdomains for GitHub and GitLab. For
self-hosted instances, set forge in .jj/jjpr.toml. See
Repo-local config.
GitHub
If you already use gh, jjpr finds your credentials automatically.
Otherwise, export GITHUB_TOKEN (or GH_TOKEN) with at least repo
scope.
GitHub Enterprise is auto-detected from the host
(*.github.enterprise.example.com). Credentials come from gh if it’s
configured for that host.
status reads a whole stack’s PR state in one GraphQL query. If your
token cannot use GraphQL, jjpr asks per PR over REST instead and reports
the same thing, using more requests. A fine-grained token needs the
pull-requests permission read; organizations with SAML enforcement need
the token authorized for the org. Neither is required, and nothing needs
configuring either way.
GitHub’s own stacked pull requests are a separate feature from jjpr’s stacks,
and jjpr recognizes them rather than fighting them. A PR that belongs to a
native stack cannot be merged through the API jjpr uses, and its base cannot be
retargeted, so status flags it, merge and watch stop and name
gh stack merge, and submit refuses a reshape it cannot apply. Pushing,
creating PRs, and updating descriptions all work normally on a stacked PR. See
GitHub native stacks.
This applies to GitHub only. GitLab and Forgejo have no equivalent feature, and nothing in jjpr changes for them.
GitLab
If you use glab, jjpr picks up your credentials. Otherwise, export
GITLAB_TOKEN with api scope.
Self-hosted GitLab is auto-detected from the host. No extra config is needed for gitlab.com or for any GitLab instance with a recognizable URL pattern.
Forgejo and Codeberg
Generate an API token with repo scope from your instance’s settings.
For Codeberg, that’s at
https://codeberg.org/user/settings/applications. Export it:
export FORGEJO_TOKEN=your_token_here
For self-hosted Forgejo, set the forge type in .jj/jjpr.toml:
forge = "forgejo"
Auto-detection only recognizes codeberg.org. Other Forgejo hosts
need the explicit forge setting.
Verifying
jjpr auth test
Reports the detected forge, the token source, and the authenticated user. See the auth command for details.
How it works
jjpr is a coordination layer over jj and your forge’s API. It does
no version control of its own. Every VCS step shells out to the jj
binary, and PR state comes from forge APIs over HTTP.
Stack discovery
jjpr discovers stacks by walking each bookmark toward trunk. Starting from a bookmarked commit, it follows parent commits (taking the first parent through merges) until it reaches the trunk branch or a foreign remote bookmark (see Foreign bases below). Each walk produces a path. Bookmarks on that path become segments of a stack.
The result is an adjacency graph keyed by jj change IDs.
- Segment: a contiguous run of commits between two bookmarked commits, or between trunk and the lowest bookmark.
- Stack: a chain of segments rooted at trunk and ending at a bookmark with no bookmarked descendants.
Multiple bookmarks at the same commit collapse into one segment. Bookmarks that don’t connect to your other stacks (a fork from trunk that isn’t an ancestor of any current bookmark, for example) form their own stacks.
Submission planning
When you run submit, merge, or watch, jjpr:
- Builds the change graph from
jj logoutput. - Identifies the target stack (explicit bookmark argument, or
inferred from
trunk()..@). - Compares each bookmark against forge state. Does a PR exist? Does it point at the right base? Has the title or body drifted?
- Plans the minimum set of pushes, PR creations, base retargets, and body updates needed to reach a consistent state.
- Executes the plan.
Step 4 is what makes the command idempotent. If the plan is empty, jjpr prints “Stack is up to date” and exits.
Merge orchestration
merge and watch walk segments from the bottom up. A segment is
mergeable when its PR is non-draft, has the required approvals, has no
requested changes, has no merge conflicts, and (when CI is required)
has passing CI.
After a merge succeeds, jjpr:
- Fetches the updated default branch.
- Reconciles the rest of the stack onto the new base. Rebase by
default, or merge commits if
reconcile_strategy = "merge". - Pushes the updated bookmarks.
- Retargets the next PR’s base if needed.
- Re-evaluates the next segment.
This continues until the stack is empty or the next segment is blocked.
Merge commits
jj new A B produces a commit with two parents. jjpr follows the
first parent through the merge, so the merge commit and its
first-parent ancestry stay in the current stack. The other parent (or
parents) form independent stacks. PRs for merge bookmarks include a
note saying which branches were merged and that the diff may include
their changes until those PRs land.
Diamond-shaped stacks (two branches that fork from a common ancestor and re-merge) are tracked as two separate stacks for submission. The merge commit’s PR carries the explanatory note about the combined diff.
Foreign bases
If a commit in your stack’s ancestry has a remote bookmark that isn’t
one of your local bookmarks, jjpr treats it as a foreign base. Your
first PR targets that branch instead of the default branch, and the
status output annotates the stack with (based on <branch>).
This handles the common case of stacking on a coworker’s PR branch.
Override with --base <branch> when needed.
submit, watch, and merge only ever act on your own bookmarks
(commits you authored). status is the exception: it shows the whole
stack down to trunk regardless of author, so a coworker’s branch you’ve
stacked on appears as its own segment — attributed to them, and marked
so it’s clear the mutating commands leave it alone. See
status.
Talking to the forge
A stack’s status needs four things per PR: whether it merges cleanly, its CI result, its reviews, and where its branch stands. Asked one at a time, that is four network round trips per PR, and the wait grows with every PR you stack.
On GitHub, jjpr asks for the whole stack in a single GraphQL query instead. A four-PR stack costs one request rather than sixteen, so status takes about as long for a tall stack as for a short one.
merge and watch read the same state and use the same query, but they
ask for less of it. merge reads CI only when it is going to require it,
and watch reads nothing but CI, because that is all it acts on. Both
matter more than they look: watch re-reads on every poll, and on GitLab
a review lookup by itself is three requests.
Two places deliberately keep asking one PR at a time. merge re-reads
each segment as it merges the one below it, and watch does the same in
its merge phase, because every merge moves the next PR’s base and changes
whether it still merges cleanly. A batch taken before that loop started
would be describing a stack that no longer exists.
GraphQL is not always available. It requires a token even on public repositories, where REST does not, and it can be refused by SAML enforcement, a fine-grained token missing the pull-requests permission, or an exhausted GraphQL budget while the REST budget is untouched. Whenever the batch query fails for any reason, jjpr falls back to asking per PR. The fallback is the same code path GitLab and Forgejo always take, neither offers a GraphQL API that jjpr can use, and it issues its requests concurrently rather than one after another. It reports the same thing, using more requests. The one difference is which commit a PR’s CI is read from: the batch reads the head as it stands when it asks, while the fallback reads the head recorded a moment earlier when the PR list was fetched. They differ only if you push mid-command, and then the batch is the fresher of the two.
jj git fetch runs at the same time as the forge lookups, since neither
needs the other’s answer. The stack graph waits for the fetch, because the
fetch decides where trunk is.
What jjpr never does
- Modify the working copy without an explicit user-driven command.
- Reimplement jj operations. Every VCS step is
jj <subcommand>. - Cache forge state across invocations. Each run re-fetches.
Code map
For contributors, the source is laid out as follows.
src/jj/:Jjtrait and theJjRunnerimplementation that shells out to thejjbinary.src/forge/:Forgetrait and per-forge backends (GitHub, GitLab, Forgejo) over a sharedureqHTTP client.src/forge/status.rs: reading PR state for a set of PRs at once, shared bystatus,merge, andwatch.src/parallel.rs: bounded, order-preserving fan-out for the forge calls that have no batch path.src/graph/: change graph construction and traversal.src/submit/: analysis, planning, and execution forsubmit.src/merge/: merge planning, execution, and the watch loop.src/watch.rs: thewatchcommand’s outer loop and helpers.
Troubleshooting
“No bookmark in working copy ancestry”
jjpr (or jjpr status) defaults to scoping output to the stack
containing your working copy. If no bookmark is in trunk()..@,
nothing matches.
Two fixes:
jj bookmark set <name> # mark the current change
or to see every stack regardless of working-copy position:
jjpr status --all
watch is the exception. It waits for a bookmark to appear, polling
every few seconds, instead of exiting.
“skipping ‘<name>’ (points to a missing or conflicted commit)”
A local bookmark points at a commit that no longer exists, usually because the corresponding PR was squash-merged on the forge and the commit was rewritten. The warning includes the cleanup command:
jj bookmark forget <name>
jj git push --deleted
After cleanup, re-run jjpr.
“cannot push — some commits have unresolved conflicts”
A commit in your stack has unresolved merge conflicts (often from a rebase that couldn’t auto-resolve). jjpr won’t push conflicted commits.
jj edit <change-id> # the change ID is in the error message
# resolve the conflicts
jjpr submit
“Local sync failed”
jjpr couldn’t push or rebase locally after a merge, so it stopped
before merging the next PR. Common causes: a jj rebase while jjpr
was running, divergent change IDs from concurrent editing, or a
conflicted push.
The just-merged PR is fine on the forge. The remaining open PRs are still open with their bases retargeted; only their local branches are out of date.
To accept the forge state:
jj git fetch
jj bookmark set <bookmark> -r <bookmark>@origin
To fix local state and push it:
jj git fetch
jj rebase -s <change-id> -d main
# resolve any conflicts
jjpr submit
Then re-run jjpr merge to continue. jjpr watch retries
automatically on the next poll once you fix things.
“Forge reconcile failed”
The forge merge succeeded but a follow-up API call (refresh PR list, retarget the next base, update stack-info comments) returned an error. Local state is fine; only the post-merge bookkeeping didn’t complete.
Retry with jjpr merge. If it keeps failing, check jjpr auth test
for token issues, then forge status / network connectivity.
Authentication errors
jjpr auth test
Reports the detected forge, where the token came from, and what the forge said. Common cases:
- No token found: set
GITHUB_TOKEN,GITLAB_TOKEN, orFORGEJO_TOKEN, or rungh auth login/glab auth login. - 403 / insufficient scope: regenerate the token with
reposcope (GitHub/Forgejo) orapiscope (GitLab). - Self-hosted instance: set
forge = "..."in.jj/jjpr.toml. See Forge support.
“PR title not updated after creation”
By design. jjpr creates the PR title from the commit’s first line but doesn’t rewrite it on subsequent submits. If the first line changes, jjpr warns about the drift and leaves the title alone so your manual edits are preserved.
To re-sync, edit the PR title on the forge directly.
“merge already in progress” warnings
A previous merge call returned a transient 502 or 503 right after
GitHub started processing the merge. jjpr polls the PR state for up to
30 seconds to confirm. If the merge actually completed, jjpr
continues. If not, it reports the failure and exits. Re-run to retry.
You only see the polling output when the network round-trip takes a while. Otherwise it’s silent.