Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Creates draft PRs for bookmarks in the stack that don’t have one yet.
  2. Marks drafts as ready when their CI checks pass. Reviewers are not added automatically.
  3. Merges PRs from the bottom up once they’re approved and mergeable.
  4. Syncs the stack after each merge: rebases downstream, pushes updated bookmarks, retargets PR bases.
  5. 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 jj command 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

FlagEffect
--timeout <MINUTES>Exit after this many minutes regardless of state
--no-ci-checkTreat 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
--readyCreate 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-fetchSkip 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

  1. Pushes all bookmarks in the stack to the remote.
  2. Creates PRs for bookmarks that don’t have one yet.
  3. Updates PR base branches to maintain the stack structure.
  4. Updates PR bodies when commit descriptions have changed.
  5. 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

FlagEffect
--reviewer <users>Comma-separated list of reviewers to request
--reviewer-scope <scope>Which PRs receive requests: bottom (default), leaf, or all
--draftCreate new PRs as drafts
--readyMark existing draft PRs as ready
--base <branch>Override auto-detected base branch
--remote <name>Override the git remote name
--no-fetchSkip git fetch before starting
--dry-runPrint 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

FlagEffect
--merge-method <method>squash, merge, or rebase (overrides config)
--required-approvals <N>Override the config’s approval threshold
--no-ci-checkTreat 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-fetchSkip git fetch before starting
--dry-runPrint 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 merge works 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

FlagEffect
--allShow every local stack instead of only the current one. Mutually exclusive with a positional bookmark.
--no-fetchSkip 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

FieldMeaning
push up to date / push needs updating / not pushed yetWhether 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 draftThe PR’s state on the forge
no PR yetThis segment has not been submitted; run jjpr submit
PR open by @user / PR merged by @userSomeone else’s PR — the author is named. Only status shows these; the mutating commands ignore them
jjpr won't submit or merge itThis segment is someone else’s; submit/watch/merge leave it alone
✓ merged … local bookmark is staleThe PR merged and its remote branch is gone, but a local bookmark remains; the line below shows how to remove it
✓ mergeable / ✗ conflictsWhether the forge reports the PR can merge without conflicts
✓ CI passing / ✗ CI pending / ✗ CI failingAggregate check status for the head commit
✓ N approvals / ✗ 0 approvalsCount of approving reviews (the required threshold comes from config)
⚠ changes requestedAt 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.

LocationCreated byPurpose
~/.config/jjpr/config.toml (or $XDG_CONFIG_HOME/jjpr/config.toml)jjpr config initGlobal defaults
.jj/jjpr.toml (inside the repo’s .jj/ directory)jjpr config init --repoRepo-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 each submit.

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, beyond user.email and 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.

ForgeToken env varCLI fallback
GitHubGITHUB_TOKEN or GH_TOKENgh auth login (reads stored credentials)
GitLabGITLAB_TOKENglab auth login (reads stored credentials)
Forgejo / CodebergFORGEJO_TOKENnone

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:

  1. Builds the change graph from jj log output.
  2. Identifies the target stack (explicit bookmark argument, or inferred from trunk()..@).
  3. Compares each bookmark against forge state. Does a PR exist? Does it point at the right base? Has the title or body drifted?
  4. Plans the minimum set of pushes, PR creations, base retargets, and body updates needed to reach a consistent state.
  5. 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:

  1. Fetches the updated default branch.
  2. Reconciles the rest of the stack onto the new base. Rebase by default, or merge commits if reconcile_strategy = "merge".
  3. Pushes the updated bookmarks.
  4. Retargets the next PR’s base if needed.
  5. 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/: Jj trait and the JjRunner implementation that shells out to the jj binary.
  • src/forge/: Forge trait and per-forge backends (GitHub, GitLab, Forgejo) over a shared ureq HTTP client.
  • src/forge/status.rs: reading PR state for a set of PRs at once, shared by status, merge, and watch.
  • 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 for submit.
  • src/merge/: merge planning, execution, and the watch loop.
  • src/watch.rs: the watch command’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, or FORGEJO_TOKEN, or run gh auth login / glab auth login.
  • 403 / insufficient scope: regenerate the token with repo scope (GitHub/Forgejo) or api scope (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.