mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e206a1f5e8
* fix(review): detect JJ mutable line-of-work base Use JJ's mutable-stack revset to find the line boundary directly instead of inferring a parent from bookmark ordering, which is ambiguous because JJ has no current bookmark. * fix(review): harden the JJ line-of-work base inference Maintainer follow-up on the line-of-work base detection. Skip the bookmarks `jj git push --change` generates. They name one change, not a line of work, and they do reach the fork point: a colleague's pushed change bookmark arrives as an untracked remote bookmark, which makes its commit immutable and therefore a candidate base, so the reviewer was told they were comparing against `push-vmopwunwxopv@origin`. The commit id is used instead. Pass a full commit id through `jjCompareTargetRevset` as a revision. It has no separators, so the commit-id fallback was being wrapped as `bookmarks(exact:"<sha>")`, which resolves to no revisions and made the whole Line of work diff fail. Fall back to `trunk()` instead of throwing. The only live caller is `getJjContext` on the review startup path, which has no handler above it, so a throw aborted `plannotator review` with a stack trace before the server was built rather than reporting anything. That also covers a `jj` too old for `fork_point`/`reachable`. Make the query explicitly single-record with `latest(..., 1)`. The parser reads one record, and bookmark preference (remote before local) is only meaningful within one commit, so the tie-break belongs in the revset rather than in a silent "first row wins" slice. Isolate the real-jj test behind its own JJ_CONFIG. It was reading the developer's real config, where `[signing] behavior = "own"` alone makes it fail with a GPG error. Live fixtures cover the generated-push-bookmark stack, the untracked remote push bookmark, and a stacked local bookmark. --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
632 lines
21 KiB
TypeScript
632 lines
21 KiB
TypeScript
import { basename } from "node:path";
|
|
import {
|
|
type DiffResult,
|
|
type DiffType,
|
|
type GitCommandResult,
|
|
type GitContext,
|
|
type GitDiffOptions,
|
|
type JjEvoLogEntry,
|
|
JJ_TRUNK_REVSET,
|
|
jjLineBaseRevset,
|
|
parseRemoteBookmark,
|
|
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; maxOutputBytes?: number },
|
|
) => Promise<GitCommandResult>;
|
|
}
|
|
|
|
// `reachable(@, mutable())` is JJ's definition of the stack being worked on.
|
|
// Its root parents are where that line diverged from immutable history.
|
|
//
|
|
// `latest(..., 1)` is what keeps the query single-record. A criss-cross history
|
|
// can leave several fork points, and the parser below reads one record only, so
|
|
// the tie-break belongs in the revset where it is deliberate and testable
|
|
// rather than in a silent "first row wins" slice. It also matters for
|
|
// correctness: bookmark preference (remote before local) is only meaningful
|
|
// within one commit, so a multi-row answer could otherwise pick a remote
|
|
// bookmark from one commit over a local bookmark on a nearer one.
|
|
const JJ_LINE_BASE_REVSET = "latest(fork_point(roots(reachable(@, mutable()))-), 1)";
|
|
|
|
// `jj git push --change` mints bookmarks under `git.push-bookmark-prefix`
|
|
// (default `push-`). They name one change, not a line of work, so they are
|
|
// never a useful review base. They reach the fork point in practice: a
|
|
// colleague's pushed change bookmark arrives as an untracked remote bookmark,
|
|
// which makes its commit immutable and therefore a candidate base, and the
|
|
// reviewer would be told they are comparing against `push-vmopwunwxopv@origin`.
|
|
const JJ_GENERATED_PUSH_BOOKMARK_PREFIX = "push-";
|
|
|
|
// `commit_id` renders in full, so an all-zero id is the virtual root commit.
|
|
// A repo with no immutable history forks there, and a 40-zero hash means
|
|
// nothing to a reviewer, so keep the `trunk()` sentinel for that case.
|
|
const JJ_ROOT_COMMIT_ID = /^0+$/;
|
|
|
|
export async function detectJjWorkspace(
|
|
runtime: ReviewJjRuntime,
|
|
cwd?: string,
|
|
): Promise<string | null> {
|
|
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<GitContext> {
|
|
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 function isJjSnapshotDiffType(diffType: string): boolean {
|
|
return diffType === "jj-current"
|
|
|| diffType === "jj-last"
|
|
|| diffType === "jj-line"
|
|
|| diffType === "jj-evolog";
|
|
}
|
|
|
|
/**
|
|
* One end of an analysis snapshot, expressed so a merge revision cannot make it
|
|
* ambiguous.
|
|
*
|
|
* `jj diff --from/--to` take exactly ONE revision. The parent shorthands do not
|
|
* guarantee that: `@-` is `parents(@)`, so on a merge revision both `@-` and
|
|
* `parents(@-)` resolve to several revisions and jj rejects the command
|
|
* outright ("resolved to more than one revision") — even though the visible
|
|
* `jj diff -r @` review still renders. Parent hops are therefore never encoded
|
|
* as revsets; they are walked first-parent-first against the repository, which
|
|
* is the same side `getJjFileContentsForDiff` already expands merges on.
|
|
*/
|
|
export interface JjSnapshotEndpoint {
|
|
/** Revset that must itself resolve to a single revision (`@`, a commit id, …). */
|
|
revset: string;
|
|
/** First-parent hops to walk from `revset`. Zero uses `revset` verbatim. */
|
|
firstParentSteps: number;
|
|
}
|
|
|
|
export function getJjSnapshotRevsets(
|
|
diffType: DiffType,
|
|
compareTarget: string,
|
|
): { from: JjSnapshotEndpoint; to: JjSnapshotEndpoint } | null {
|
|
switch (diffType) {
|
|
case "jj-current":
|
|
return { from: { revset: "@", firstParentSteps: 1 }, to: { revset: "@", firstParentSteps: 0 } };
|
|
case "jj-last":
|
|
return { from: { revset: "@", firstParentSteps: 2 }, to: { revset: "@", firstParentSteps: 1 } };
|
|
case "jj-line":
|
|
return {
|
|
from: {
|
|
revset: jjLineBaseRevset(compareTarget.length > 0 ? compareTarget : JJ_TRUNK_REVSET),
|
|
firstParentSteps: 0,
|
|
},
|
|
to: { revset: "@", firstParentSteps: 0 },
|
|
};
|
|
case "jj-evolog":
|
|
return compareTarget.length > 0
|
|
? { from: { revset: compareTarget, firstParentSteps: 0 }, to: { revset: "@", firstParentSteps: 0 } }
|
|
: null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Resolve one endpoint to a revision `jj diff` accepts as a single argument. */
|
|
export async function resolveJjSnapshotEndpoint(
|
|
runtime: ReviewJjRuntime,
|
|
endpoint: JjSnapshotEndpoint,
|
|
cwd?: string,
|
|
): Promise<string> {
|
|
let revision = endpoint.revset;
|
|
for (let step = 0; step < endpoint.firstParentSteps; step += 1) {
|
|
const parent = await resolveJjFirstParentCommitId(runtime, revision, cwd);
|
|
if (!parent) {
|
|
throw new Error("This Jujutsu revision has no parent revision to compare against.");
|
|
}
|
|
revision = parent;
|
|
}
|
|
return revision;
|
|
}
|
|
|
|
/**
|
|
* The FIRST parent's commit id. jj lists a merge's parents in the order they
|
|
* were given to `jj new`, so index 0 is stable across invocations — unlike
|
|
* `heads()`/`latest()`, which order by graph shape or timestamp.
|
|
*/
|
|
async function resolveJjFirstParentCommitId(
|
|
runtime: ReviewJjRuntime,
|
|
revision: string,
|
|
cwd?: string,
|
|
): Promise<string | null> {
|
|
const result = await runtime.runJj([
|
|
"--ignore-working-copy",
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
revision,
|
|
"-T",
|
|
'parents.map(|p| p.commit_id()).join("\n")',
|
|
], { cwd });
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(firstErrorLine(result.stderr) ?? "Jujutsu could not resolve the snapshot revision.");
|
|
}
|
|
return result.stdout.split("\n").map((line) => line.trim()).find(Boolean) ?? null;
|
|
}
|
|
|
|
export async function runJjDiff(
|
|
runtime: ReviewJjRuntime,
|
|
diffType: DiffType,
|
|
defaultBranch: string,
|
|
cwd?: string,
|
|
options?: GitDiffOptions,
|
|
): Promise<DiffResult> {
|
|
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 };
|
|
}
|
|
|
|
// --- Diff staleness fingerprint ---------------------------------------------
|
|
// jj snapshots the working copy on every command invocation, so `@`'s commit
|
|
// id reflects the CURRENT file state — a working-copy edit changes it. That
|
|
// makes commit ids a complete content fingerprint for every jj mode. `null`
|
|
// means "cannot fingerprint" (callers treat as always-fresh).
|
|
export async function getJjDiffFingerprint(
|
|
runtime: ReviewJjRuntime,
|
|
diffType: DiffType,
|
|
defaultBranch: string,
|
|
cwd?: string,
|
|
): Promise<string | null> {
|
|
const idOf = async (rev: string): Promise<string | null> => {
|
|
const result = await runtime.runJj(
|
|
["log", "-r", rev, "--no-graph", "-T", "commit_id"],
|
|
{ cwd },
|
|
);
|
|
return result.exitCode === 0 ? result.stdout.trim() || null : null;
|
|
};
|
|
|
|
try {
|
|
switch (diffType) {
|
|
case "jj-current":
|
|
case "jj-all": {
|
|
const id = await idOf("@");
|
|
return id ? `jj:${diffType}:${id}` : null;
|
|
}
|
|
case "jj-last": {
|
|
const id = await idOf("@-");
|
|
return id ? `jj:${diffType}:${id}` : null;
|
|
}
|
|
case "jj-line": {
|
|
const compareTarget = defaultBranch.length > 0 ? defaultBranch : JJ_TRUNK_REVSET;
|
|
const current = await idOf("@");
|
|
const base = await idOf(jjLineBaseRevset(compareTarget));
|
|
return current && base ? `jj:${diffType}:${base}:${current}` : null;
|
|
}
|
|
case "jj-evolog": {
|
|
// Evolog diffs render `--from <historical entry> --to @`: the
|
|
// historical end is immutable, but @ is the LIVE working copy — every
|
|
// edit moves it and changes the patch. Fingerprint BOTH ends.
|
|
const current = await idOf("@");
|
|
if (!current) return null;
|
|
if (defaultBranch.length > 0) return `jj:${diffType}:${defaultBranch}:${current}`;
|
|
const evologs = await getJjEvoLogEntries(runtime, cwd);
|
|
return evologs.length >= 2
|
|
? `jj:${diffType}:auto:${evologs[1].commitId}:${current}`
|
|
: null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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<string> {
|
|
const result = await runtime.runJj([
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
JJ_LINE_BASE_REVSET,
|
|
"-T",
|
|
'json(bookmarks) ++ "\\t" ++ commit_id ++ "\\n"',
|
|
], { cwd });
|
|
// Every unresolvable case falls back to `trunk()`, which is what this
|
|
// returned before the line-of-work base was inferred at all. The only live
|
|
// caller is `getJjContext`, which runs on the review startup path with no
|
|
// handler above it, so throwing here does not report a problem: it aborts
|
|
// `plannotator review` with a stack trace before the server is built. That
|
|
// also covers a `jj` too old for `fork_point`/`reachable`, where the revset
|
|
// itself fails and the previous default is still perfectly serviceable.
|
|
if (result.exitCode !== 0) return JJ_TRUNK_REVSET;
|
|
|
|
const [record] = splitJjTemplateRecords(result.stdout);
|
|
if (!record) return JJ_TRUNK_REVSET;
|
|
|
|
const fields = splitJjTemplateFields(record);
|
|
const bookmark = parseJjResolvedBookmarks(fields?.[0] ?? record)
|
|
.find((name) => !isGeneratedPushBookmark(name));
|
|
if (bookmark) return bookmark;
|
|
|
|
const commitId = fields?.[1]?.trim();
|
|
if (commitId && !JJ_ROOT_COMMIT_ID.test(commitId)) return commitId;
|
|
return JJ_TRUNK_REVSET;
|
|
}
|
|
|
|
function isGeneratedPushBookmark(target: string): boolean {
|
|
const name = parseRemoteBookmark(target)?.name ?? target;
|
|
return name.startsWith(JJ_GENERATED_PUSH_BOOKMARK_PREFIX);
|
|
}
|
|
|
|
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<string>();
|
|
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<string>();
|
|
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<JjEvoLogEntry[]> {
|
|
// 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<string | null> {
|
|
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<string | null> {
|
|
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;
|
|
}
|