Skip to content

feat: add jj support to call flow mode - #1312

Merged
backnotprop merged 5 commits into
backnotprop:mainfrom
graemefolk:feat/jj-call-flow
Aug 15, 2026
Merged

backnotprop merged 5 commits into
backnotprop:mainfrom
graemefolk:feat/jj-call-flow

Conversation

@graemefolk

@graemefolk graemefolk commented Aug 13, 2026

Copy link
Copy Markdown
Contributor
  • Adds Call Flow support for Jujutsu current, last, line-of-work, and evolution views.
  • Extracts the Call Flow snapshotting logic behind the VCS abstraction.

@graemefolk
graemefolk marked this pull request as draft August 13, 2026 20:31
@graemefolk
graemefolk marked this pull request as ready for review August 13, 2026 20:47
…t revsets

`jj diff --from/--to` take exactly one revision, and the parent shorthands do
not guarantee one: `@-` is `parents(@)`, so on a merge revision both `@-`
(jj-current's base) and `parents(@-)` (jj-last's base) resolve to several
revisions and jj rejects the command outright with "resolved to more than one
revision". The visible `jj diff -r @` review still renders in that state, so
Call flow failed on exactly the revisions a reviewer is most likely to be
looking at.

Parent hops are no longer encoded as revsets. `getJjSnapshotRevsets` now
returns a revset plus a first-parent hop count, and `resolveJjSnapshotEndpoint`
walks those hops against the repository, taking the first parent's commit id at
each step. jj lists a merge's parents in the order they were given to `jj new`,
so index 0 is stable across invocations, unlike `heads()` or `latest()`. This
is also the side `getJjFileContentsForDiff` already expands merges on, so Call
flow and file expansion now agree.

packages/shared/jj-snapshot.test.ts builds a real jj workspace whose working
copy IS a merge and drives both revset paths through the provider. All three
tests fail on the previous revsets with the exact jj ambiguity error.
…ization

Three properties of the Jujutsu snapshot routine, all invisible in the visible
review but all reaching CallDiff:

Fileset anchoring. `glob-i:` patterns are relative to the INVOCATION directory,
so a review started from a subdirectory of the workspace silently dropped every
source file above it and handed CallDiff a partial repository call graph — the
opposite of the whole-repo call graph this spec exists to preserve. The
`root-glob-i:` prefix anchors at the workspace root instead; patch paths were
already workspace-relative either way.

Output ceiling. The 64 MB limit was checked AFTER `jj diff` had been buffered in
full, so it validated a number without bounding the memory that produced it. The
limit is now passed to the runtime, which stops reading and kills the command at
the ceiling and reports `truncated`. Both runtimes implement it: Bun reads the
stream with a budget, Pi counts chunks in its node:child_process reader.

Materialization cost. Both sides used to materialize the whole parseable tree
(`--from root()`), so cost scaled with the repository twice over. Only the base
side does now; the second side is that tree plus the changed-file delta between
the two revisions, which is what a review actually needs. Scoping the analysis
itself to changed files is deliberately NOT done — the spec's whole-repository
same-language call graph depends on both snapshots being complete — so this
scopes the materialization without narrowing what CallDiff sees. A whole-tree
second pass remains as the fallback when a delta cannot be replayed.
@backnotprop

Copy link
Copy Markdown
Owner

TLDR: great refactor, merging after two fixes I pushed straight to this branch (4427bdf1, 7355de0f). One blocker (merge revisions broke both jj-current and jj-last) plus fileset anchoring, an unbounded read, and materialization cost. One of the five review findings did not hold up and I did not "fix" it. Please review the two commits and push back on anything you disagree with.

AI-assisted review: findings were re-verified against real jj repositories before any change was made, and the commits were written and tested with agent assistance.

What's good here

Pulling snapshot materialization behind the VCS abstraction is the right call and it is a nicer shape than what was there before. CallFlowAnalysisInput losing cwd / diffType / base / vcsType / prCommitPair in favour of an opaque snapshot.materialize() is exactly the seam that was missing: Call flow no longer knows what a review mode is, and the getAdvert signature collapsing to snapshotSupported deletes the string-prefix sniffing (jj-, gitbutler:, p4-) that would have needed editing for every future provider. That is a net simplification on top of adding a feature, which is rare.

The Jujutsu materialization itself is careful in the ways that are easy to get wrong. --ignore-working-copy everywhere so the reviewed checkout is never snapshotted. Building the synthetic commits in a throwaway repo instead of anywhere near the user's workspace. Filtering binary chunks with a comment explaining why, and keeping symlink, executable-mode and deletion semantics intact through the Git patch format. Renaming getGitCallFlowMaterializationPatch to getGitSnapshotMaterializationPatch because it is no longer call-flow-specific. The ADR updated in the same change. And the fake-runtime test asserting 120000 blob / 100755 blob survive the round trip is a genuinely good test, which is why I kept it and extended it rather than replacing it.

Both runtimes were wired symmetrically too (createJjProvider(jjRuntime, gitRuntime) in Bun and Pi), so the parity work was already done.

Findings

1. Blocker, confirmed: merge revisions broke both jj revset paths. jj diff --from/--to take exactly one revision. @- is parents(@), so on a merge revision @- (jj-current's base) and parents(@-) (jj-last's base) both resolve to several revisions and jj refuses:

Error: Revset `@-` resolved to more than one revision

The asymmetry is what makes it bad: jj diff -r @ renders the merge fine, so the review looks healthy and only Call flow fails. Merge working copies are an ordinary jj state, not an edge case.

Fixed in 4427bdf1. getJjSnapshotRevsets now returns a revset plus a first-parent hop count, and resolveJjSnapshotEndpoint walks those hops against the repository, taking the first parent's commit id per step. jj lists a merge's parents in the order given to jj new, so index 0 is stable, unlike heads() or latest(). I picked first-parent over erroring because getJjFileContentsForDiff already expands jj-last merges on the first parent, so this makes Call flow and file expansion agree rather than introducing a new convention. Shout if you would rather it refused outright on a merge.

2. Confirmed: glob-i: filesets are cwd-relative. Verified against a real repo. From a subdirectory, glob-i:"**/*.ts" silently returns only the files at or below that directory:

== from repo root, glob-i ==      sub/deep/d.ts  sub/s.ts  top.ts
== from sub/, glob-i ==           sub/deep/d.ts  sub/s.ts
== from sub/, root-glob-i ==      sub/deep/d.ts  sub/s.ts  top.ts

options.cwd is the review's cwd, which is wherever the user ran plannotator review, so this silently narrowed CallDiff to a partial call graph for anyone reviewing from a subdirectory. That is the opposite of the whole-repo guarantee the spec is built on, and it fails quietly. Fixed in 7355de0f; patch paths were already workspace-relative, so this is a strict widening.

3. Confirmed: the 64 MB cap was post-hoc. Neither runJj caps its read, so Buffer.byteLength(result.stdout) > MAX validated a number after the memory had already been spent. Fixed in 7355de0f: maxOutputBytes is passed through to the runtime, which stops reading and kills the command at the ceiling and reports truncated. Implemented in both runtimes (Bun reads the stream with a budget, Pi counts chunks in its node:child_process reader), with a real-jj test per runtime. The post-check stays as a backstop for runtimes that ignore the option.

4. Partially refuted, and I did not do what the finding asked. Scoping the analysis to changed files only would have broken the spec on purpose: "This preserves complete same-language repository call graphs rather than reducing analysis to changed files." snapshotPathsForLanguages needs both snapshots complete, so narrowing them would have made jj Call flow quietly weaker than git rather than faster.

The real cost problem was that BOTH sides materialized the whole tree, so a review paid for the repository twice. 7355de0f keeps the base side whole and builds the second side as that tree plus the changed-file delta between the two revisions, with a whole-tree second pass as fallback if a delta cannot be replayed. Roughly halves the work and the peak patch size without narrowing anything CallDiff sees.

Worth noting as a possible follow-up, not something I touched: jj's default backend is git, so on a colocated repo the revisions already exist as git objects and materialization could be skipped entirely. Bigger change, and it does not hold for the native backend.

5. Confirmed: no coverage for either case. packages/shared/jj-snapshot.test.ts (new) builds a real jj workspace whose working copy IS a merge and drives jj-current and jj-last through the provider, plus a third test materializing from a subdirectory. All three fail on the original revsets with the exact jj ambiguity error, and the subdirectory one fails on glob-i:. Skipped when jj is absent, matching the existing hasJj() gate in apps/pi-extension/server.test.ts, since CI runners do not ship jj. Your fake-runtime test now models a merge parent and asserts the delta path; I also added a truncation test and the two runtime ceiling tests.

Verification

bunx tsc --noEmit clean across all seven projects. Full bun test at 3456 pass, with the same 12 failures before and after my commits (GitButler and semantic-diff suites that need but and sem on PATH locally). Pi needed real mirroring only for the runtime reader; everything else lives in packages/shared and reaches Pi through vendor.sh, which already covers jj-core, vcs-core, review-core and call-flow.

Have a look at the two commits and tell me if anything reads wrong to you, particularly the first-parent choice in finding 1 and the delta materialization in finding 4. Thanks for another careful piece of jj work.

@backnotprop
backnotprop merged commit e309133 into backnotprop:main Aug 15, 2026
23 checks passed
@backnotprop

Copy link
Copy Markdown
Owner

Merged, thanks @graemefolk. jj Call Flow ships in the next release.

dylanvanh pushed a commit to dylanvanh/plannotator that referenced this pull request Aug 25, 2026
Adds Jujutsu (jj) as a Call Flow analysis provider: jj-current/jj-last/jj-line/jj-all snapshot revsets with deterministic first-parent resolution across merge revisions, root-anchored filesets so results are cwd-independent, bounded snapshot materialization (base tree + changed-file delta) with a streamed 64MB output ceiling in both the Bun and Pi runtimes, and real-jj regression tests covering merges and subdirectory invocation.

Contributed by @graemefolk, who also built the original jj integration. Review fixes pushed in-branch: merge-parent resolution, root-glob filesets, bounded materialization and buffering, plus CI gating guards for runners without jj.
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.

2 participants