Skip to content

Commit 8705e97

Browse files
committed
feat(review): add jj support for local diffs
1 parent 5ecf5be commit 8705e97

6 files changed

Lines changed: 453 additions & 24 deletions

File tree

apps/hook/server/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import {
6363
startAnnotateServer,
6464
handleAnnotateServerReady,
6565
} from "@plannotator/server/annotate";
66-
import { type DiffType, getVcsContext, runVcsDiff, gitRuntime } from "@plannotator/server/vcs";
66+
import { type DiffType, getVcsContext, runVcsDiff, resolveInitialDiffType, gitRuntime } from "@plannotator/server/vcs";
6767
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
6868
import { stripAtPrefix, resolveAtReference } from "@plannotator/shared/at-reference";
6969
import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown";
@@ -501,8 +501,8 @@ if (args[0] === "sessions") {
501501
// --- Local Review Mode ---
502502
gitContext = await getVcsContext();
503503
const config = loadConfig();
504-
initialDiffType = gitContext.vcsType === "p4" ? "p4-default" : resolveDefaultDiffType(config);
505-
const diffResult = await runVcsDiff(initialDiffType, gitContext.defaultBranch, undefined, {
504+
initialDiffType = resolveInitialDiffType(gitContext, resolveDefaultDiffType(config));
505+
const diffResult = await runVcsDiff(initialDiffType, gitContext.defaultBranch, gitContext.cwd, {
506506
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
507507
});
508508
rawPatch = diffResult.patch;

apps/opencode-plugin/commands.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
startAnnotateServer,
1919
handleAnnotateServerReady,
2020
} from "@plannotator/server/annotate";
21-
import { getGitContext, runGitDiffWithContext } from "@plannotator/server/git";
21+
import { getVcsContext, runVcsDiff, resolveInitialDiffType } from "@plannotator/server/vcs";
2222
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
2323
import { loadConfig, resolveDefaultDiffType, resolveUseJina } from "@plannotator/shared/config";
2424
import {
@@ -59,7 +59,7 @@ export async function handleReviewCommand(
5959
let gitRef: string;
6060
let diffError: string | undefined;
6161
let userDiffType: import("@plannotator/shared/config").DefaultDiffType | undefined;
62-
let gitContext: Awaited<ReturnType<typeof getGitContext>> | undefined;
62+
let gitContext: Awaited<ReturnType<typeof getVcsContext>> | undefined;
6363
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
6464

6565
if (isPRMode) {
@@ -91,10 +91,10 @@ export async function handleReviewCommand(
9191
} else {
9292
client.app.log({ level: "info", message: "Opening code review UI..." });
9393

94-
gitContext = await getGitContext(directory);
9594
const config = loadConfig();
96-
userDiffType = resolveDefaultDiffType(config);
97-
const diffResult = await runGitDiffWithContext(userDiffType, gitContext, {
95+
gitContext = await getVcsContext(directory);
96+
userDiffType = resolveInitialDiffType(gitContext, resolveDefaultDiffType(config));
97+
const diffResult = await runVcsDiff(userDiffType, gitContext.defaultBranch, gitContext.cwd, {
9898
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
9999
});
100100
rawPatch = diffResult.patch;

packages/server/jj.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
import {
2+
type DiffResult,
3+
type DiffType,
4+
type GitCommandResult,
5+
type GitContext,
6+
validateFilePath,
7+
} from "@plannotator/shared/review-core";
8+
import { basename } from "node:path";
9+
10+
async function runJj(
11+
args: string[],
12+
options?: { cwd?: string },
13+
): Promise<GitCommandResult> {
14+
try {
15+
const proc = Bun.spawn(["jj", ...args], {
16+
cwd: options?.cwd,
17+
stdout: "pipe",
18+
stderr: "pipe",
19+
});
20+
21+
const [stdout, stderr, exitCode] = await Promise.all([
22+
new Response(proc.stdout).text(),
23+
new Response(proc.stderr).text(),
24+
proc.exited,
25+
]);
26+
27+
return { stdout, stderr, exitCode };
28+
} catch {
29+
return { stdout: "", stderr: "jj not found", exitCode: 1 };
30+
}
31+
}
32+
33+
export async function detectJjWorkspace(cwd?: string): Promise<string | null> {
34+
const result = await runJj(["workspace", "root"], { cwd });
35+
return result.exitCode === 0 ? result.stdout.trim() || null : null;
36+
}
37+
38+
export async function getJjContext(cwd?: string): Promise<GitContext> {
39+
const root = await detectJjWorkspace(cwd);
40+
const targets = await listJjCompareTargets(root ?? cwd);
41+
const defaultTarget = selectDefaultJjCompareTarget(targets);
42+
const contextCwd = root ?? cwd;
43+
44+
return {
45+
currentBranch: "",
46+
defaultBranch: defaultTarget,
47+
diffOptions: [
48+
{ id: "jj-current", label: "Current change" },
49+
{ id: "jj-last", label: "Last change" },
50+
{ id: "jj-line", label: "Line of work" },
51+
{ id: "jj-all", label: "All files" },
52+
],
53+
worktrees: [],
54+
availableBranches: targets,
55+
compareTarget: {
56+
diffTypes: ["jj-line"],
57+
fallback: "@-",
58+
picker: {
59+
rowLabel: "from revision",
60+
triggerLabel: "revision",
61+
triggerTitlePrefix: "Compare against",
62+
searchPlaceholder: "Search bookmarks…",
63+
emptyText: "No bookmarks match.",
64+
localGroupLabel: "Bookmarks",
65+
remoteGroupLabel: "Remote bookmarks",
66+
},
67+
},
68+
repository: contextCwd ? { displayFallback: basename(contextCwd) } : undefined,
69+
cwd: contextCwd,
70+
vcsType: "jj",
71+
};
72+
}
73+
74+
export async function runJjDiff(
75+
diffType: DiffType,
76+
defaultBranch: string,
77+
cwd?: string,
78+
): Promise<DiffResult> {
79+
const compareTarget = defaultBranch.length > 0 ? defaultBranch : "@-";
80+
const args = getDiffArgs(diffType, compareTarget);
81+
if (!args) return { patch: "", label: "Unknown diff type" };
82+
83+
const result = await runJj(args.args, { cwd });
84+
if (result.exitCode !== 0) {
85+
return { patch: "", label: args.label, error: firstErrorLine(result.stderr) };
86+
}
87+
88+
return { patch: result.stdout, label: args.label };
89+
}
90+
91+
export async function getJjFileContentsForDiff(
92+
diffType: DiffType,
93+
defaultBranch: string,
94+
filePath: string,
95+
oldPath?: string,
96+
cwd?: string,
97+
): Promise<{ oldContent: string | null; newContent: string | null }> {
98+
validateFilePath(filePath);
99+
if (oldPath) validateFilePath(oldPath);
100+
101+
const oldFilePath = oldPath === undefined || oldPath.length === 0 ? filePath : oldPath;
102+
const root = await detectJjWorkspace(cwd);
103+
const fileCwd = root ?? cwd;
104+
105+
switch (diffType) {
106+
case "jj-current":
107+
return {
108+
oldContent: await jjFileContent("@-", oldFilePath, fileCwd),
109+
newContent: await jjFileContent("@", filePath, fileCwd),
110+
};
111+
case "jj-last": {
112+
const parentRev = await resolveJjParent("@-", fileCwd);
113+
return {
114+
oldContent: parentRev ? await jjFileContent(parentRev, oldFilePath, fileCwd) : null,
115+
newContent: await jjFileContent("@-", filePath, fileCwd),
116+
};
117+
}
118+
case "jj-line":
119+
const compareTarget = defaultBranch.length > 0 ? defaultBranch : "@-";
120+
return {
121+
oldContent: await jjFileContent(jjLineBaseRevset(compareTarget), oldFilePath, fileCwd),
122+
newContent: await jjFileContent("@", filePath, fileCwd),
123+
};
124+
case "jj-all":
125+
return {
126+
oldContent: null,
127+
newContent: await jjFileContent("@", filePath, fileCwd),
128+
};
129+
default:
130+
return { oldContent: null, newContent: null };
131+
}
132+
}
133+
134+
function getDiffArgs(
135+
diffType: DiffType,
136+
compareTarget: string,
137+
): { args: string[]; label: string } | null {
138+
switch (diffType) {
139+
case "jj-current":
140+
return { args: ["diff", "--git", "-r", "@"], label: "Current change" };
141+
case "jj-last":
142+
return { args: ["diff", "--git", "-r", "@-"], label: "Last change" };
143+
case "jj-line":
144+
return {
145+
args: ["diff", "--git", "--from", jjLineBaseRevset(compareTarget), "--to", "@"],
146+
label: `Line of work vs ${compareTarget}`,
147+
};
148+
case "jj-all":
149+
return { args: ["diff", "--git", "--from", "root()", "--to", "@"], label: "All files" };
150+
default:
151+
return null;
152+
}
153+
}
154+
155+
export function selectDefaultJjCompareTarget(targets: { local: string[]; remote: string[] }): string {
156+
const preferredRemote = preferredJjBranchNames()
157+
.flatMap((name) => [
158+
`${name}@origin`,
159+
`${name}@upstream`,
160+
...targets.remote.filter((target) => target.startsWith(`${name}@`)).sort(),
161+
])
162+
.find((target) => targets.remote.includes(target));
163+
if (preferredRemote) return preferredRemote;
164+
165+
const preferredLocal = preferredJjBranchNames().find((name) => targets.local.includes(name));
166+
if (preferredLocal) return preferredLocal;
167+
168+
return "@-";
169+
}
170+
171+
export function jjCompareTargetRevset(target: string): string {
172+
const remoteBookmark = parseRemoteBookmark(target);
173+
if (remoteBookmark) {
174+
return `remote_bookmarks(exact:${quoteJjString(remoteBookmark.name)}, exact:${quoteJjString(remoteBookmark.remote)})`;
175+
}
176+
const localBookmark = parseBookmarkName(target);
177+
return localBookmark ? `bookmarks(exact:${quoteJjString(localBookmark)})` : target;
178+
}
179+
180+
export function jjLineBaseRevset(target: string): string {
181+
const compareTarget = jjCompareTargetRevset(target);
182+
return `heads(::@ & ::(${compareTarget}))`;
183+
}
184+
185+
export function parseRemoteBookmark(target: string): { name: string; remote: string } | null {
186+
const at = target.lastIndexOf("@");
187+
if (at <= 0 || at === target.length - 1) return null;
188+
return { name: target.slice(0, at), remote: target.slice(at + 1) };
189+
}
190+
191+
function parseBookmarkName(target: string): string | null {
192+
if (!target || target.startsWith("@") || /[()\s]/.test(target)) return null;
193+
return target;
194+
}
195+
196+
function quoteJjString(value: string): string {
197+
return JSON.stringify(value);
198+
}
199+
200+
async function listJjCompareTargets(cwd?: string): Promise<{ local: string[]; remote: string[] }> {
201+
const [localResult, remoteResult] = await Promise.all([
202+
runJj([
203+
"bookmark",
204+
"list",
205+
"--sort",
206+
"committer-date-",
207+
"--sort",
208+
"name",
209+
"-T",
210+
"if(remote, '', if(present, json(name) ++ '\n', ''))",
211+
], { cwd }),
212+
runJj([
213+
"bookmark",
214+
"list",
215+
"--all-remotes",
216+
"--sort",
217+
"committer-date-",
218+
"--sort",
219+
"name",
220+
"-T",
221+
"if(remote, if(present, json(name) ++ '\t' ++ json(remote) ++ '\n', ''), '')",
222+
], { cwd }),
223+
]);
224+
225+
const local = localResult.exitCode === 0 ? parseJjBookmarkList(localResult.stdout) : [];
226+
const remote = remoteResult.exitCode === 0 ? parseJjRemoteBookmarkList(remoteResult.stdout) : [];
227+
228+
return {
229+
local,
230+
remote,
231+
};
232+
}
233+
234+
export function parseJjBookmarkList(stdout: string): string[] {
235+
const seen = new Set<string>();
236+
const bookmarks: string[] = [];
237+
238+
for (const rawLine of stdout.split("\n")) {
239+
const line = rawLine.replace(/\\n$/, "").trim();
240+
if (!line) continue;
241+
242+
const bookmark = parseSerializedJjString(line);
243+
if (!bookmark || seen.has(bookmark)) continue;
244+
245+
seen.add(bookmark);
246+
bookmarks.push(bookmark);
247+
}
248+
249+
return bookmarks;
250+
}
251+
252+
export function parseJjRemoteBookmarkList(stdout: string): string[] {
253+
const seen = new Set<string>();
254+
const bookmarks: string[] = [];
255+
256+
for (const rawLine of stdout.split("\n")) {
257+
const line = rawLine.replace(/\\n$/, "").trim();
258+
if (!line) continue;
259+
260+
const separator = line.indexOf("\t");
261+
if (separator === -1) continue;
262+
263+
const name = parseSerializedJjString(line.slice(0, separator));
264+
const remote = parseSerializedJjString(line.slice(separator + 1));
265+
if (!name || !remote || remote === "git") continue;
266+
267+
const bookmark = `${name}@${remote}`;
268+
if (seen.has(bookmark)) continue;
269+
270+
seen.add(bookmark);
271+
bookmarks.push(bookmark);
272+
}
273+
274+
return bookmarks;
275+
}
276+
277+
function parseSerializedJjString(value: string): string | null {
278+
try {
279+
const parsed = JSON.parse(value);
280+
return typeof parsed === "string" ? parsed : null;
281+
} catch {
282+
return null;
283+
}
284+
}
285+
286+
function preferredJjBranchNames(): string[] {
287+
return ["develop", "main", "master", "trunk"];
288+
}
289+
290+
async function jjFileContent(rev: string, filePath: string, cwd?: string): Promise<string | null> {
291+
const result = await runJj(["file", "show", "-r", rev, "--", filePath], { cwd });
292+
return result.exitCode === 0 ? result.stdout : null;
293+
}
294+
295+
async function resolveJjParent(rev: string, cwd?: string): Promise<string | null> {
296+
const result = await runJj(["log", "-r", rev, "--no-graph", "-T", "parents.map(|p| p.change_id()).join(' ')", "--limit", "1"], { cwd });
297+
const parent = result.stdout.trim().split(/\s+/).find(Boolean);
298+
return result.exitCode === 0 && parent ? parent : null;
299+
}
300+
301+
function firstErrorLine(stderr: string): string | undefined {
302+
const line = stderr.split("\n").find((value) => value.trim().length > 0)?.trim();
303+
if (!line) return undefined;
304+
return line.length > 200 ? line.slice(0, 200) + "..." : line;
305+
}

0 commit comments

Comments
 (0)