Skip to content

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

Draft
jucor wants to merge 8 commits intoejoffe:masterfrom
jucor:jj-compat
Draft

feat: add Jujutsu (jj) support via VCSOperations abstraction#496
jucor wants to merge 8 commits intoejoffe: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 2 times, most recently from 8194d10 to c1d1e8c Compare March 31, 2026 00:44
jucor added 5 commits April 2, 2026 18:06
When spr detects a jj-colocated repo (.jj/ directory present), it uses
jj commands for history-rewriting operations instead of git rebase.
This preserves jj change IDs across spr update/merge/amend/edit cycles.

Key changes:
- New vcs/ package with VCSOperations interface abstracting the 7
  operations where git and jj differ (FetchAndRebase, GetLocalCommitStack,
  AmendInto, EditStart/Finish/Abort, PrepareForPush)
- GitOps: pure extraction of existing git logic (no behavior change)
- JjOps: jj-native implementation using jj describe, jj rebase,
  jj squash, jj edit (preserves change IDs)
- Auto-detection: .jj/ directory triggers jj mode
- Opt-out: --no-jj flag, SPR_NOJJ env var, or noJJ: true in ~/.spr.yml
- 31 new tests, all existing tests pass unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

commit-id:6bdd63bc
GetInfo previously called git.GetLocalCommitStack internally, bypassing the
VCSOperations abstraction. In jj mode this panicked because git rebase cannot
add commit-id trailers to jj commits. Now all callers provide commits via
vcsOps.GetLocalCommitStack, and GetInfo receives them as a parameter.

Also adds CheckStackCompleteness to warn when @ has descendants in jj mode
(commits above @ would be excluded from trunk()..@), prompting for confirmation
before proceeding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

commit-id:0e2e9f53
JjCmd.Jj() uses strings.Fields to split args, which breaks template
strings containing spaces (e.g. -T 'commit_id ++ ...'). Switch to
JjArgs which passes pre-split arguments directly to exec.Command,
bypassing shell quoting entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

commit-id:95f2de31
Removed JJ_CONFIG= from jj command execution. Setting this env var
to empty caused jj to skip loading user config, resulting in empty
committer name/email on every rebase and describe operation.

commit-id:d56062ca
jucor and others added 2 commits April 2, 2026 20:43
When GetLocalCommitStack encounters a commit without a commit-id
trailer and jj describe fails (e.g. because the commit is immutable
due to untracked remote bookmarks), spr now panics with an actionable
message: which commit is affected, why it is likely immutable, and
how to fix it (jj bookmark track).

Also adds error response support to the jj mock and injects the
commit-id generator for test determinism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EditStart used MustJj() with shell-quoted template arguments like
-T 'id.short(16)'. MustJj() splits with strings.Fields which
passes the quotes as literal characters to jj, causing the template
to be echoed as-is instead of evaluated. This resulted in
op_id=id.short(16) in the edit state file, making EditAbort fail.

Switch to JjArgs() which passes pre-split arguments directly to
exec.Command, matching the fix already applied to GetLocalCommitStack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EditCommit hardcoded "git spr" in its output messages. When invoked
via "jj spr edit", the instructions told users to run "git spr edit
--done" which is confusing.

Add CommandName() to the VCSOperations interface, returning "git spr"
or "jj spr" depending on the implementation. Use it in all edit
session messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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