@plannotator/pi-extension 0.19.10 → 0.19.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,11 +8,17 @@
8
8
 
9
9
  import { resolve as resolvePath } from "node:path";
10
10
 
11
+ export const JJ_TRUNK_REVSET = "trunk()";
12
+
11
13
  export type DiffType =
12
14
  | "uncommitted"
13
15
  | "staged"
14
16
  | "unstaged"
15
17
  | "last-commit"
18
+ | "jj-current"
19
+ | "jj-last"
20
+ | "jj-line"
21
+ | "jj-all"
16
22
  | "branch"
17
23
  | "merge-base"
18
24
  | "all"
@@ -36,14 +42,36 @@ export interface AvailableBranches {
36
42
  remote: string[];
37
43
  }
38
44
 
45
+ export interface CompareTargetPickerCopy {
46
+ rowLabel: string;
47
+ triggerLabel: string;
48
+ triggerTitlePrefix: string;
49
+ searchPlaceholder: string;
50
+ emptyText: string;
51
+ localGroupLabel: string;
52
+ remoteGroupLabel: string;
53
+ }
54
+
55
+ export interface CompareTargetConfig {
56
+ diffTypes: string[];
57
+ fallback: string;
58
+ picker: CompareTargetPickerCopy;
59
+ }
60
+
61
+ export interface RepositoryContext {
62
+ displayFallback?: string;
63
+ }
64
+
39
65
  export interface GitContext {
40
66
  currentBranch: string;
41
67
  defaultBranch: string;
42
68
  diffOptions: DiffOption[];
43
69
  worktrees: WorktreeInfo[];
44
70
  availableBranches: AvailableBranches;
71
+ compareTarget?: CompareTargetConfig;
72
+ repository?: RepositoryContext;
45
73
  cwd?: string;
46
- vcsType?: "git" | "p4";
74
+ vcsType?: "git" | "jj" | "p4";
47
75
  }
48
76
 
49
77
  export interface DiffResult {
@@ -70,6 +98,36 @@ export interface GitDiffOptions {
70
98
  hideWhitespace?: boolean;
71
99
  }
72
100
 
101
+ export function parseRemoteBookmark(target: string): { name: string; remote: string } | null {
102
+ const at = target.lastIndexOf("@");
103
+ if (at <= 0 || at === target.length - 1) return null;
104
+ return { name: target.slice(0, at), remote: target.slice(at + 1) };
105
+ }
106
+
107
+ export function jjCompareTargetRevset(target: string): string {
108
+ const remoteBookmark = parseRemoteBookmark(target);
109
+ if (remoteBookmark) {
110
+ return `remote_bookmarks(exact:${quoteJjString(remoteBookmark.name)}, exact:${quoteJjString(remoteBookmark.remote)})`;
111
+ }
112
+
113
+ const localBookmark = parseJjBookmarkName(target);
114
+ return localBookmark ? `bookmarks(exact:${quoteJjString(localBookmark)})` : target;
115
+ }
116
+
117
+ export function jjLineBaseRevset(target: string): string {
118
+ const compareTarget = jjCompareTargetRevset(target);
119
+ return `heads(::@ & ::(${compareTarget}))`;
120
+ }
121
+
122
+ function parseJjBookmarkName(target: string): string | null {
123
+ if (!target || target.startsWith("@") || /[()\s]/.test(target)) return null;
124
+ return target;
125
+ }
126
+
127
+ function quoteJjString(value: string): string {
128
+ return JSON.stringify(value);
129
+ }
130
+
73
131
  export async function getCurrentBranch(
74
132
  runtime: ReviewGitRuntime,
75
133
  cwd?: string,
@@ -299,7 +357,21 @@ export async function getGitContext(
299
357
  diffOptions,
300
358
  worktrees: worktrees.filter((wt) => wt.path !== currentTreePath),
301
359
  availableBranches,
360
+ compareTarget: {
361
+ diffTypes: ["branch", "merge-base"],
362
+ fallback: "main",
363
+ picker: {
364
+ rowLabel: "compare against",
365
+ triggerLabel: "base",
366
+ triggerTitlePrefix: "Review base",
367
+ searchPlaceholder: "Search branches…",
368
+ emptyText: "No branches match.",
369
+ localGroupLabel: "Local",
370
+ remoteGroupLabel: "Remote",
371
+ },
372
+ },
302
373
  cwd,
374
+ vcsType: "git",
303
375
  };
304
376
  }
305
377
 
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from "node:os";
4
4
  import { mkdir, writeFile, readFile, unlink } from "node:fs/promises";
5
5
  import type { DiffType } from "./review-core.js";
6
6
  import type { PRMetadata } from "./pr-provider.js";
7
+ import { getLocalDiffInstruction } from "./agent-review-message.js";
7
8
  import type {
8
9
  CodeTourOutput,
9
10
  TourDiffAnchor,
@@ -244,7 +245,7 @@ should reference at least one stop.
244
245
 
245
246
  ## Pipeline
246
247
 
247
- 1. Read the full diff (git diff or inlined patch).
248
+ 1. Read the full diff (git diff, jj diff, or inlined patch).
248
249
  2. Read CLAUDE.md and README.md for project context.
249
250
  3. Read commit messages (git log --oneline) and PR title/body if available.
250
251
  4. Identify logical groupings of change (cross-file when appropriate). These
@@ -285,7 +286,7 @@ problems. Most clean changesets should have zero warnings and zero [!WARNING]
285
286
  callouts. The primary question is "what does this change do and why?" not
286
287
  "what's wrong with this code?"`;
287
288
 
288
- function buildTourUserMessage(
289
+ export function buildTourUserMessage(
289
290
  patch: string,
290
291
  diffType: DiffType,
291
292
  options?: { defaultBranch?: string; hasLocalAccess?: boolean; prDiffScope?: string },
@@ -318,38 +319,18 @@ function buildTourUserMessage(
318
319
  return [prMetadata.url, "", "Walk the reviewer through this PR as a guided tour."].join("\n");
319
320
  }
320
321
 
321
- const effectiveDiffType = diffType.startsWith("worktree:")
322
- ? diffType.split(":").pop() || "uncommitted"
323
- : diffType;
324
-
325
- switch (effectiveDiffType) {
326
- case "uncommitted":
327
- return "Walk the reviewer through the current code changes (staged, unstaged, and untracked files) as a guided tour.";
328
- case "staged":
329
- return "Walk the reviewer through the currently staged code changes (`git diff --staged`) as a guided tour.";
330
- case "unstaged":
331
- return "Walk the reviewer through the unstaged code changes (tracked modifications and untracked files) as a guided tour.";
332
- case "last-commit":
333
- return "Walk the reviewer through the code changes introduced in the last commit (`git diff HEAD~1..HEAD`) as a guided tour.";
334
- case "branch": {
335
- const base = options?.defaultBranch || "main";
336
- return `Walk the reviewer through the code changes against the base branch '${base}' as a guided tour. Run \`git diff ${base}..HEAD\` to inspect the changes.`;
337
- }
338
- case "merge-base": {
339
- const base = options?.defaultBranch || "main";
340
- return `Walk the reviewer through the PR-style diff against base '${base}' as a guided tour. First find the common ancestor with \`git merge-base ${base} HEAD\`, then run \`git diff <merge-base>..HEAD\` using that commit to inspect only the changes introduced on this branch (matches GitHub's PR view).`;
341
- }
342
- case "all":
343
- return "Walk the reviewer through every file in the repository as a guided tour. All files are shown as additions (diffed against an empty tree).";
344
- default:
345
- return [
346
- "Walk the reviewer through the following code changes as a guided tour.",
347
- "",
348
- "```diff",
349
- patch,
350
- "```",
351
- ].join("\n");
322
+ const instruction = getLocalDiffInstruction(diffType, options?.defaultBranch);
323
+ if (instruction) {
324
+ return `Walk the reviewer through ${instruction.target} as a guided tour. ${instruction.inspect}`;
352
325
  }
326
+
327
+ return [
328
+ "Walk the reviewer through the following code changes as a guided tour.",
329
+ "",
330
+ "```diff",
331
+ patch,
332
+ "```",
333
+ ].join("\n");
353
334
  }
354
335
 
355
336
  export interface TourClaudeCommandResult {
@@ -365,6 +346,9 @@ export function buildTourClaudeCommand(prompt: string, model: string = "sonnet",
365
346
  "Bash(git grep:*)", "Bash(git ls-remote:*)", "Bash(git ls-tree:*)",
366
347
  "Bash(git merge-base:*)", "Bash(git remote:*)", "Bash(git rev-parse:*)",
367
348
  "Bash(git show-ref:*)",
349
+ "Bash(jj status:*)", "Bash(jj diff:*)", "Bash(jj log:*)",
350
+ "Bash(jj show:*)", "Bash(jj file show:*)", "Bash(jj cat:*)",
351
+ "Bash(jj bookmark list:*)",
368
352
  "Bash(gh pr view:*)", "Bash(gh pr diff:*)", "Bash(gh pr list:*)",
369
353
  "Bash(gh api repos/*/*/pulls/*)", "Bash(gh api repos/*/*/pulls/*/files*)",
370
354
  // The tour prompt follows linked issues (`Fixes #123`, `Closes owner/repo#456`),
@@ -0,0 +1,452 @@
1
+ // @generated — DO NOT EDIT. Source: packages/shared/vcs-core.ts
2
+ import {
3
+ type DiffResult,
4
+ type DiffType,
5
+ type GitContext,
6
+ type GitDiffOptions,
7
+ type ReviewGitRuntime,
8
+ detectRemoteDefaultBranch,
9
+ getFileContentsForDiff as getGitFileContentsForDiff,
10
+ getGitContext,
11
+ gitAddFile,
12
+ gitResetFile,
13
+ parseWorktreeDiffType,
14
+ runGitDiff,
15
+ } from "./review-core";
16
+ import { isAbsolute, relative, resolve } from "node:path";
17
+ import {
18
+ type ReviewJjRuntime,
19
+ detectJjWorkspace,
20
+ getJjContext,
21
+ getJjFileContentsForDiff,
22
+ runJjDiff,
23
+ } from "./jj-core";
24
+
25
+ export type {
26
+ DiffOption,
27
+ DiffResult,
28
+ DiffType,
29
+ GitContext,
30
+ GitDiffOptions,
31
+ WorktreeInfo,
32
+ } from "./review-core";
33
+
34
+ export {
35
+ JJ_TRUNK_REVSET,
36
+ jjCompareTargetRevset,
37
+ jjLineBaseRevset,
38
+ parseRemoteBookmark,
39
+ parseWorktreeDiffType,
40
+ validateFilePath,
41
+ } from "./review-core";
42
+
43
+ export interface VcsProvider {
44
+ readonly id: string;
45
+ detect(cwd?: string): Promise<boolean>;
46
+ getRoot?(cwd?: string): Promise<string | null>;
47
+ ownsDiffType(diffType: string): boolean;
48
+ canStageFiles?(diffType: string): boolean;
49
+ getContext(cwd?: string): Promise<GitContext>;
50
+ runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions): Promise<DiffResult>;
51
+ getFileContents(
52
+ diffType: DiffType,
53
+ defaultBranch: string,
54
+ filePath: string,
55
+ oldPath?: string,
56
+ cwd?: string,
57
+ ): Promise<{ oldContent: string | null; newContent: string | null }>;
58
+ stageFile?(filePath: string, cwd?: string): Promise<void>;
59
+ unstageFile?(filePath: string, cwd?: string): Promise<void>;
60
+ resolveCwd?(diffType: string, fallbackCwd?: string): string | undefined;
61
+ detectRemoteDefaultCompareTarget?(cwd?: string): Promise<string | null>;
62
+ }
63
+
64
+ export type VcsSelection = "auto" | "git" | "jj" | "p4";
65
+
66
+ export interface VcsApi {
67
+ detectVcs(cwd?: string): Promise<VcsProvider>;
68
+ getVcsContext(cwd?: string, vcsType?: VcsSelection): Promise<GitContext>;
69
+ detectRemoteDefaultCompareTarget(cwd?: string, vcsType?: VcsSelection): Promise<string | null>;
70
+ prepareLocalReviewDiff(options: PrepareLocalReviewDiffOptions): Promise<PreparedLocalReviewDiff>;
71
+ runVcsDiff(
72
+ diffType: DiffType,
73
+ defaultBranch?: string,
74
+ cwd?: string,
75
+ options?: GitDiffOptions,
76
+ ): Promise<DiffResult>;
77
+ getVcsFileContentsForDiff(
78
+ diffType: DiffType,
79
+ defaultBranch: string,
80
+ filePath: string,
81
+ oldPath?: string,
82
+ cwd?: string,
83
+ ): Promise<{ oldContent: string | null; newContent: string | null }>;
84
+ canStageFiles(diffType: string, cwd?: string): Promise<boolean>;
85
+ stageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
86
+ unstageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
87
+ resolveVcsCwd(diffType: string, fallbackCwd?: string): string | undefined;
88
+ }
89
+
90
+ export interface PrepareLocalReviewDiffOptions {
91
+ cwd?: string;
92
+ vcsType?: VcsSelection;
93
+ requestedDiffType?: DiffType;
94
+ requestedBase?: string;
95
+ configuredDiffType: DiffType;
96
+ hideWhitespace?: boolean;
97
+ }
98
+
99
+ export interface PreparedLocalReviewDiff {
100
+ gitContext: GitContext;
101
+ diffType: DiffType;
102
+ base: string;
103
+ rawPatch: string;
104
+ gitRef: string;
105
+ error?: string;
106
+ }
107
+
108
+ const GIT_DIFF_TYPES = new Set(["uncommitted", "staged", "unstaged", "last-commit", "branch", "merge-base", "all"]);
109
+ const JJ_DIFF_TYPES = new Set(["jj-current", "jj-last", "jj-line", "jj-all"]);
110
+
111
+ function selectNearestProvider(
112
+ candidates: Array<{ provider: VcsProvider; root: string | null; order: number }>,
113
+ cwd?: string,
114
+ ): VcsProvider | null {
115
+ if (candidates.length === 0) return null;
116
+
117
+ const effectiveCwd = resolve(cwd ?? process.cwd());
118
+ const ranked = candidates
119
+ .map((candidate) => ({
120
+ ...candidate,
121
+ rootDepth: candidate.root ? vcsRootDepth(candidate.root) : -1,
122
+ containsCwd: candidate.root ? isSameOrAncestor(candidate.root, effectiveCwd) : false,
123
+ }))
124
+ .sort((a, b) => {
125
+ if (a.containsCwd !== b.containsCwd) return a.containsCwd ? -1 : 1;
126
+ if (a.rootDepth !== b.rootDepth) return b.rootDepth - a.rootDepth;
127
+ return a.order - b.order;
128
+ });
129
+
130
+ return ranked[0]?.provider ?? null;
131
+ }
132
+
133
+ function isSameOrAncestor(root: string, child: string): boolean {
134
+ const relativePath = relative(resolve(root), child);
135
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
136
+ }
137
+
138
+ function vcsRootDepth(root: string): number {
139
+ return resolve(root).split(/[\\/]+/).filter(Boolean).length;
140
+ }
141
+
142
+ export function createGitProvider(runtime: ReviewGitRuntime): VcsProvider {
143
+ return {
144
+ id: "git",
145
+
146
+ async detect(cwd?: string): Promise<boolean> {
147
+ try {
148
+ const result = await runtime.runGit(["rev-parse", "--is-inside-work-tree"], { cwd });
149
+ return result.exitCode === 0;
150
+ } catch {
151
+ return false;
152
+ }
153
+ },
154
+
155
+ async getRoot(cwd?: string): Promise<string | null> {
156
+ const result = await runtime.runGit(["rev-parse", "--show-toplevel"], { cwd });
157
+ return result.exitCode === 0 ? result.stdout.trim() || null : null;
158
+ },
159
+
160
+ ownsDiffType(diffType: string): boolean {
161
+ return GIT_DIFF_TYPES.has(diffType) || diffType.startsWith("worktree:");
162
+ },
163
+
164
+ canStageFiles(diffType: string): boolean {
165
+ const effectiveDiffType = parseWorktreeDiffType(diffType)?.subType ?? diffType;
166
+ return effectiveDiffType === "uncommitted" || effectiveDiffType === "unstaged";
167
+ },
168
+
169
+ getContext(cwd?: string): Promise<GitContext> {
170
+ return getGitContext(runtime, cwd);
171
+ },
172
+
173
+ runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions): Promise<DiffResult> {
174
+ return runGitDiff(runtime, diffType, defaultBranch, cwd, options);
175
+ },
176
+
177
+ getFileContents(diffType, defaultBranch, filePath, oldPath?, cwd?) {
178
+ return getGitFileContentsForDiff(runtime, diffType, defaultBranch, filePath, oldPath, cwd);
179
+ },
180
+
181
+ stageFile(filePath: string, cwd?: string): Promise<void> {
182
+ return gitAddFile(runtime, filePath, cwd);
183
+ },
184
+
185
+ unstageFile(filePath: string, cwd?: string): Promise<void> {
186
+ return gitResetFile(runtime, filePath, cwd);
187
+ },
188
+
189
+ detectRemoteDefaultCompareTarget(cwd?: string): Promise<string | null> {
190
+ return detectRemoteDefaultBranch(runtime, cwd);
191
+ },
192
+
193
+ resolveCwd(diffType: string, fallbackCwd?: string): string | undefined {
194
+ const parsed = parseWorktreeDiffType(diffType);
195
+ return parsed?.path ?? fallbackCwd;
196
+ },
197
+ };
198
+ }
199
+
200
+ export function createJjProvider(runtime: ReviewJjRuntime): VcsProvider {
201
+ return {
202
+ id: "jj",
203
+
204
+ async detect(cwd?: string): Promise<boolean> {
205
+ return (await detectJjWorkspace(runtime, cwd)) !== null;
206
+ },
207
+
208
+ getRoot(cwd?: string): Promise<string | null> {
209
+ return detectJjWorkspace(runtime, cwd);
210
+ },
211
+
212
+ ownsDiffType(diffType: string): boolean {
213
+ return JJ_DIFF_TYPES.has(diffType);
214
+ },
215
+
216
+ getContext(cwd?: string): Promise<GitContext> {
217
+ return getJjContext(runtime, cwd);
218
+ },
219
+
220
+ runDiff(diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions): Promise<DiffResult> {
221
+ return runJjDiff(runtime, diffType, defaultBranch, cwd, options);
222
+ },
223
+
224
+ getFileContents(diffType, defaultBranch, filePath, oldPath?, cwd?) {
225
+ return getJjFileContentsForDiff(runtime, diffType, defaultBranch, filePath, oldPath, cwd);
226
+ },
227
+ };
228
+ }
229
+
230
+ export function createVcsApi(providers: readonly VcsProvider[]): VcsApi {
231
+ const providerList = [...providers];
232
+ const defaultProvider = providerList.find((provider) => provider.id === "git") ?? providerList[0];
233
+ const vcsCache = new Map<string, VcsProvider>();
234
+
235
+ if (!defaultProvider) {
236
+ throw new Error("createVcsApi requires at least one provider");
237
+ }
238
+
239
+ async function detectVcs(cwd?: string): Promise<VcsProvider> {
240
+ const key = cwd ?? process.cwd();
241
+ const cached = vcsCache.get(key);
242
+ if (cached) return cached;
243
+
244
+ const candidates: Array<{ provider: VcsProvider; root: string | null; order: number }> = [];
245
+ for (const provider of providerList) {
246
+ let root: string | null = null;
247
+ let detected = false;
248
+ try {
249
+ if (provider.getRoot) {
250
+ root = await provider.getRoot(cwd);
251
+ detected = root !== null;
252
+ } else {
253
+ detected = await provider.detect(cwd);
254
+ }
255
+ } catch {
256
+ continue;
257
+ }
258
+ if (detected) {
259
+ candidates.push({ provider, root, order: candidates.length });
260
+ }
261
+ }
262
+
263
+ const detected = selectNearestProvider(candidates, cwd) ?? defaultProvider;
264
+ vcsCache.set(key, detected);
265
+ return detected;
266
+ }
267
+
268
+ function getProviderForDiffType(diffType: string): VcsProvider | null {
269
+ for (const provider of providerList) {
270
+ if (provider.ownsDiffType(diffType)) {
271
+ return provider;
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
277
+ function getProviderById(id: Exclude<VcsSelection, "auto">): VcsProvider | null {
278
+ return providerList.find((provider) => provider.id === id) ?? null;
279
+ }
280
+
281
+ function formatVcsName(id: Exclude<VcsSelection, "auto">): string {
282
+ switch (id) {
283
+ case "git":
284
+ return "Git";
285
+ case "jj":
286
+ return "JJ";
287
+ case "p4":
288
+ return "P4";
289
+ }
290
+ }
291
+
292
+ async function getProviderForSelection(
293
+ vcsType: VcsSelection | undefined,
294
+ cwd?: string,
295
+ ): Promise<VcsProvider> {
296
+ if (!vcsType || vcsType === "auto") {
297
+ return detectVcs(cwd);
298
+ }
299
+
300
+ const provider = getProviderById(vcsType);
301
+ const vcsName = formatVcsName(vcsType);
302
+ if (!provider) {
303
+ throw new Error(`${vcsName} support is not available in this runtime.`);
304
+ }
305
+ if (!(await provider.detect(cwd))) {
306
+ throw new Error(`${vcsName} workspace not found.`);
307
+ }
308
+ return provider;
309
+ }
310
+
311
+ async function getProviderForOperation(diffType: string, cwd?: string): Promise<VcsProvider> {
312
+ return getProviderForDiffType(diffType) ?? detectVcs(cwd);
313
+ }
314
+
315
+ async function getContextWithProvider(
316
+ cwd?: string,
317
+ vcsType?: VcsSelection,
318
+ ): Promise<{ provider: VcsProvider; gitContext: GitContext }> {
319
+ const provider = await getProviderForSelection(vcsType, cwd);
320
+ return { provider, gitContext: await provider.getContext(cwd) };
321
+ }
322
+
323
+ function resolveRequestedDiffType(
324
+ provider: VcsProvider,
325
+ gitContext: GitContext,
326
+ requestedDiffType: DiffType | undefined,
327
+ configuredDiffType: DiffType,
328
+ ): DiffType {
329
+ if (requestedDiffType && provider.ownsDiffType(requestedDiffType)) {
330
+ return requestedDiffType;
331
+ }
332
+ return resolveInitialDiffType(gitContext, configuredDiffType);
333
+ }
334
+
335
+ function resolveInitialBase(
336
+ gitContext: GitContext,
337
+ diffType: DiffType,
338
+ requestedBase: string | undefined,
339
+ ownsRequestedDiffType: boolean,
340
+ ): string {
341
+ if (gitContext.vcsType === "jj") {
342
+ if (diffType === "jj-line" && ownsRequestedDiffType && requestedBase) {
343
+ return requestedBase;
344
+ }
345
+ return gitContext.defaultBranch;
346
+ }
347
+ return requestedBase ?? gitContext.defaultBranch;
348
+ }
349
+
350
+ return {
351
+ detectVcs,
352
+
353
+ async getVcsContext(cwd?: string, vcsType?: VcsSelection): Promise<GitContext> {
354
+ return (await getContextWithProvider(cwd, vcsType)).gitContext;
355
+ },
356
+
357
+ async detectRemoteDefaultCompareTarget(cwd?: string, vcsType?: VcsSelection): Promise<string | null> {
358
+ const provider = await getProviderForSelection(vcsType, cwd);
359
+ return provider.detectRemoteDefaultCompareTarget?.(cwd) ?? null;
360
+ },
361
+
362
+ async prepareLocalReviewDiff(options: PrepareLocalReviewDiffOptions): Promise<PreparedLocalReviewDiff> {
363
+ const { provider, gitContext } = await getContextWithProvider(options.cwd, options.vcsType);
364
+ const ownsRequestedDiffType = options.requestedDiffType !== undefined
365
+ && provider.ownsDiffType(options.requestedDiffType);
366
+ const diffType = resolveRequestedDiffType(
367
+ provider,
368
+ gitContext,
369
+ options.requestedDiffType,
370
+ options.configuredDiffType,
371
+ );
372
+ const base = resolveInitialBase(gitContext, diffType, options.requestedBase, ownsRequestedDiffType);
373
+ const result = await provider.runDiff(diffType, base, gitContext.cwd ?? options.cwd, {
374
+ hideWhitespace: options.hideWhitespace,
375
+ });
376
+
377
+ return {
378
+ gitContext,
379
+ diffType,
380
+ base,
381
+ rawPatch: result.patch,
382
+ gitRef: result.label,
383
+ error: result.error,
384
+ };
385
+ },
386
+
387
+ async runVcsDiff(
388
+ diffType: DiffType,
389
+ defaultBranch: string = "main",
390
+ cwd?: string,
391
+ options?: GitDiffOptions,
392
+ ): Promise<DiffResult> {
393
+ const provider = await getProviderForOperation(diffType, cwd);
394
+ return provider.runDiff(diffType, defaultBranch, cwd, options);
395
+ },
396
+
397
+ async getVcsFileContentsForDiff(
398
+ diffType: DiffType,
399
+ defaultBranch: string,
400
+ filePath: string,
401
+ oldPath?: string,
402
+ cwd?: string,
403
+ ): Promise<{ oldContent: string | null; newContent: string | null }> {
404
+ const provider = await getProviderForOperation(diffType, cwd);
405
+ return provider.getFileContents(diffType, defaultBranch, filePath, oldPath, cwd);
406
+ },
407
+
408
+ async canStageFiles(diffType: string, cwd?: string): Promise<boolean> {
409
+ const provider = await getProviderForOperation(diffType, cwd);
410
+ return provider.stageFile !== undefined && (provider.canStageFiles?.(diffType) ?? false);
411
+ },
412
+
413
+ async stageFile(diffType: string, filePath: string, cwd?: string): Promise<void> {
414
+ const provider = await getProviderForOperation(diffType, cwd);
415
+ if (!provider.stageFile || !(provider.canStageFiles?.(diffType) ?? false)) {
416
+ throw new Error(`Staging not available for ${provider.id}`);
417
+ }
418
+ return provider.stageFile(filePath, cwd);
419
+ },
420
+
421
+ async unstageFile(diffType: string, filePath: string, cwd?: string): Promise<void> {
422
+ const provider = await getProviderForOperation(diffType, cwd);
423
+ if (!provider.unstageFile || !(provider.canStageFiles?.(diffType) ?? false)) {
424
+ throw new Error(`Unstaging not available for ${provider.id}`);
425
+ }
426
+ return provider.unstageFile(filePath, cwd);
427
+ },
428
+
429
+ resolveVcsCwd(diffType: string, fallbackCwd?: string): string | undefined {
430
+ const provider = getProviderForDiffType(diffType);
431
+ return provider?.resolveCwd?.(diffType, fallbackCwd) ?? fallbackCwd;
432
+ },
433
+ };
434
+ }
435
+
436
+ export function resolveInitialDiffType(
437
+ gitContext: GitContext,
438
+ configuredDiffType: DiffType,
439
+ ): DiffType {
440
+ if (gitContext.vcsType === "p4") {
441
+ return "p4-default";
442
+ }
443
+ if (gitContext.vcsType === "jj") {
444
+ return "jj-current";
445
+ }
446
+ if (gitContext.diffOptions.some((option) => option.id === configuredDiffType)) {
447
+ return configuredDiffType;
448
+ }
449
+
450
+ const fallback = gitContext.diffOptions[0]?.id;
451
+ return fallback ? fallback as DiffType : configuredDiffType;
452
+ }
package/index.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  getAnnotateMessageFeedbackPrompt,
50
50
  } from "./generated/prompts.js";
51
51
  import { parseAnnotateArgs } from "./generated/annotate-args.js";
52
+ import { parseReviewArgs } from "./generated/review-args.js";
52
53
  import { resolveAtReference } from "./generated/at-reference.js";
53
54
  import {
54
55
  hasPlanBrowserHtml,
@@ -404,7 +405,7 @@ export default function plannotator(pi: ExtensionAPI): void {
404
405
  });
405
406
 
406
407
  pi.registerCommand("plannotator-review", {
407
- description: "Open interactive code review for current changes or a PR URL",
408
+ description: "Open interactive code review for current changes or a PR URL; pass --git to force Git in JJ workspaces",
408
409
  handler: async (args, ctx) => {
409
410
  if (!hasReviewBrowserHtml()) {
410
411
  ctx.ui.notify(
@@ -418,9 +419,13 @@ export default function plannotator(pi: ExtensionAPI): void {
418
419
  const origin = getPiSessionIdentity(ctx);
419
420
 
420
421
  try {
421
- const prUrl = args?.trim() || undefined;
422
- const isPRReview = prUrl?.startsWith("http://") || prUrl?.startsWith("https://");
423
- const session = await startCodeReviewBrowserSession(ctx, { prUrl });
422
+ const reviewArgs = parseReviewArgs(args ?? "");
423
+ const isPRReview = reviewArgs.prUrl !== undefined;
424
+ const session = await startCodeReviewBrowserSession(ctx, {
425
+ prUrl: reviewArgs.prUrl,
426
+ vcsType: reviewArgs.vcsType,
427
+ useLocal: reviewArgs.useLocal,
428
+ });
424
429
  ctx.ui.notify("Code review opened. You can keep chatting while it runs.", "info");
425
430
  void session
426
431
  .waitForDecision()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.19.10",
3
+ "version": "0.19.11",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",