Skip to content

Jj compat progressive#513

Open
jucor wants to merge 12 commits into
ejoffe:masterfrom
jucor:jj-compat-progressive
Open

Jj compat progressive#513
jucor wants to merge 12 commits into
ejoffe:masterfrom
jucor:jj-compat-progressive

Conversation

@jucor

@jucor jucor commented Jun 9, 2026

Copy link
Copy Markdown

No description provided.

jucor added 12 commits June 9, 2026 13:51
Split history-rewriting VCS operations out of spr/spr.go into a new
vcs package behind a VCSOperations interface. Today this is wholly
git-backed via GitOps (a no-behavior-change extraction of the existing
git rebase / amend / edit logic). The interface is shaped so alternate
backends (e.g. jj/Jujutsu) can implement it without changing spr.go
callers.

Behavior preserved:
  - EditCommitDone's specific 'Rebase conflict detected. Resolve
    conflicts and run "<cmd> edit --done" again.' recovery hint is
    preserved by returning a sentinel vcs.ErrRebaseConflict from
    EditFinish and detecting it with errors.Is in spr.go.

Interface design:
  - GetLocalCommitStack() ([]git.Commit, error) — no args (receiver
    already holds cfg + gitcmd), error return so a future jj
    implementation can surface immutable-commit or revset errors
    cleanly instead of panicking.
  - NewStackedPR(cfg, github, gitcmd, vcsOps) — required vcsOps arg
    rather than a variadic shim, so all call sites pass an explicit
    backend.
  - vcs.NewVCSOperations factory returns GitOps unconditionally for
    now; extension points are documented.

Files:
  - spr/spr.go: callers route through sd.vcsOps.X instead of in-lining
    git commands. EditCommit / EditCommitDone / EditCommitAbort,
    SyncStack, FetchAndRebase, AmendInto, GetLocalCommitStack all
    delegate.
  - vcs/interface.go: VCSOperations + NewVCSOperations factory + the
    ErrRebaseConflict sentinel (in git_ops.go).
  - vcs/git_ops.go: GitOps — extracts the existing git logic.
  - vcs/git_ops_test.go: unit tests (mockgit).
  - cmd/spr/main.go, cmd/amend/main.go: construct VCSOperations via
    the factory and pass to NewStackedPR.
  - spr/spr_test.go: stubVcsOps for tests; existing fixtures updated
    minimally to feed the interface.
  - git/mockgit/mockgit.go: ExpectFetchTags helper for GitOps tests.

All existing tests pass under `go test -race ./...`.
golang.org/x/tools v0.21.x (transitive via fezzik) has a
constant-overflow build error on Go 1.26. Bumping the toolchain
directive resolves it so the GraphQL regeneration step in
github/githubclient runs cleanly. The actual codegen change (added
ClosedOrphanPullRequests query) lives in a follow-up commit.

No source-code changes; go.mod + go.sum only.
go vet on Go 1.25 flags non-constant format strings in
fmt.Sprintf-based wrappers. The Mock.expect helper accepted variadic
args but every existing caller already passed a fully-formatted
string, so the variadic was dead code. Signature change:

  -func (m *Mock) expect(cmd string, args ...interface{}) *Mock
  +func (m *Mock) expect(cmd string) *Mock

No call sites change because all callers pre-formatted with fmt.Sprintf.
Removes the vet diagnostic with no behavior change.
Pure plumbing for forthcoming jj/Jujutsu support — no callers yet.

  - git/commit.go: ChangeID field on git.Commit. Populated only when
    the VCS backend produces one (jj does; git doesn't). Stable across
    rewrites, unlike CommitHash.
  - vcs/jj_cmd.go: JjCmd / JjInterface — exec.Command-based wrapper
    for jj invocations. Separates stdout/stderr cleanly so parsers
    don't ingest jj's informational stderr text.
  - vcs/jj_parse.go: parser for `jj log` template output, including
    handling of multi-line descriptions, empty commits, conflict
    markers, and the commit-id trailer.
  - vcs/jj_cmd_test.go, vcs/jj_parse_test.go: unit coverage for both.

Nothing in this commit consumes any of these — the VCSOperations
factory still returns GitOps unconditionally. JjOps wiring lands in
the next commit.
Adds the jj (Jujutsu) backend behind the VCSOperations interface and
makes spr's user-facing commands jj-aware in a single coherent unit,
so jj users never observe a half-baked state when only part of this
work has shipped.

VCS layer:
  - vcs/jj_ops.go: JjOps implementation (jj git fetch / jj rebase /
    jj squash / jj edit / jj bookmark set / jj git push). Preserves
    change IDs across operations. GetLocalCommitStack returns errors
    (no panics) so the spr layer can surface immutable-commit failures
    cleanly. FetchAndRebase uses `-d trunk()` rather than
    `-d <branch>@<remote>`, tracking any user customization of jj's
    trunk() revset and staying symmetric with the
    `trunk()..(@:: | ::@)` discovery revset.
  - vcs/jj_ops_test.go: unit tests via mockjj. The immutable-commit
    and log-failure paths assert error returns (no recover()).
  - vcs/mockjj/mockjj.go: expectation-based jj mock, mirrors mockgit.
  - vcs/detect.go + tests: IsJJColocated checks for .jj/ + .git/.
  - vcs/interface.go: NewVCSOperations returns JjOps when colocated
    and !NoJJ; falls back to GitOps otherwise.

Config + CLI:
  - config/config.go: NoJJ user-config field (default false).
  - cmd/spr/main.go: --no-jj flag + SPR_NOJJ env var; new jj-setup
    subcommand to register the 'jj spr' alias.

spr layer (the jj-mode UX reshape):
  - spr/spr.go: EditCommit announces 'jj edit <change-id>' and calls
    EditStart, then exits — no session in jj mode.
  - EditCommitDone / EditCommitAbort: echo-only in jj mode. jj has no
    edit sessions; output points users at the native jj equivalents
    (jj new / jj undo).
  - SyncStack: in jj mode, runs 'jj git fetch' and stops. The git-mode
    cherry-pick logic doesn't apply to jj's change-id alignment model.

Tests:
  - spr/spr_test.go: jj-mode unit tests for each of the four entry
    points, using stubVcsOps with commandName='jj spr'.

Integration tests against a real jj binary land in the next commit
(needs the jjtest harness).
Build-tagged integration suite (//go:build integration) that exercises
the jj backend end-to-end against a real jj binary. CI runs both this
and the unit suite; locally use `go test -tags=integration`.

  - vcs/jjtest/jjtest.go: test harness — colocated repo factory, bare
    'origin' remote, helpers to add/edit/fork commits, op-log
    snapshot/diff for asserting exactly which jj operations a code
    path invoked. Doc comment in vcs/jjtest/doc.go.
  - vcs/jj_integration_test.go: VCS-layer coverage for
    GetLocalCommitStack (auto-revset across @-position variations,
    WIP truncation, divergent siblings, commit-id trailer auto-add),
    CheckStackCompleteness (multi-head detection), FetchAndRebase,
    AmendInto, PushBranches (including conflict-refusal pin), and a
    smoke test.
  - spr/jj_integration_test.go: spr-layer tests that exercise the
    backend through StackedPR — status from mid-stack (auto-revset),
    refusal-on-fork variants for update/amend/check, the immutable-
    commit error path through JjOps.EditStart, plus the four jj-mode
    behaviors introduced in the previous commit (Edit_MovesAtAndAnnounces,
    Sync_RunsJjGitFetch, EditDone_EchoOnly_NoOps, EditAbort_EchoOnly_NoOps).

TestMain in both files fails fast if jj is missing; set
SPR_SKIP_JJ_INTEGRATION=1 to skip cleanly when jj is unavailable.
Build/test infrastructure to run the jj-backed integration suite from
the previous commit alongside the existing unit suite, both locally
and in CI.

  - Makefile: `make test` (unit only, no jj needed), `make test-integration`
    (real jj, build tag), `make test-all` runs both then merges
    coverage. Per-package coverage attribution via the merge script.
  - .github/workflows/ci.yml: install pinned JJ_VERSION (v0.40.0),
    run unit + integration suites, upload coverage artifacts.
  - scripts/coverage-merge/main.go: tiny tool that merges Go coverage
    profiles per-file (max-coverage strategy) so unit + integration
    runs combine cleanly. Stdlib only, no extra deps.
  - git/helpers_test.go: pure-Go parseLocalCommitStack tests added
    (previously this code was only exercised indirectly via mockgit).

JJ_VERSION is pinned — bump intentionally when verifying against new
jj releases.
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.

commit-id:f009356f
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.

commit-id:4222bc0d
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 fezzik (`go generate
    ./github/githubclient/`).

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 returns the package-level
    MockClientClosedOrphans slice. Intentionally NOT expectation-
    verified — it's a pure read of GitHub PR state that
    fetchAndGetGitHubInfo calls on every `spr update`. Verifying
    would require updating dozens of existing fixtures with
    `ExpectGetClosedOrphanPRs()` they don't otherwise care about.

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

The Go-toolchain bump (required by x/tools for the codegen step) and
the unrelated mockgit format-string fix moved to dedicated earlier
commits to keep this one focused on the orphan-PR feature.
Bridge the GitHub-side orphan signal (closed-not-merged PRs whose
HeadRefName matches the spr branch prefix) to the VCS-side abandon
plumbing, so jj users get cascade-orphan recovery as default behavior
on every spr update.

This is *mostly* parity with git mode: `git rebase`'s patch-id
detection silently drops absorbed commits on every spr update on the
same code path. Our jj implementation extends the recovery further —
it also catches the 'structural overlap' case (multiple PRs touching
the same file with cumulative additions where each slice doesn't
bit-match the cumulative squash) which git rebase's patch-id drop
does NOT handle silently. This extension is safe to ship default-on
because jj has `jj op log` + `jj op restore`, which together with
the user-visible notice (below) gives users a one-command undo if
abandon was wrong. Git has no introspection equivalent.

Flow inside identifyOrphanChangeIDs:
  1. Short-circuit if cfg.User.NoRebase (FetchAndRebase will skip
     abandon too — silent skip avoids a misleading notice)
  2. github.GetClosedOrphanPRs(ctx)
  3. Index orphans by spr commit-id (the trailer value), keeping PR
     pointer so the user notice can name PR number + subject
  4. vcsOps.GetLocalCommitStack — intersect with orphan set
  5. For each match: collect ChangeID for abandon, plus PR + Subject
     for the user notice
  6. Print a transparent notice naming each abandoned commit and a
     `jj op restore` recovery hint

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

Mockclient: GetClosedOrphanPRs 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. Tests that DO care set mockclient.MockClientClosedOrphans to
the canned response.

Tests (spr/spr_test.go::TestIdentifyOrphanChangeIDs):
  - NoRebase_skips_detection_silently (regression guard for the
    short-circuit)
  - no_orphans_returns_nil_and_silent
  - orphan_matches_local_commit_returns_change_id_and_notice (asserts
    user notice contains PR number references + subjects + the
    `jj op restore` recovery hint)
  - orphan_with_no_local_match_is_dropped_and_silent
  - local_commit_without_change_id_is_skipped_silently (git-mode
    safety: no jj change ID = no abandon attempt)
Single user-facing announcement of jj support and the cascade-orphan
recovery mechanism, placed at the end of the stack so it lands only
after the full feature is in place. Users following intermediate PRs
see no announcement about jj until everything works.

readme.md:
  - 'Jujutsu (jj) Support' section under Usage Guide. Covers setup
    (jj-setup alias), what works identically vs what differs (edit,
    sync, non-linear stacks), the noJJ opt-out config / --no-jj flag
    / SPR_NOJJ env var, and the auto-revset behavior (work from any
    @ position).
  - 'Merging mid-stack and cascade-orphan recovery' subsection
    explaining the squash-merge mechanism, what spr does about
    orphans automatically, parity with git mode.

docs/jj-mode-design.md:
  - Twelve sections covering the guiding principle (lean on jj,
    don't mimic git), auto-revset for stack discovery, multi-head
    ambiguity, the jj-mode edit/sync semantics, WIP truncation,
    conflict delegation, cascade-orphan recovery (with worked
    example output), VCS interface changes, the stdout/stderr
    separation bug fix, testing strategy, out-of-scope items, and
    a glossary.
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