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>
143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import {
|
|
getJjDiffArgs,
|
|
jjLineBaseRevset,
|
|
runtime as jjRuntime,
|
|
selectDefaultJjCompareTarget,
|
|
} from "./jj";
|
|
|
|
describe("jj diff args", () => {
|
|
test("builds git-format diff args for each jj mode", () => {
|
|
expect(getJjDiffArgs("jj-current", "trunk()")).toEqual({
|
|
args: ["diff", "--git", "-r", "@"],
|
|
label: "Current change",
|
|
});
|
|
|
|
expect(getJjDiffArgs("jj-last", "trunk()")).toEqual({
|
|
args: ["diff", "--git", "-r", "@-"],
|
|
label: "Last change",
|
|
});
|
|
|
|
expect(getJjDiffArgs("jj-line", "trunk()")).toEqual({
|
|
args: ["diff", "--git", "--from", "heads(::@ & ::(trunk()))", "--to", "@"],
|
|
label: "Line of work vs trunk()",
|
|
});
|
|
|
|
expect(getJjDiffArgs("jj-all", "trunk()")).toEqual({
|
|
args: ["diff", "--git", "--from", "root()", "--to", "@"],
|
|
label: "All files",
|
|
});
|
|
});
|
|
|
|
test("preserves hide-whitespace in every jj diff mode", () => {
|
|
expect(getJjDiffArgs("jj-current", "trunk()", { hideWhitespace: true })?.args)
|
|
.toEqual(["diff", "--git", "-w", "-r", "@"]);
|
|
expect(getJjDiffArgs("jj-last", "trunk()", { hideWhitespace: true })?.args)
|
|
.toEqual(["diff", "--git", "-w", "-r", "@-"]);
|
|
expect(getJjDiffArgs("jj-line", "trunk()", { hideWhitespace: true })?.args)
|
|
.toEqual(["diff", "--git", "-w", "--from", "heads(::@ & ::(trunk()))", "--to", "@"]);
|
|
expect(getJjDiffArgs("jj-all", "trunk()", { hideWhitespace: true })?.args)
|
|
.toEqual(["diff", "--git", "-w", "--from", "root()", "--to", "@"]);
|
|
});
|
|
});
|
|
|
|
describe("jj compare targets", () => {
|
|
test("resolves a readable target for the detected line base", async () => {
|
|
await expect(selectDefaultJjCompareTarget({
|
|
async runJj() {
|
|
return { stdout: '[{"name":"main"},{"name":"main","remote":"origin"}]\\t0123456789abcdef\\n', stderr: "", exitCode: 0 };
|
|
},
|
|
})).resolves.toBe("main@origin");
|
|
|
|
await expect(selectDefaultJjCompareTarget({
|
|
async runJj() {
|
|
return { stdout: '[{"name":"main"}]\\t0123456789abcdef\\n', stderr: "", exitCode: 0 };
|
|
},
|
|
})).resolves.toBe("main");
|
|
|
|
await expect(selectDefaultJjCompareTarget({
|
|
async runJj() {
|
|
return { stdout: "[]\\t0123456789abcdef\\n", stderr: "", exitCode: 0 };
|
|
},
|
|
})).resolves.toBe("0123456789abcdef");
|
|
|
|
// Generated `jj git push --change` bookmarks are never a readable target.
|
|
await expect(selectDefaultJjCompareTarget({
|
|
async runJj() {
|
|
return {
|
|
stdout: '[{"name":"push-vmopwunwxopv","remote":"origin"}]\\t0123456789abcdef\\n',
|
|
stderr: "",
|
|
exitCode: 0,
|
|
};
|
|
},
|
|
})).resolves.toBe("0123456789abcdef");
|
|
|
|
// An unresolvable base degrades to the previous default instead of aborting
|
|
// review startup, which has no handler for a throw.
|
|
await expect(selectDefaultJjCompareTarget({
|
|
async runJj() {
|
|
return { stdout: "", stderr: "unknown function", exitCode: 1 };
|
|
},
|
|
})).resolves.toBe("trunk()");
|
|
});
|
|
|
|
test("treats bookmarks and revsets correctly in line-of-work revsets", () => {
|
|
expect(jjLineBaseRevset("main")).toBe('heads(::@ & ::(bookmarks(exact:"main")))');
|
|
expect(jjLineBaseRevset("main@origin")).toBe('heads(::@ & ::(remote_bookmarks(exact:"main", exact:"origin")))');
|
|
expect(jjLineBaseRevset("trunk()")).toBe("heads(::@ & ::(trunk()))");
|
|
expect(jjLineBaseRevset("a".repeat(40))).toBe(`heads(::@ & ::(${"a".repeat(40)}))`);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Snapshot materialization asks jj for a whole repository tree, so the runtime
|
|
* has to stop READING at the ceiling. Measuring the output after buffering it
|
|
* bounds nothing: the memory is already spent by the time the check runs.
|
|
*
|
|
* Skipped when `jj` is not installed (CI runners do not ship it).
|
|
*/
|
|
describe("jj runtime output ceiling", () => {
|
|
// Bun.spawnSync throws when the executable is missing entirely (ENOENT),
|
|
// which is exactly the case this gate exists for on CI runners without jj.
|
|
const testIfJj = (() => {
|
|
try {
|
|
return Bun.spawnSync(["jj", "--version"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})()
|
|
? test
|
|
: test.skip;
|
|
let workspace = "";
|
|
|
|
afterEach(() => {
|
|
if (workspace) rmSync(workspace, { recursive: true, force: true });
|
|
workspace = "";
|
|
});
|
|
|
|
testIfJj("stops reading and flags truncation once maxOutputBytes is passed", async () => {
|
|
workspace = mkdtempSync(join(tmpdir(), "plannotator-jj-cap-"));
|
|
const jj = (args: string[]) => {
|
|
const result = Bun.spawnSync(["jj", ...args], { cwd: workspace, stdout: "pipe", stderr: "pipe" });
|
|
if (result.exitCode !== 0) throw new Error(result.stderr.toString());
|
|
};
|
|
jj(["git", "init", "."]);
|
|
jj(["config", "set", "--repo", "user.name", "Cap Test"]);
|
|
jj(["config", "set", "--repo", "user.email", "cap-test@example.invalid"]);
|
|
writeFileSync(join(workspace, "big.ts"), "export const line = 1;\n".repeat(4000));
|
|
jj(["commit", "-m", "big"]);
|
|
|
|
const args = ["--ignore-working-copy", "diff", "--git", "--from", "root()", "--to", "@-"];
|
|
const uncapped = await jjRuntime.runJj(args, { cwd: workspace });
|
|
expect(uncapped.truncated).toBeUndefined();
|
|
expect(uncapped.stdout.length).toBeGreaterThan(10_000);
|
|
|
|
const capped = await jjRuntime.runJj(args, { cwd: workspace, maxOutputBytes: 64 });
|
|
expect(capped.truncated).toBe(true);
|
|
expect(capped.stdout.length).toBeLessThanOrEqual(64);
|
|
});
|
|
});
|