stakk
stakk bridges Jujutsu bookmarks to GitHub stacked pull requests.
It is not a jj wrapper. It complements jj by reading your local bookmark state and turning it into a coherent set of GitHub PRs that merge into each other in the correct order — with stack-awareness comments, correct base branches, and idempotent updates.

Features
- Automatic stack detection — analyzes the jj change graph to find bookmark chains and their topological order.
- No bookmarks required — stakk discovers unbookmarked heads and creates bookmarks for them, interactively or from explicit flags.
- Interactive TUI —
stakkopens a graph of every branch stack, then a screen for assigning bookmarks to the commits that need them. Each commit cycles through:[x]existing →[~]auto →[>]typed by hand →[+]generatedstakk-xxxx→[*]custom command →[ ]skip. - Auto bookmark naming — the
[~]autostate derives names from commit descriptions and file paths via TF-IDF scoring;rcycles alternatives and--auto-prefixbrands them (e.g.gb-caching-database). - Stacked PR submission — creates or updates GitHub PRs with correct base branches so each PR shows only its own
diff;
--draftcreates new ones as drafts. - Stack-awareness comments — every PR gets the full stack with links, updated in place on re-runs and rendered with customizable minijinja templates. See Stack info placement.
- Idempotent — re-running
stakk submitis always safe. Existing PRs are updated, never duplicated. - Dry-run mode —
--dry-runshows exactly what would happen without touching GitHub — or the repo: no bookmarks are created, nothing is pushed. - Non-interactive selection —
--keep/--keep-all/--new/--new-auto/--new-commandbuild the exact same submission the TUI would, without a terminal. - PR titles and bodies from descriptions — populated from jj change descriptions on creation, so manual edits on
GitHub survive;
--sync-pr-contentopts into keeping them in sync. - Self-documenting —
stakk docsprints the bundled documentation, version-locked to the binary you are running. - No direct git usage — all VCS operations go through
jjcommands, so workspaces and non-colocated repos work automatically. - Forge-agnostic core — GitHub is the first implementation, but the
submission logic is decoupled behind a
Forgetrait.
Installation
Requirements
stakk shells out to the jj CLI, which must be installed and on your PATH.
The minimum supported jj version is 0.39.0.
Older versions may work but are untested; stakk prints a warning when it detects one.
mise (recommended)
mise use -g 'github:glennib/stakk'
Or from crates.io:
mise use -g 'cargo:stakk'
cargo-binstall
cargo binstall stakk
cargo install
cargo install stakk
Pre-built binaries
Download from the latest release.
Quick start
# Submit interactively — pick a stack and assign bookmarks in the TUI
stakk
# See your stacks without submitting (offline: jj only, never GitHub)
stakk show
# Submit a specific bookmark and its ancestors as stacked PRs
stakk submit my-feature
# Preview a submission without touching the repo or GitHub
stakk submit my-feature --dry-run
How stacking works
In jj, bookmarks point at changes. When bookmarks form a linear chain — each building on the previous — they represent a stack. Create the bookmarks yourself, or let stakk discover unbookmarked heads and create them for you:
○ feat-ui ← leaf
○ feat-api
○ feat-auth
◆ main ← trunk
stakk submit feat-ui pushes every bookmark from the trunk up to feat-ui and creates or updates one PR per bookmark,
basing each on the bookmark below it:
feat-auth→ PR targetingmainfeat-api→ PR targetingfeat-authfeat-ui→ PR targetingfeat-api
Each PR shows only its own diff, and a stack comment on every PR links all related PRs together.
The comment draws the stack the same way stakk show and the TUI do — leaf at the top, trunk at the bottom —
and marks the PR you are looking at:
Stack of 3 PRs — merges into main
○ #14 feat-ui
● #13 feat-api ← this PR
○ #12 feat-auth
◆ main
Re-running stakk submit is always safe — it updates existing PRs rather than creating duplicates.
Stack info placement
--stack-placement decides where the stack overview lives on each PR: a separate PR comment (default),
a fenced section in the PR body, none (write nothing and remove what is already there), or ignore
(write nothing and touch nothing).
Switching between comment and body migrates automatically,
and a submission that produces a single PR is not a stack, so no stack info is written.
Full mode table, migration details, and stack comment templating: docs/template.md,
or run stakk docs template.
Configuration
Settings come from CLI flags, then STAKK_-prefixed environment variables, then a repository stakk.toml
(found by walking up to the jj workspace root),
then the user config (~/.config/stakk/config.toml on Linux), then built-in defaults.
All fields are optional, and unknown fields cause a parse error.
# stakk.toml
= "origin"
= "draft"
= "body"
= "gb-"
Every config key and environment variable, the inherit field, and worked examples: docs/config.md,
or run stakk docs config.
GitHub Enterprise Server
github.com works out of the box.
For a GitHub Enterprise Server host, name the host with --github-host, STAKK_GITHUB_HOST,
github_host in stakk.toml, or GH_HOST —
stakk then accepts remotes on that host and uses its API at https://<host>/api/v3.
Tokens are resolved per host, mirroring the GitHub CLI, so an Enterprise token is never sent to github.com.
Host resolution order, the per-host token order, and the API base: docs/config.md,
or run stakk docs config.
Usage
stakk --help and stakk <subcommand> --help are the flag reference: every flag, its default,
and its environment variable.
stakk
Launches the interactive submission flow.
A ratatui TUI shows a graph of all branch stacks; select a leaf branch, then toggle bookmarks on commits that need them.
Works even in repos with no pre-existing bookmarks — stakk creates stakk-<change_id> bookmarks for unmarked commits.
Identical to stakk submit with no arguments.
stakk submit [bookmark]
Submit a bookmark and all its ancestors as stacked PRs. Without a bookmark argument, it runs the interactive flow above.
Non-interactive selection
--keep, --keep-all, --new, --new-auto and --new-command replace the TUI with a fully explicit,
scriptable selection: the marks determine the PR set with nothing implicit, all marks must lie on one trunk-to-tip path,
the topmost mark is the tip, and bookmarks on the path that are not kept fold into the PR above them.
rev is a change id or commit id prefix as printed by stakk show, which makes submission a two-command loop:
stakk show --format=json
stakk submit --keep base --new qzvs=my-feature --new-auto wmtk --dry-run
stakk submit --keep base --new qzvs=my-feature --new-auto wmtk
Every selection rule, the machine-readable stakk::selection::* diagnostic codes, and the JSON schema:
docs/scripting.md, or run stakk docs scripting.
Pointing a coding agent at stakk docs scripting is the fastest way to bring it up to speed.
PR titles and bodies
PR titles come from the first line of the jj change description and bodies from everything after it;
segments with multiple commits join their descriptions with --- separators.
Both are written only on PR creation, so manually edited PR descriptions are never overwritten — --sync-pr-content
(title, body, all) opts into updating existing PRs, and only changed fields are sent.
Descriptions are reflowed on the way into the PR body: hard-wrapped prose lines are joined into soft-wrapped paragraphs,
so a commit message wrapped at 72 columns does not render as ragged lines on GitHub.
Structural Markdown — headers, lists, tables, block quotes, fenced and indented code, thematic breaks —
is passed through verbatim.
--trailers strip removes the trailing key/value block
(Signed-off-by, Co-authored-by, Refs, …) from the generated body; the default keep passes it through.
Custom bookmark names
--bookmark-command names bookmarks with an external program.
The command is run through sh -c (Unix) or cmd /C (Windows),
receives a JSON description of one segment of commits on stdin, and must print a single bookmark name on stdout.
It powers the [*] state in the TUI and the --new-command selection flag.
The full JSON schema, with a worked example, is in stakk submit --help;
the surrounding context is in docs/template.md, or run stakk docs template.
Immutable commits
Commits that jj considers immutable cannot get a new bookmark: the default bookmarks revset excludes immutable(),
so stakk would create a PR it could never see again on the next run.
The TUI locks such rows to [ ] and explains why, --new <rev> on one fails with stakk::selection::rev_immutable,
and the commits are annotated in stakk show.
A row only unlocks when the commit is a real segment boundary —
a bookmark exists on it and the bookmarks revset does not filter that bookmark out
(the default ~ immutable() term does).
So if you really need a PR there, create the bookmark yourself and drop ~ immutable() from --bookmarks-revset;
otherwise move the work onto mutable commits.
The non-interactive side of this is in docs/scripting.md, or run stakk docs scripting.
stakk show
Display repository status and all bookmark stacks without submitting.
Fully offline: only jj is queried, never GitHub — PR state is stakk submit --dry-run's job.
show and submit build the graph the same way, so --bookmarks-revset/--heads-revset apply to both:
what show prints is what submit would work on.
The default pretty format renders a jj-log-style graph of all stacks, always fully expanded.
Every commit row carries its short change id, bookmarks, and description summary;
immutable commits and bookmarks excluded by the bookmarks revset are annotated:
Default branch: main
Remote: origin git@github.com:you/repo.git (you/repo)
○ wmtk feat-a (no description set)
○ wmtl "feat a work"
│ ○ rlkv feat-b "feat b work" (immutable — bookmark old-mark excluded by --bookmarks-revset)
├─╯
○ qzvs base "extend base"
○ qzvt "add base"
◆ trunk
Bookmarks whose history contains a merge commit cannot be stacked and are left out of the graph; when that happens,
pretty prints a (N bookmark(s) excluded due to merge commits) footer.
--format=json emits a schema-versioned document for machine consumption
(scripts, agents); its change id prefixes and bookmark names can be passed directly to stakk submit.
Field-by-field schema: docs/scripting.md, or run stakk docs scripting.
stakk docs [topic]
Print the documentation bundled into the binary.
Because it ships inside the binary, it always describes the version you are actually running.
Run stakk docs with no arguments for the list of topics.
Each topic is one of the Markdown files in docs/, which is also where to read them on GitHub.
At a terminal the prose is re-flowed to your terminal width.
Redirected, the source is emitted verbatim —
so stakk docs scripting >> AGENTS.md writes exactly the Markdown in docs/scripting.md,
which makes it a one-line way to give a coding agent the full non-interactive workflow.
stakk completions <shell>
Generate shell completions.
Supported shells: bash, zsh, fish, elvish, powershell.
# Zsh — add to your fpath
stakk completions zsh > ~/.zfunc/_stakk
# Bash
stakk completions bash > ~/.local/share/bash-completion/completions/stakk
# Fish
stakk completions fish > ~/.config/fish/completions/stakk.fish
stakk auth test
Validate that GitHub authentication is working, and print the resolved host and the authenticated username.
stakk auth setup
Print instructions for setting up authentication. stakk resolves a GitHub token for the host the remote points at. For github.com, in this order:
- GitHub CLI (
gh auth token) — recommended GITHUB_TOKENenvironment variableGH_TOKENenvironment variable
For a GitHub Enterprise Server host, gh auth token --hostname <host>, then GH_ENTERPRISE_TOKEN,
then GITHUB_ENTERPRISE_TOKEN.
Design
stakk never calls git directly.
All git operations go through jj subcommands (jj git push, jj git remote list, etc.).
This means stakk works automatically in jj workspaces and non-colocated repositories —
two cases where calling git directly fails.
All forge interaction goes through a Forge trait.
GitHub is the first (and currently only) implementation, but the core submission logic is forge-agnostic.
This opens the door to Forgejo, GitLab, or other platforms in the future.
The submission pipeline is split into three phases:
- Analyze — pure function, no I/O, fully testable with mock data
- Plan — queries the forge for existing PRs, determines actions
- Execute — pushes bookmarks, creates/updates PRs, manages comments
This separation makes the business logic testable without hitting real APIs, and --dry-run falls out naturally
(run phases 1 and 2, skip 3).
License
MIT OR Apache-2.0