agentleFS
jamestexasjamestexas/agents/skills/jj-stack/SKILL.md

jj-stack

Skill2 starsChanged 6 months agoApache-2.0View on GitHub

Turn one dev branch into small, individually-reviewable stacked PRs that land on a feature branch (one final merge to main), using jujutsu (jj) colocated with git plus keyless commit signing. Use after pr-buckets decides the slice boundaries, or whenever a large or multi-PR ticket wants one branch you follow linearly, split into tiny PRs peers review. Captures the jj primitives (colocate, duplicate, split, bookmark, sign, push) and the parts jj does not give you by default — the signing-backend wiring and the feature-branch orchestration.

  • Deletes or force-pushes
  • Installs packages
  • Commits and pushes

The file

We split this skill into the rules it gives an agent

9 rules. About 0 are common, 3 are distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Run it in a linked worktree and jj refuses with a hint that names the two ways out:

    line 41
  2. Distinctive:

    If the target branch is checked out in a linked worktree, that's fine — you duplicate it here (Phase 2) and leave the worktree's branch as …

    line 47
  3. Distinctive:

    Verify it actually signs before trusting it (Phase 4).

    line 103
  4. Very specific:

    Read the stack's change ids: jj log -r '<base>::<dev-branch>' -T 'change_id.shortest(6) ++ " " ++ description.first_line() ++ "\n"'.

    line 113
  5. Very specific:

    Use description(substring:"feat: x") if you must match text at all.

    line 125
  6. Very specific:

    If a bucket needs a *sub-file* split, use jj-hunk — it does programmatic hunk selection for split/commit/squash without opening a diff …

    line 148
  7. Very specific:

    Verify each bucket's contents: jj diff -r <bookmark> --name-only, and sanity- check the split is lossless (sum of split slices == the …

    line 167
  8. Very specific:

    Force-reset main: If local main diverged (behind upstream + a local commit not upstream), a gh repo sync --force / hard reset discards …

    line 248
  9. Very specific:

    Every jj step is reversible: jj op log shows the operation trail and jj undo reverts the last one.

    line 260

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md
<!-- Author: jamestexas -->

/jj-stack — split one branch into signed, stacked PRs on a feature branch

The workflow, and why it exists: agentic work wants one branch you (and the agent) reason about linearly; reviewers want small PRs; the org wants one clean merge. jj resolves the tension — you develop on one branch, jj split it into tiny commits, push each as a small PR that targets a feature branch, and the feature branch is a single squash/merge to main. A reviewer can review each small PR into feature; the feature merge is the one that hits main.

jj gives the primitives (split, duplicate, bookmark, push, signing backends). This skill supplies the two things it does not: keyless-signing wiring and the feature-branch orchestration. Nothing here shells out from a script — it's a procedure of jj/git/gh commands; the only file written is the jj/git config.

Phase 0 — prerequisites (one-time per machine/repo)

  1. jj installed. command -v jj || brew install jj (formula is jj).
  2. Colocate the repo. jj cannot git init --colocate inside a git worktree — do it in the MAIN checkout:
    cd <main-repo> && jj git init --colocate     # non-destructive; git untouched; rm -rf .jj reverts
    Run it in a linked worktree and jj refuses with a hint that names the two ways out:
    Error: Cannot create a colocated jj repo inside a Git worktree.
    Hint: Run `jj git init` in the main Git repository instead, or use `jj workspace add` …
    If the target branch is checked out in a linked worktree, that's fine — you duplicate it here (Phase 2) and leave the worktree's branch as the backup.
  3. Author identity (empty identity can't push):
    jj config set --user user.name  "<Name>"
    jj config set --user user.email "<email>"
  4. Snapshot noise — and why it is dangerous for agents. jj refuses to snapshot files over ~1 MiB. The refusal is a Warning:, not an error: the command still exits 0, and the oversized file is silently left untracked and out of the commit you just made. An agent that trusts exit codes will report a clean commit that is missing a file.
    Warning: Refused to snapshot some files:
      big.bin: 8.0KiB (8192 bytes); the maximum size allowed is 1.0KiB (1024 bytes)
    So: grep the output for Refused to snapshot after any command that snapshots (status, commit, split, diff — any of them). Then fix the cause by ignoring the cruft (*.test, *.log, build-binary paths in .git/info/exclude) and/or raising the guard for this repo:
    jj config set --repo snapshot.max-new-file-size 60MiB
  5. Signing backend. This is the load-bearing setup jj lacks by default. Field reference: config → commit signing.
    • Keyless / gitsign (sigstore x509): jj's X.509 backend is gpgsm, which is exactly what gitsign is (git wires it via gpg.format=x509, gpg.x509.program=gitsign). So:
      jj config set --user signing.backend "gpgsm"
      jj config set --user signing.backends.gpgsm.program "gitsign"
    • Then pick a signing.behavior — this is the choice that decides whether a stacked workflow is pleasant or miserable. Valid values are drop, keep (the default), own, force.
      • own signs every commit you author on every rewrite. jj always re-signs; it cannot detect that a commit is already signed by you (jj#5786). Splitting, describing, and rebasing a stack rewrites commits constantly, so own means one signing operation per rewrite per commit.
      • For rewrite-heavy stacked work, prefer drop + sign-on-push:
        jj config set --user signing.behavior "drop"
        jj config set --repo git.sign-on-push true
        Rewrites stay unsigned and cheap; every commit is signed in one pass at push, which is also the only moment the signature has to exist.
    • SSH / GPG: set signing.backend to ssh/gpg and the matching signing.backends.<b>.program, translating your existing git signing config.
    • Expect "Unverified" on GitHub for gitsign commits. Sigstore's root is not in GitHub's trust store, so the web UI badge is not the check that matters. Verify with gitsign verify (or jj log, Phase 4) instead of treating the badge as a failure.
    • Verify it actually signs before trusting it (Phase 4).

Phase 1 — take the buckets

Get the slice manifest from pr-buckets (or accept one). You need, per bucket: the files it owns, its dependency (base or another bucket), and whether it's off-stack (bases on the feature branch directly, e.g. a test-harness seam).

Phase 2 — duplicate, so the original branch stays a backup

Read the stack's change ids: jj log -r '<base>::<dev-branch>' -T 'change_id.shortest(6) ++ " " ++ description.first_line() ++ "\n"'.

Duplicate the whole stack — copies get new change ids; the original branch (and any worktree checkout of it) is untouched:

jj duplicate '<bottom-change>::<top-change>'

Work on the duplicates from here.

Address commits by change id, not by description. description("…") matches exactly (descriptions carry a trailing newline), so description("feat: x") returns the empty set and every command built on it silently operates on nothing. Use description(substring:"feat: x") if you must match text at all.

Phase 3 — split by bucket (file-disjoint = non-interactive)

jj split -r <rev> <paths...> puts those paths in the first commit, the rest in a child. Passing a fileset already suppresses the interactive diff editor, and -m supplies the description non-interactively, so the whole split is one command per bucket:

jj split -r <delegation-copy> <bucket-3a-files> -m "feat(x): dispatcher …"   # -> 3a ; remainder is the rest
jj split -r <remainder>       <bucket-3b-files> -m "feat(x): wiring …"        # -> 3b ; remainder -> 3c
jj describe -r <3c> -m "test(x): parity flip …"                              # the last remainder

-m describes the selected side only. The remainder keeps the original commit's description verbatim, which is why the final remainder still needs an explicit jj describe. Check descriptions after splitting, don't assume them.

Do not point EDITOR at a no-op (/usr/bin/true) to skip the description prompt. A no-op editor "accepts" the prefilled text, so both halves of the split silently end up with the same description — and neither is wrong enough to notice. You get a stack whose PR titles are all the pre-split message. The fileset + -m form above is the fix.

If a bucket needs a sub-file split, use jj-hunk — it does programmatic hunk selection for split/commit/squash without opening a diff editor, and is on jj's own community-tools list as the option for scripts and coding agents. Otherwise jj split -i -r <rev> opens the interactive diff editor — hand that to the human.

Set a bookmark per bucket (jj rewrites descendants on split, so use change ids, which are stable):

jj bookmark set <feature>-1-<slug> -r <change>
… one per bucket …

Two bookmark behaviors matter here:

  • Bookmarks follow rewrites. Describe or rebase the commit and the bookmark moves with the change id to the new commit id. Nothing to re-point.
  • Abandoning a commit DELETES its bookmark. jj abandon -r <change> prints Deleted bookmarks: <name> and the bookmark is gone, not relocated to the parent. If you abandon a slice, re-create its bookmark deliberately. Verify each bucket's contents: jj diff -r <bookmark> --name-only, and sanity- check the split is lossless (sum of split slices == the original commit's diff).

Off-stack buckets — jj parallelize

pr-buckets marks buckets that don't depend on the rest as off-stack. Those shouldn't sit in the linear chain: an off-stack slice stacked on three unrelated slices can't be reviewed or merged until they land. jj parallelize makes a run of commits siblings on their common parent, so each becomes an independent PR against the feature branch:

jj parallelize '<first-change>::<last-change>'

Gotcha: only contiguous ranges work. parallelize takes the :: range form. A sparse revset silently does nothing:

jj parallelize 'c1 | c3'     # -> "Nothing changed."  (c2 is not in the set, so c3 keeps its ancestry)
jj parallelize 'c1::c3'      # -> c1, c2, c3 all become siblings of the base

It reports Nothing changed. and exits 0 — no error to catch. If you need to lift a non-adjacent commit out, jj rebase -s <change> -d <base> it directly instead.

Phase 4 — sign the whole stack in one op

With git.sign-on-push = true (Phase 0) this phase is the push itself — skip to Phase 5 and verify afterward. Sign explicitly only when you need signatures before pushing:

jj sign -r '<bottom-change>::'      # signs the range; default is the revsets.sign setting

Either way, sign the whole range in one op. Signing rewrites the commit, and rewriting a mid-stack commit rebases every descendant into new commit ids — so a one-commit signing pass leaves the bookmarks above it pointing at unsigned commits. This is the argument for sign-on-push: push is the one moment the whole stack is rewritten together anyway.

Verify — don't trust the config blindly. Signature columns are hidden by default (ui.show-cryptographic-signatures is false), so ask for the field:

jj log -r <bookmark> -T 'if(signature, signature.status(), "NONE")'   # want: good

Keyless note: gitsign signs from cached OIDC creds if fresh (no prompt); if stale it needs one interactive browser OIDC flow (device mode is unsupported) — that flow is the human's.

Phase 5 — feature branch + stacked PRs

See jj's own GitHub workflow page for the general push/fetch/review-comment mechanics; below is only the feature-branch orchestration it doesn't cover.

  1. Create the feature branch on the base (this is the single-merge target):
    jj git push --named <feature>=<base>       # creates, names, and tracks in one step
    --named name=revision is the one-step form: it creates the bookmark, pushes it, and sets it tracking the remote. Prefer it over jj bookmark set + jj git push --bookmark, which is two steps that can disagree. (There is no --allow-new flag in jj 0.45.1 — --named is how you push a bookmark the remote has never seen.)
  2. Push each bucket bookmark and open its PR targeting the feature branch (not main), in dependency order:
    jj git push --named <feature>-1-<slug>=<change>      # first push of this slice
    jj git push --bookmark <feature>-1-<slug>            # subsequent updates
    gh pr create --base <feature> --head <feature>-1-<slug> --draft --title "…" --body "…(links prev slice)…"
    … 2 bases on 1, 3a on 2, … ; off-stack buckets base on <feature> directly …
    For a throwaway push where the name doesn't matter, jj git push --change <rev> auto-creates a bookmark named push-<full-change-id>. Convenient for a quick CI run; don't use it for review slices — reviewers need names that say what the slice is. Draft is the default — they're dependent slices; draft signals "review in order, don't merge yet." Each PR body links its parent slice for sequencing.
  3. Reviewers review each small PR into the feature branch. When all are in, the feature branch is the single merge to main (squash or merge, per repo convention).

Do NOT

  • Force-reset main. If local main diverged (behind upstream + a local commit not upstream), a gh repo sync --force / hard reset discards that commit. Instead git fetch and jj rebase the stack onto the fresh base.
  • Try to move a tool out of its module because of replace. Go's internal- package rule ignores replace — a helper importing …/internal/… must live inside that module. Relevant when a bucket is a dev tool that imports internals.
  • Sign a mid-stack commit and forget the descendants. Sign the whole range in one op (Phase 4) so bookmarks don't end up on unsigned rebased copies.
  • Trust a jj exit code as "it worked". Snapshot refusals and no-op parallelize both warn and exit 0 (Phase 0.4, Phase 3). Read the output.

Recovery

Every jj step is reversible: jj op log shows the operation trail and jj undo reverts the last one. Note the command is top-level jj undo — jj op undo does not exist (verified on 0.45.1; it errors with unrecognized subcommand). The duplicated original branch is your belt-and-suspenders.

What jj gives you vs what this skill adds

  • jj native: split, duplicate, bookmark, git push, sign, parallelize, signing backends (gpg/gpgsm/ssh), automatic descendant rebase.
  • Not native (this skill): the gpgsm→gitsign wiring, the feature-branch single-merge orchestration, PR creation (gh), and the bucket→bookmark mapping.

Upstream references (read these instead of a copy)

Command flags and semantics change between releases; this skill deliberately does not restate them.

  • config → commit signing — every signing key, backend, and behavior.
  • GitHub workflows — push/fetch, addressing review comments, colocated repos, gh setup.
  • community tools — the sanctioned integration list.
  • jj help <cmd> / jj <cmd> --help is the ground truth for the installed version.

Adjacent tools, if this skill's hand-rolled orchestration stops paying for itself:

  • jj-stack — name collision with this skill, different project. On jj's own community-tools list: "turns a linear series of local jj changes into a stack of GitHub pull requests... run jj-stack submit to update GitHub." Does the Phase 3/5 split-and-push mechanics this skill hand-rolls, but targets one PR per commit directly (no feature-branch single-merge step) — check it before re-deriving this skill's orchestration from scratch.
  • jj-spr — stacked PRs via shadow base branches, so each PR's diff is just its own slice. Different tradeoff from the feature-branch approach here.
  • LazyJJ — a jj distribution shipping a stacked workflow (stack-view/-top/-sync, PR integration) preconfigured. Not the same project as the lazyjj TUI on jj's community-tools list; the names collide.

Verified against (2026-10-16)

Installed jj 0.45.1; latest upstream is 0.46.0 (one minor behind, not urgent). Confirmed against live docs + --help on the installed binary: signing config keys (Phase 0.5), jj git push --named (Phase 5.1 — real flag, documented in jj git push --help even though the web docs page omits it; trust --help over a doc-page summary), jj parallelize sibling/contiguous-range behavior (Phase 3), and that jj op undo does not exist while top-level jj undo does (Recovery). No corrections needed to command semantics.

0.46.0 adds jj workspace add --[no-]colocate and a git.colocate setting — colocating a non-main workspace, not just the repo root. Not needed for this skill's pattern (a child workspace doesn't need git to see it directly), but worth knowing about if a future workflow wants git tooling inside a workspace other than the main checkout.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.