// @generated — DO NOT EDIT. Source: packages/shared/jj-core.ts import { basename } from "node:path"; import { type DiffResult, type DiffType, type GitCommandResult, type GitContext, type GitDiffOptions, type JjEvoLogEntry, JJ_TRUNK_REVSET, jjLineBaseRevset, validateFilePath, } from "./review-core"; export { JJ_TRUNK_REVSET, jjCompareTargetRevset, jjLineBaseRevset, parseRemoteBookmark, type JjEvoLogEntry, } from "./review-core"; export interface ReviewJjRuntime { runJj: ( args: string[], options?: { cwd?: string; timeoutMs?: number }, ) => Promise; } export async function detectJjWorkspace( runtime: ReviewJjRuntime, cwd?: string, ): Promise { const result = await runtime.runJj(["workspace", "root"], { cwd }); return result.exitCode === 0 ? result.stdout.trim() || null : null; } export async function getJjContext( runtime: ReviewJjRuntime, cwd?: string, ): Promise { const root = await detectJjWorkspace(runtime, cwd); const targets = await listJjCompareTargets(runtime, root ?? cwd); const defaultTarget = await selectDefaultJjCompareTarget(runtime, root ?? cwd); const contextCwd = root ?? cwd; const evologs = await getJjEvoLogEntries(runtime, root ?? cwd); return { currentBranch: "", defaultBranch: defaultTarget, diffOptions: [ { id: "jj-current", label: "Current change" }, { id: "jj-last", label: "Last change" }, { id: "jj-line", label: "Line of work" }, ...(evologs.length >= 2 ? [{ id: "jj-evolog", label: "Evolution diff" }] : []), { id: "jj-all", label: "All files" }, ], worktrees: [], availableBranches: targets, compareTarget: { diffTypes: ["jj-line"], fallback: defaultTarget, picker: { rowLabel: "from revision", triggerLabel: "revision", triggerTitlePrefix: "Compare against", searchPlaceholder: "Search bookmarks...", emptyText: "No bookmarks match.", localGroupLabel: "Bookmarks", remoteGroupLabel: "Remote bookmarks", }, }, repository: contextCwd ? { displayFallback: basename(contextCwd) } : undefined, cwd: contextCwd, vcsType: "jj", jjEvologs: evologs.length >= 2 ? evologs : undefined, }; } export async function runJjDiff( runtime: ReviewJjRuntime, diffType: DiffType, defaultBranch: string, cwd?: string, options?: GitDiffOptions, ): Promise { let compareTarget = defaultBranch.length > 0 ? defaultBranch : JJ_TRUNK_REVSET; // For evolog diffs, when no explicit base is provided, default to the // second evolog entry (the previous state of the current change). if (diffType === "jj-evolog" && defaultBranch.length === 0) { const evologs = await getJjEvoLogEntries(runtime, cwd); if (evologs.length < 2) { return { patch: "", label: "Evolution diff", error: "No previous evolution found" }; } compareTarget = evologs[1].commitId; } const args = getJjDiffArgs(diffType, compareTarget, options); if (!args) return { patch: "", label: "Unknown diff type" }; const result = await runtime.runJj(args.args, { cwd }); if (result.exitCode !== 0) { return { patch: "", label: args.label, error: firstErrorLine(result.stderr) }; } const patch = options?.hideWhitespace ? dropHunklessGitDiffChunks(result.stdout) : result.stdout; return { patch, label: args.label }; } function dropHunklessGitDiffChunks(patch: string): string { if (!patch.includes("diff --git ")) return patch; const chunks = patch.split(/^diff --git /m); const prefix = chunks.shift() ?? ""; const filtered = chunks .filter(hasReviewableGitDiffChunk) .map((chunk) => `diff --git ${chunk}`) .join(""); return `${prefix}${filtered}`; } function hasReviewableGitDiffChunk(chunk: string): boolean { if (/^@@@? /m.test(chunk)) return true; return /^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|copy from|copy to|GIT binary patch|Binary files |similarity index|dissimilarity index)/m.test(chunk); } export async function getJjFileContentsForDiff( runtime: ReviewJjRuntime, diffType: DiffType, defaultBranch: string, filePath: string, oldPath?: string, cwd?: string, ): Promise<{ oldContent: string | null; newContent: string | null }> { validateFilePath(filePath); if (oldPath) validateFilePath(oldPath); const oldFilePath = oldPath === undefined || oldPath.length === 0 ? filePath : oldPath; const root = await detectJjWorkspace(runtime, cwd); const fileCwd = root ?? cwd; switch (diffType) { case "jj-current": return { oldContent: await jjFileContent(runtime, "@-", oldFilePath, fileCwd), newContent: await jjFileContent(runtime, "@", filePath, fileCwd), }; case "jj-last": { const parentRev = await resolveJjParent(runtime, "@-", fileCwd); return { oldContent: parentRev ? await jjFileContent(runtime, parentRev, oldFilePath, fileCwd) : null, newContent: await jjFileContent(runtime, "@-", filePath, fileCwd), }; } case "jj-line": { const compareTarget = defaultBranch.length > 0 ? defaultBranch : JJ_TRUNK_REVSET; return { oldContent: await jjFileContent(runtime, jjLineBaseRevset(compareTarget), oldFilePath, fileCwd), newContent: await jjFileContent(runtime, "@", filePath, fileCwd), }; } case "jj-evolog": { // defaultBranch carries the evolog commit ID of the historical state. const evologRev = defaultBranch.length > 0 ? defaultBranch : "@-"; return { oldContent: await jjFileContent(runtime, evologRev, oldFilePath, fileCwd), newContent: await jjFileContent(runtime, "@", filePath, fileCwd), }; } case "jj-all": return { oldContent: null, newContent: await jjFileContent(runtime, "@", filePath, fileCwd), }; default: return { oldContent: null, newContent: null }; } } export function getJjDiffArgs( diffType: DiffType, compareTarget: string, options?: GitDiffOptions, ): { args: string[]; label: string } | null { const whitespaceArgs = options?.hideWhitespace ? ["-w"] : []; switch (diffType) { case "jj-current": return { args: ["diff", "--git", ...whitespaceArgs, "-r", "@"], label: "Current change" }; case "jj-last": return { args: ["diff", "--git", ...whitespaceArgs, "-r", "@-"], label: "Last change" }; case "jj-line": return { args: ["diff", "--git", ...whitespaceArgs, "--from", jjLineBaseRevset(compareTarget), "--to", "@"], label: `Line of work vs ${compareTarget}`, }; case "jj-evolog": // compareTarget is the short commit ID of an older evolog entry. // Diff from that historical state to the current working copy. return { args: ["diff", "--git", ...whitespaceArgs, "--from", compareTarget, "--to", "@"], label: `Evolution diff from ${compareTarget.slice(0, 8)}`, }; case "jj-all": return { args: ["diff", "--git", ...whitespaceArgs, "--from", "root()", "--to", "@"], label: "All files" }; default: return null; } } export async function selectDefaultJjCompareTarget( runtime: ReviewJjRuntime, cwd?: string, ): Promise { const result = await runtime.runJj([ "log", "--no-graph", "-r", JJ_TRUNK_REVSET, "-T", "json(bookmarks)", ], { cwd }); if (result.exitCode !== 0) return JJ_TRUNK_REVSET; return parseJjResolvedBookmarks(result.stdout)[0] ?? JJ_TRUNK_REVSET; } function parseJjResolvedBookmarks(value: string): string[] { try { const parsed = JSON.parse(value); if (!Array.isArray(parsed)) return []; const local: string[] = []; const remote: string[] = []; for (const bookmark of parsed) { if (typeof bookmark === "string") { local.push(bookmark); continue; } if (!bookmark || typeof bookmark !== "object") continue; const name = typeof bookmark.name === "string" ? bookmark.name : null; if (!name) continue; const remoteName = typeof bookmark.remote === "string" ? bookmark.remote : null; if (remoteName) { remote.push(`${name}@${remoteName}`); continue; } local.push(name); } return [...remote, ...local]; } catch { return []; } } async function listJjCompareTargets( runtime: ReviewJjRuntime, cwd?: string, ): Promise<{ local: string[]; remote: string[] }> { const [localResult, remoteResult] = await Promise.all([ runtime.runJj([ "bookmark", "list", "--sort", "committer-date-", "--sort", "name", "-T", "if(remote, '', if(present, json(name) ++ '\\n', ''))", ], { cwd }), runtime.runJj([ "bookmark", "list", "--all-remotes", "--sort", "committer-date-", "--sort", "name", "-T", "if(remote, if(present, json(name) ++ '\\t' ++ json(remote) ++ '\\n', ''), '')", ], { cwd }), ]); const local = localResult.exitCode === 0 ? parseJjBookmarkList(localResult.stdout) : []; const remote = remoteResult.exitCode === 0 ? parseJjRemoteBookmarkList(remoteResult.stdout) : []; return { local, remote, }; } export function parseJjBookmarkList(stdout: string): string[] { const seen = new Set(); const bookmarks: string[] = []; for (const rawLine of splitJjTemplateRecords(stdout)) { const line = rawLine.trim(); if (!line) continue; const bookmark = parseSerializedJjString(line); if (!bookmark || seen.has(bookmark)) continue; seen.add(bookmark); bookmarks.push(bookmark); } return bookmarks; } export function parseJjRemoteBookmarkList(stdout: string): string[] { const seen = new Set(); const bookmarks: string[] = []; for (const rawLine of splitJjTemplateRecords(stdout)) { const line = rawLine.trim(); if (!line) continue; const fields = splitJjTemplateFields(line); if (!fields) continue; const [nameField, remoteField] = fields; const name = parseSerializedJjString(nameField); const remote = parseSerializedJjString(remoteField); if (!name || !remote) continue; const bookmark = `${name}@${remote}`; if (seen.has(bookmark)) continue; seen.add(bookmark); bookmarks.push(bookmark); } return bookmarks; } function splitJjTemplateRecords(stdout: string): string[] { return stdout.split(/\n|\\n/g); } function splitJjTemplateFields(line: string): [string, string] | null { const literalTab = line.indexOf("\t"); if (literalTab !== -1) return [line.slice(0, literalTab), line.slice(literalTab + 1)]; const escapedTab = line.indexOf("\\t"); if (escapedTab !== -1) return [line.slice(0, escapedTab), line.slice(escapedTab + 2)]; return null; } function parseSerializedJjString(value: string): string | null { try { const parsed = JSON.parse(value); return typeof parsed === "string" ? parsed : null; } catch { return null; } } /** * Returns the evolution log for the current change (`@`), newest-first. * Each entry represents a previous state of the same change ID. * Returns an empty array if evolog is unavailable or the change has no history. */ export async function getJjEvoLogEntries( runtime: ReviewJjRuntime, cwd?: string, ): Promise { // Template uses CommitEvolutionEntry type: each field is accessed via `commit.*`. const result = await runtime.runJj( [ "evolog", "--no-graph", "-r", "@", "-T", 'commit.commit_id().short(12) ++ "\t" ++ commit.description().first_line() ++ "\t" ++ commit.author().timestamp().ago() ++ "\n"', ], { cwd }, ); if (result.exitCode !== 0) return []; const entries: JjEvoLogEntry[] = []; for (const rawLine of result.stdout.split("\n")) { const line = rawLine.trim(); if (!line) continue; const tabIdx = line.indexOf("\t"); if (tabIdx === -1) continue; const commitId = line.slice(0, tabIdx); const rest = line.slice(tabIdx + 1); const tab2 = rest.indexOf("\t"); const description = tab2 === -1 ? rest : rest.slice(0, tab2); const age = tab2 === -1 ? undefined : rest.slice(tab2 + 1); if (commitId) entries.push({ commitId, description, age }); } return entries; } async function jjFileContent( runtime: ReviewJjRuntime, rev: string, filePath: string, cwd?: string, ): Promise { const result = await runtime.runJj(["file", "show", "-r", rev, "--", filePath], { cwd }); return result.exitCode === 0 ? result.stdout : null; } async function resolveJjParent( runtime: ReviewJjRuntime, rev: string, cwd?: string, ): Promise { const result = await runtime.runJj(["log", "-r", rev, "--no-graph", "-T", "parents.map(|p| p.change_id()).join(' ')", "--limit", "1"], { cwd }); const parent = result.stdout.trim().split(/\s+/).find(Boolean); return result.exitCode === 0 && parent ? parent : null; } function firstErrorLine(stderr: string): string | undefined { const line = stderr.split("\n").find((value) => value.trim().length > 0)?.trim(); if (!line) return undefined; return line.length > 200 ? line.slice(0, 200) + "..." : line; }