Skip to content

feat: add Jujutsu (jj) support via VCSOperations abstraction#496

Draft
jucor wants to merge 6 commits into
ejoffe:masterfrom
jucor:jj-compat
Draft

feat: add Jujutsu (jj) support via VCSOperations abstraction#496
jucor wants to merge 6 commits into
ejoffe:masterfrom
jucor:jj-compat

Conversation

@jucor
Copy link
Copy Markdown

@jucor jucor commented Mar 30, 2026

Hi @ejoffe

Full transparency: I have used Claude Code (Opus 4.6 extended) a lot for this PR, just as I do for my own work nowadays. I held it on a tight leash, and will be beta-testing intensively on the large stack of commits I have in my current main work repo. This PR solves a real need I have for work.

However, I am accutely aware of the hot debate about using AI in open-source, and the risk of unwelcome "drive-by PRs". I do not want to be a bother: if you do not want AI-generated code here, please do feel free to say so, and I can just keep using my fork in my corner! That's also why I tag this PR as "draft". It's functional, just wanted to discuss first.

Summary

Adds Jujutsu (jj) support to spr. When a .jj/ directory is detected (jj-colocated repo), spr uses jj-native commands for history-rewriting operations instead of git rebase. This preserves jj change IDs across all spr operations.

Key changes:

  • New vcs/ package with a VCSOperations interface abstracting the 7 operations where git and jj differ
  • GitOps: pure extraction of existing logic (zero behavior change for git-only repos)
  • JjOps: jj-native implementation using jj describe, jj rebase, jj squash, jj edit
  • Auto-detection of .jj/ directory, with opt-out via --no-jj flag, SPR_NOJJ env var, or noJJ: true in ~/.spr.yml
  • jj-setup command to register a jj spr alias
  • 31 new tests, all existing tests pass unchanged

Motivation

jj is gaining traction as a git-compatible VCS with an updated mental model (immutable commits, change IDs that survive rewrites). spr is the best stacked-PR tool for squash-merge workflows on GitHub. But currently, every spr update runs git rebase, which destroys jj change IDs in a colocated repo — making the two tools painful to combine.

This PR makes them work together cleanly: spr's commit-id trailers + PR management coexist with jj's change IDs and operation model.

There exists a work-in-progress jj-spr tool, but it is lacking several features (need 4 manual commands for each merge or the stack breaks), so I figured it was probably better to adapt the battle-tested spr to jj rather than have a new project from scratch.

How it works

spr operation git (unchanged) jj (new)
Fetch + rebase git fetch + git rebase --autostash jj git fetch + jj rebase -b @ -d main@origin
Add commit-id trailers git rebase -i with spr_reword_helper jj describe -r <change-id>
Amend into commit git commit --fixup + git rebase -i --autosquash jj squash --into <change-id>
Edit a commit git rebase -i with edit stop jj edit <change-id>
Stash for push git stash / git stash pop No-op (jj working copy is always a commit)

Git push, branch management, and all GitHub API calls remain unchanged.

Backward compatibility

  • The NewStackedPR constructor accepts vcsOps as a variadic parameter — existing callers work without modification
  • Auto-detection only activates when .jj/ exists; plain git repos behave identically to before
  • All existing tests pass with -race, zero modifications needed
  • commit-id trailer format is unchanged (commit-id:XXXXXXXX)

Test plan

  • 31 new tests covering: detection, factory, opt-out, git ops (5), jj parser (9), jj ops (11), detection + factory (6)
  • All existing tests pass unchanged (go test -race ./...)
  • Manual testing on a real jj-colocated repo with GitHub PRs (in progress — beta testing on my own repos)

@jucor jucor force-pushed the jj-compat branch 4 times, most recently from ef2d8fd to 3e041d5 Compare April 2, 2026 19:45
@jucor jucor force-pushed the jj-compat branch 3 times, most recently from ed38421 to 49dfab9 Compare May 19, 2026 22:58
Adds end-to-end Jujutsu support for stacked PRs in colocated git+jj repos,
preserving jj change IDs across all spr operations and using jj-native
primitives (auto-rebase, conflicts as commits, non-blocking edits) instead
of mimicking git's rebase-session model.

Core architecture:
- New vcs.VCSOperations interface — the abstraction boundary for
  history-rewriting operations (FetchAndRebase, GetLocalCommitStack,
  AmendInto, EditStart/Finish/Abort, Fetch, PushBranches, etc.).
- vcs/git_ops.go: git implementation (extracted from spr.go, behavior-
  preserving). Improved EditFinish uses .git/REBASE_HEAD to distinguish
  conflict-resolution from initial edit stop and stages only tracked
  changes (subsumes upstream's 606df43 and 0767a45 fixes).
- vcs/jj_ops.go: jj implementation. Uses jj-native commands:
  jj git fetch + jj rebase, jj describe (for auto-added commit-id
  trailers), jj squash --into, jj edit, jj bookmark set + jj git push.
- Commit.ChangeID field (empty in git mode, populated in jj mode).
- vcs/detect.go: factory chooses JjOps when .jj/ exists and User.NoJJ
  is false; respects --no-jj flag and SPR_NOJJ env var.

Auto-revset (the key correctness fix):
- JjOps.GetLocalCommitStack queries trunk()..(@:: | ::@) — the
  connected component containing @, excluding trunk — rather than the
  position-dependent trunk()..@. Result: spr finds the full stack
  regardless of where @ is, so 'spr update' from a mid-stack @ no
  longer silently truncates the stack and closes PRs for the missing
  commits.
- JjOps.CheckStackCompleteness detects multi-head ambiguity via
  heads(trunk()..(@:: | ::@)) and refuses non-linear stacks with a
  clear error pointing at jj rebase / jj edit as the fix.
- checkStackUsable (renamed from confirmIfIncompleteStack, used as
  guard in update/merge/status/check/amend) blocks rather than prompts
  on ambiguity — no safe 'continue anyway' answer exists.

jj-mode behavioral divergences:
- spr edit (jj): announces the action then runs 'jj edit <change-id>'.
  No state file, no op-id snapshot, no rebase-session machinery —
  jj is non-blocking and 'jj undo' handles revert. --done and --abort
  are echo-only deprecations that point users at native jj.
- spr sync (jj): runs 'jj git fetch' (the literal 'git cherry-pick'
  was broken in jj). No rebase promise — that's spr update's job.

VCS-layer integrations:
- Configurable branch prefix (upstream feature b01881d): jj_ops
  threads cfg.User.BranchPrefix through PushBranches' bookmark glob.
- Stdout/stderr separation in vcs/jj_cmd.go: replaced
  cmd.CombinedOutput() with separate capture so jj's stderr messages
  (e.g. 'Rebased 1 descendant commits onto updated working copy')
  don't corrupt parsed log output. Caught by the conflicted-push
  integration test.

Testing:
- Two suites: unit (mockjj-based, fast) and integration (real jj via
  the new vcs/jjtest package + a colocated test repo).
- Integration build tag //go:build integration; SPR_SKIP_JJ_INTEGRATION=1
  env var to skip cleanly.
- jjtest.NewRepo creates a colocated git+jj repo in t.TempDir() with a
  bare-repo origin; helpers for stack topology (AddCommit, Edit, Fork,
  InsertAbove, SnapshotOpLog, AssertOpsSince).
- Headline regression: TestSprIntegration_StatusFromMidStack_ShowsFullStack
  pins that GetLocalCommitStack from mid-stack @ returns the whole stack.
- TestJjIntegration_PushBranches_ConflictedCommit_Refused pins jj's
  natural refusal to push conflicts (this is the test that revealed
  the stderr bug).
- 100% statement coverage on jj-related production code (jj_ops.go,
  jj_cmd.go, jj_parse.go).
- Op-log assertions verify that spr commands do exactly the expected
  jj operations and no destructive ones.

Coverage tooling:
- scripts/coverage-merge/main.go merges two coverprofile files (max
  hits per block, within and across profiles) and reports per-file
  attribution (unit %, integ %, merged %, plus 'only in X' sections).
- Makefile targets: test-unit, test-integration, test-all, coverage-html.
- CI installs pinned JJ_VERSION (0.40.0), runs both suites with
  -coverpkg=./..., merges, uploads as artifact.

Documentation:
- readme.md: new Jujutsu (jj) Support section covering setup, the
  jj spr command set, divergences for edit/sync, the multi-head error
  message, and the auto-revset behavior.
- docs/jj-mode-design.md: full design rationale including alternatives
  considered (spr-tip bookmark, 3-option prompt), the stderr bug fix,
  testing strategy, and what's deferred.

Files: +4500/-110 across 35 files.
jucor added 5 commits June 4, 2026 12:24
When a stacked PR is squash-merged on GitHub, the resulting commit on
trunk absorbs the cumulative diff of all PRs at-or-below the merge
target. Local commits whose patches are subsumed by that squash should
drop out on the next fetch+rebase — git mode does this automatically
via patch-id detection in plain `git rebase`. jj mode did not: vanilla
`jj rebase` left them as [EMPTY] stubs cluttering the stack.

Passing --skip-emptied makes jj match git's self-healing behavior for
the clean form of the cascade (no local drift). It does NOT cover:
  - User-edit drift (orphan commit amended locally before next update)
  - Structural overlap (multiple PRs touching the same file with
    cumulative diffs that don't bit-match individual slices)
Both cases still produce conflicts on rebase; the next step (orphan-
abandon in FetchAndRebase) closes them.

Adds a characterization test suite:
  - vcs/gittest/        — bare remote + local clone harness, mirrors jjtest
  - vcs/gittest/cascade_integration_test.go — git mode self-heals (asserted)
  - vcs/jj_cascade_integration_test.go      — 6 sub-tests covering vanilla
    vs --skip-emptied across clean, drift, and structural-overlap variants
  - TestJjOps_FetchAndRebase_SelfHealsCleanCascade — spr-API regression
    guard confirming the flag is wired up

mockjj/mockjj.go ExpectRebase / ExpectRebaseAndFail updated to match
the new command form.
Phase 2a of the cascade-orphan fix: introduce the VCS-level plumbing
that abandons local commits whose PRs have been orphaned by an upstream
squash, before rebase has a chance to see them. Without this, --skip-
emptied alone resolves the clean case (orphan whose content is wholly
absorbed by trunk) but the polis production form (structural overlap on
shared files, or local drift on amended orphans) still produces
conflicts.

Interface changes:
  - VCSOperations.FetchAndRebase now takes (cfg, orphanChangeIDs).
    Callers pass nil for backwards compatibility; Phase 2c will populate.
  - VCSOperations.AbandonChangeIDs([]string) — new hook.

JjOps:
  - FetchAndRebase: fetch → AbandonChangeIDs(orphans) → rebase
    --skip-emptied. Abandon errors short-circuit before rebase, so
    rebasing with a still-orphan commit never happens.
  - AbandonChangeIDs: jj abandon <id> per entry; empty input is no-op.

GitOps:
  - FetchAndRebase: signature change; orphans ignored. Git's rebase
    handles the clean case via patch-id; drift/overlap cases have no
    git-side equivalent fix today.
  - AbandonChangeIDs: no-op for interface compat.

Mocks: mockjj.ExpectAbandon + ExpectAbandonAndFail.

Tests:
  - vcs/jj_ops_test.go: 5 unit tests covering AbandonChangeIDs (empty,
    multiple ordered, error short-circuit) and FetchAndRebase (with
    orphans, with abandon-fail, with NoRebase).
  - vcs/git_ops_test.go: AbandonChangeIDs no-op + FetchAndRebase
    ignores orphans (regression guard).
  - vcs/jj_cascade_integration_test.go: 2 new spr-API regression
    guards — OrphanPrune_ResolvesDrift and OrphanPrune_ResolvesStructur-
    alOverlap — verifying the previously-stuck scenarios from the
    characterization suite now resolve to a clean stack.

spr.fetchAndGetGitHubInfo passes nil orphans; populated in Phase 2c.
Phase 2b of the cascade-orphan fix: expose GitHub's view of orphan PRs
(state=CLOSED-not-merged whose head ref matches the spr branch prefix)
so the spr update flow can identify which local change IDs to abandon
before rebase.

GraphQL:
  - New ClosedOrphanPullRequests query in queries.graphql, mirroring
    PullRequests' shape but with states:[CLOSED]. GitHub's enum has
    CLOSED (closed-not-merged) as distinct from MERGED, so this filter
    alone is sufficient — no client-side dedup needed.
  - gen/genclient/operations.go regenerated via `go generate`.

GitHubInterface:
  - GetClosedOrphanPRs(ctx) []*PullRequest returns orphan candidates
    with .Commit.CommitID populated from the HeadRefName, so callers
    don't need to re-parse trailers. Nil on transport error; empty
    slice on "no orphans found".

githubclient.client:
  - GetClosedOrphanPRs delegates the per-node filter to
    filterClosedOrphans (pure helper, no transport) so the matching
    logic can be unit-tested without mocking the genclient interface
    (14 methods, too many to stub for one test).

Mockclient:
  - MockClient.GetClosedOrphanPRs + ExpectGetClosedOrphanPRs.
    Canned response via package-level MockClientClosedOrphans slice
    so test setup is a single assignment.

Tests:
  - client_test.go: TestFilterClosedOrphans — 4 sub-tests covering
    nil input, no-orphans, mixed match/skip, custom branch prefix.

Unrelated drive-by:
  - go.mod: golang.org/x/tools upgraded so `go generate` works on
    Go 1.26 (old version had constant-overflow build error).
  - git/mockgit/mockgit.go: drop fmt.Sprintf in Mock.expect; new vet
    flagged non-constant format strings. Callers already pre-format.
Phase 2c of the cascade-orphan fix: bridge the GitHub-side orphan
signal (Phase 2b) to the VCS-side abandon plumbing (Phase 2a) so
`spr update` automatically cleans up orphaned local commits before
rebase, avoiding the conflict cascade described in
docs/jj-mode-design.md (and witnessed on the polis stack).

Flow:
  identifyOrphanChangeIDs(ctx):
    1. honor cfg.User.NoPruneOrphans (opt-out, default false)
    2. github.GetClosedOrphanPRs(ctx) — pre-fetch, only GitHub API
    3. build orphan commit-id set from PR HeadRefName regex
    4. vcsOps.GetLocalCommitStack — pre-fetch, but change IDs and
       commit-id trailers are stable across fetches so this is safe
    5. intersect local commits' CommitID with orphan set; collect
       ChangeID for each match
    6. nil safety — empty short-circuits skip GetLocalCommitStack

  fetchAndGetGitHubInfo: now calls identifyOrphanChangeIDs first, then
  passes the result into FetchAndRebase. JjOps abandons each ID before
  rebase; GitOps ignores (no-op per its interface contract).

Config: NoPruneOrphans bool (default false). Set true to skip orphan
detection — useful for investigating closed-not-merged PRs manually
before they're dropped, or if a transient GitHub query failure should
not block updates.

Mockclient: GetClosedOrphanPRs is intentionally NOT expectation-
verified — it's a pure read of GitHub PR state that fetchAndGetGitHubInfo
calls on every `spr update`. Verifying would require adding
ExpectGetClosedOrphanPRs() to 17 existing test fixtures that don't care
about the call. Tests that DO care set
mockclient.MockClientClosedOrphans to the canned response.

Tests:
  - spr/spr_test.go: TestIdentifyOrphanChangeIDs — 5 sub-tests
    covering opt-out, no-orphans, match/no-match, missing-change-ID
    (git-mode safety).
  - Existing UpdatePullRequests fixtures unchanged: empty
    MockClientClosedOrphans means the new call returns nil and the
    rest of the flow matches pre-2c behavior.
User-facing docs to surface what the previous four commits actually do.
Without this, a user running 'jj spr update' on a stack with closed
orphan PRs would see commits silently abandoned with no obvious way to
look up why or how to disable it.

docs/jj-mode-design.md:
  - New 'Cascade-orphan recovery' section between 'Conflicts' and
    'VCS interface changes'. Covers: the problem (squashing mid-stack
    leaves orphan PRs and their content in two places), git-mode self-
    heal, jj-mode's two failure modes (empty stubs, conflicts), what
    spr does about it (--skip-emptied + pre-rebase jj abandon), the
    noPruneOrphans opt-out, and the scenarios where you'd still need
    manual recovery. Points to handoffs/ for the polis post-mortem
    that motivated the work.

readme.md:
  - New 'Merging mid-stack and cascade-orphan recovery' section under
    Jujutsu Support, between editing and non-linear stacks. Explains
    the safety net in plain language for the user case who hits it
    accidentally, and points to the design doc for mechanism.
  - noPruneOrphans row added to the User Configuration table with a
    short pointer back to the design-doc section.

No CLI flag added — yaml-only opt-out, matching noRebase/noFetch/noJJ
convention. The flag isn't a hot path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant