Files
PaperClipAI/.github/scripts/tests/ci-plan.test.mjs
T
DottaandPaperclip e40de0c9e8 perf(ci): refresh test weights and narrow graph dependencies (#18464)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Contributors need fast, reliable CI feedback.
> - Diff-aware CI selects test files and partitions them into runner
jobs.
> - Stale cost estimates put several slow suites on the same runner.
Broad imports also couple small tests to large modules.
> - This PR refreshes test costs, runs independent graph discovery in
parallel, and narrows three dependency boundaries.
> - Selection safeguards and CODEOWNER-only AWS routing stay in force.

## Linked Issues or Issue Description

Refs #18398. This follows the audit of [PR #18427's verification
run](https://github.com/paperclipai/paperclip/actions/runs/41169978784).

The audit found 403 selected general-server files without timing
weights. Three suites measured at 232s, 122s and 116s shared one shard
because each received a roughly 2s fallback weight. Local graph
discovery across four projects took 43.7s serially and 21.7s with two
workers. A reverse-order repeat took 37.7s serially and 22.6s with two
workers. Both comparisons produced identical selection results. This is
a local measurement, not a claim about production CI speed.

## What Changed

- Refresh general-server costs for 1,285 of 1,309 current files and
serialized costs for all 163 files from two successful protected-master
Cloud readiness runs. Retain prior costs for unmeasured files, including
dedicated chat and native-runner suites.
- Record passing server file costs, including collection, setup and
environment preparation. A weekly hosted workflow validates trusted
source runs and opens a timing-only PR. It tests shard coverage, pauses
while a refresh PR is open, and never merges automatically.
- Discover at most two project graphs concurrently in isolated,
heap-limited Node processes. Cache repeated inventory reads within one
selection. Worker errors and timeouts retain the full task plan.
- Fetch only the merge commit and parents for the selector. A real
shallow-clone regression proves the complete diff still includes more
than 300 files, renames and deletions.
- Share the identical log viewer and run details between both
AgentDetail pages. The viewer test imports the shared implementation and
runs each of its 13 distinct cases once.
- Extract the pure API error class while preserving the API client's
public exports. Import delivery work signals through their existing
narrow DB export.

## Verification

- PASS: `pnpm -r typecheck` and `pnpm build`.
- PASS: 284 CI selection, timing, shallow-diff, sharding and routing
tests.
- PASS: 74 further cache, routing and graph-proof tests.
- PASS: 94 targeted UI assertions across the viewer, run invocation, API
errors/client and progress helpers. All 76 assertions in the 12 server
suites made independently selectable also pass.
- PASS: `pnpm check:token-gates`, module-boundary checks, YAML parsing
and `git diff --check`.
- PASS: [Full PR CI and required `ci / verify`
aggregate](https://github.com/paperclipai/paperclip/actions/runs/41206683345),
including all 12 general-server shards, all nine serialized shards,
workspace groups, chat suites, Runner lanes and build checks.
- PASS: [GitHub hosted real-workspace selector and cache/routing
qualification](https://github.com/paperclipai/paperclip/actions/runs/41206683205).
- PASS: Both Greptile and Paperclip reviews are 5/5 with no actionable
findings or inline comments on `7dc65c51b8`. The PR has no merge
conflicts.
- PASS: The production refresh parser accepted all 107 structured
file-cost records from a real successful server CI job, including
setup/loading cost.
- Local `pnpm test:run` completed all six chat shards successfully. The
duplicate monolithic local run was stopped after the complete remote
suite passed; it was not completed locally.

The focused log-viewer command improved from 18.84s (26 duplicate
variant cases) to 8.49s (all 13 distinct behavior cases). The dependency
audit confirms 12 server files and the API-error test no longer need
conservative always-run retention. Repartitioning the audited PR with
the new costs changes the slowest shard from 810s to 591s of summed
historical test execution (1,124 of 1,142 files measured). This is a
workload simulation, not measured new CI wall time.

## Risks

- Two graph processes use more peak memory. Each has a 2 GiB heap limit
and a three-minute timeout. Any failure preserves the original task
plan. Real workspace qualification runs in GitHub CI.
- Historical logs contain test/hook costs only. New reporter records add
loading/setup costs after merge. The manifests identify measurement
coverage and source runs. These weights change placement, never
permission to skip tests.
- Runner load adds timing noise. The refresh uses medians and requires
at least 90% measurement coverage in each lane.
- GitHub may require approval to run checks on a PR created with
`GITHUB_TOKEN`. Refreshes remain reviewable and do not merge themselves.
- The shared viewer is a mechanical extraction. Both pages retain their
existing exports, and the behavior assertions remain covered. AWS runner
authorization is unchanged.

## Model Used

OpenAI Codex, GPT-6, with repository inspection, reasoning, code
execution and GitHub tools. The exact deployment ID and context-window
size were not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused checks and six
chat shards; complete suite passed in CI as detailed above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-31 11:05:12 -05:00

305 lines
18 KiB
JavaScript

import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { comparisonPaths, planTasks } from "../ci-plan.mjs";
import { mergeDiff } from "../ci-diff.mjs";
const root = new URL("../../../", import.meta.url);
const config = JSON.parse(readFileSync(new URL("turbo.json", root), "utf8"));
const lanes = JSON.parse(readFileSync(new URL(".github/ci-task-matrix.json", root), "utf8"));
const plan = (paths, options = {}) => planTasks({ config, lanes, paths, ...options });
const selected = (result, job) => result.jobs[job].run;
const selectedTasks = (result) => new Set(Object.values(result.jobs).filter((job) => job.run)
.flatMap((job) => job.matrix.include.map((entry) => entry.task)));
test("trusted workflow fallbacks preserve every declared partition and command", () => {
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
for (const [job, entries] of Object.entries(lanes)) {
const block = workflow.split(`\n ${job}:\n`)[1].split(/\n [a-z0-9_]+:\n/)[0];
const normalized = block.replaceAll("(needs.test_selection.outputs.plan || needs.gate.outputs.plan)", "needs.gate.outputs.plan");
assert.ok(normalized.includes(`fromJSON(needs.gate.outputs.plan).jobs.${job}.run`), job);
if (!block.includes(" matrix:")) { assert.equal(entries.length, 1); continue; }
const fallback = block.match(/matrix:.*\|\| '(.*)'\) \}\}/)?.[1];
assert.ok(fallback, `${job}: missing full fallback`);
assert.deepEqual(JSON.parse(fallback), { include: entries }, job);
}
});
test("a UI component retains browser and build coverage without unrelated server/Rust jobs", () => {
const result = plan(["ui/src/components/Sidebar.tsx"]);
assert.equal(selected(result, "build"), true);
assert.equal(selected(result, "e2e_shards"), true);
assert.equal(selected(result, "verify_serialized_server"), false);
assert.deepEqual([...selectedTasks(result)].filter((task) => /server|chat|runner/.test(task)), []);
assert.equal(result.jobs.general_tests.selected, 2);
});
test("source inspected across package boundaries selects its consumers", () => {
for (const file of ["ui/src/lib/issue-execution-policy.ts", "ui/src/index.css", "ui/src/pages/CompanyExport.tsx",
"ui/src/components/MarkdownBody.tsx"]) {
const tasks = selectedTasks(plan([file]));
assert.ok(tasks.has("ci:server"), file);
assert.ok(tasks.has("ci:runner-vitest"), file);
}
assert.ok(selectedTasks(plan(["ui/public/app-logos/example.svg"])).has("ci:workspaces-b"));
});
test("repository-wide guidance guards run uncached for every scanned UI file type", () => {
const source = readFileSync(new URL("server/src/__tests__/cli-invocation-safety.test.ts", root), "utf8");
const extensions = [...source.split("const SCAN_EXTENSIONS = new Set([")[1].split("]);")[0].matchAll(/"(\.[a-z]+)"/g)]
.map((match) => match[1]);
assert.ok(extensions.length > 0);
for (const extension of extensions) {
for (const folder of ["ui/src/components", "ui/src/pages", "ui/src/hooks", "ui/public"]) {
assert.ok(plan([`${folder}/guidance${extension}`]).jobs.typecheck_release_registry.run);
}
}
assert.equal(config.tasks["//#ci:typecheck"].cache, false);
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
const job = workflow.split("\n typecheck_release_registry:\n")[1].split("\n general_tests:\n")[0];
assert.match(job, /- name: Verify repository-wide CLI guidance safety\n run: pnpm exec vitest run --project @paperclipai\/server server\/src\/__tests__\/cli-invocation-safety.test.ts/);
assert.ok(!job.includes("uses: ./.github/actions/run-ci-task"));
});
test("shared contracts, runtime prompts, protocol inputs and fixtures reach consumers", () => {
const shared = selectedTasks(plan(["packages/shared/src/types/issue.ts"]));
for (const task of ["ci:server", "ci:db", "ci:workspaces-a", "ci:workspaces-b", "ci:serialized", "ci:e2e"]) {
assert.ok(shared.has(task), task);
}
assert.ok(selectedTasks(plan(["server/src/onboarding-assets/default/AGENTS.md"])).has("ci:runner-vitest"));
assert.ok(selectedTasks(plan(["packages/paperclip-runner/protocol/schema.json"])).has("ci:runner-rust"));
for (const file of ["tests/runner-e2e/fixtures/prompt.md", "scripts/run-vitest-stable.mjs", "pnpm-lock.yaml"]) {
assert.ok(Object.values(plan([file]).jobs).every((job) => job.selected === job.total), file);
}
});
test("renames out of source and deletions cannot masquerade as release notes", () => {
const paths = comparisonPaths({ files: [
{ filename: "releases/v2026.1029.0.md", previous_filename: "packages/shared/src/constants.ts", status: "renamed" },
{ filename: "server/src/deleted.ts", status: "removed" },
] });
assert.ok(selectedTasks(plan(paths)).has("ci:server"));
assert.ok(selectedTasks(plan(paths)).has("ci:db"));
});
test("unclassified paths and uncertain comparisons select full verification", () => {
for (const paths of [[], ["new-root/file.ts"], ["../escaped"]]) {
const result = plan(paths);
assert.ok(result.forceReason);
assert.ok(Object.values(result.jobs).every((job) => job.selected === job.total));
}
for (const files of [null, [], [{ status: "modified" }], [{ filename: "a", status: "renamed" }],
Array.from({ length: 300 }, () => ({ filename: "ui/src/test.ts" }))]) {
assert.throws(() => comparisonPaths({ files }));
}
});
test("shadow mode explains savings but runs every task; force overrides selection", () => {
const shadow = plan(["ui/src/components/Sidebar.tsx"], { mode: "shadow" });
assert.equal(shadow.jobs.verify_serialized_server.proposed, 0);
assert.equal(shadow.jobs.verify_serialized_server.selected, 9);
for (const options of [{ mode: "full" }, { forceReason: "Stack" }]) {
assert.ok(Object.values(plan(["ui/src/components/Sidebar.tsx"], options).jobs)
.every((job) => job.selected === job.total));
}
});
test("cache-affecting definition changes force execution across successive PR pushes", () => {
for (const file of ["turbo.json", "package.json", "packages/db/package.json", "scripts/ci/run-task.mjs",
".github/actions/run-ci-task/action.yml"]) {
for (const paths of [[file], [file, "packages/db/src/schema/issues.ts"]]) {
const result = plan(paths);
assert.match(result.forceReason, /Global CI input changed/);
assert.ok(Object.values(result.jobs).every((job) => job.selected === job.total));
}
}
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
assert.equal((workflow.match(/force:.*\.forceReason != null/g) ?? []).length, 3);
const action = readFileSync(new URL(".github/actions/run-ci-task/action.yml", root), "utf8");
assert.match(action, /CI_FORCE_TASKS: \$\{\{ inputs.force \}\}/);
assert.equal((action.match(/if:.*inputs.force != 'true'/g) ?? []).length, 2);
});
test("task dependencies propagate input changes and unsupported graph syntax fails closed", () => {
const fixture = { globalDependencies: [], tasks: {
"//#build": { inputs: ["lib/**"] }, "//#test": { inputs: ["app/**"], dependsOn: ["//#build"] },
} };
assert.equal(planTasks({ config: fixture, lanes: { tests: [{ task: "test" }] }, paths: ["lib/index.ts"] }).jobs.tests.run, true);
fixture.tasks["//#build"].dependsOn = ["//#test"];
assert.throws(() => planTasks({ config: fixture, lanes: { tests: [{ task: "test" }] }, paths: ["app/index.ts"] }), /Cyclic/);
assert.throws(() => plan(["ui/src/a.ts"], { mode: "typo" }), /Invalid CI mode/);
});
test("local planner reads a real Git rename and handles a missing revision conservatively", () => {
const directory = mkdtempSync(path.join(tmpdir(), "ci-plan-"));
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
try {
git("init", "-q"); git("config", "user.name", "CI Test"); git("config", "user.email", "ci@example.invalid");
mkdirSync(path.join(directory, "packages/shared/src"), { recursive: true });
writeFileSync(path.join(directory, "packages/shared/src/input.ts"), "original\n");
git("add", "."); git("commit", "-qm", "base");
const base = git("rev-parse", "HEAD").trim();
mkdirSync(path.join(directory, "releases"));
git("mv", "packages/shared/src/input.ts", "releases/v1.md"); git("commit", "-qm", "rename");
for (const revision of [base, "missing-revision"]) {
const result = spawnSync(process.execPath, [new URL("../ci-plan.mjs", import.meta.url).pathname, revision], {
cwd: directory, encoding: "utf8", env: { ...process.env, GITHUB_OUTPUT: "", GITHUB_STEP_SUMMARY: "",
CI_TASK_CONFIG: new URL("turbo.json", root).pathname, CI_TASK_MATRIX: new URL(".github/ci-task-matrix.json", root).pathname },
});
assert.equal(result.status, 0, result.stderr);
const resultPlan = JSON.parse(result.stdout);
assert.ok(selectedTasks(resultPlan).has("ci:server"));
if (revision !== base) assert.match(resultPlan.forceReason, /Diff unavailable/);
}
} finally { rmSync(directory, { recursive: true, force: true }); }
});
test("required aggregates reject omitted, failed and cancelled selected work", () => {
const workflow = readFileSync(new URL(".github/workflows/pr-trusted.yml", root), "utf8");
for (const id of ["verify", "e2e"]) {
const block = workflow.split(`\n ${id}:\n`)[1].split(/\n [a-z0-9_]+:\n/)[0];
const code = block.split(" node --input-type=module <<'NODE'\n")[1].split("\n NODE")[0]
.split("\n").map((line) => line.slice(10)).join("\n");
const names = /needs: \[([^\]]+)\]/.exec(block)[1].split(", ");
const resultPlan = plan(["ui/src/components/Sidebar.tsx"]);
const results = Object.fromEntries(names.map((name) => [name, { result:
name === "gate" || name === "policy" || resultPlan.jobs[name]?.run ? "success" : "skipped" }]));
const run = () => spawnSync(process.execPath, ["--input-type=module", "-e", code], { encoding: "utf8",
env: { ...process.env, FULL_CI: "true", CI_PLAN: JSON.stringify(resultPlan), CI_RESULTS: JSON.stringify(results) } });
assert.equal(run().status, 0);
for (const name of names) {
// Optional analysis can fail or be skipped; the unchanged task plan
// still has to pass. Selected-plan cases are exercised separately.
if (name === "test_selection") continue;
const previous = results[name].result;
for (const bad of previous === "success" ? ["failure", "cancelled", "skipped"] : ["success"]) {
results[name].result = bad;
assert.notEqual(run().status, 0, `${id}: ${name} must reject ${bad}`);
}
results[name].result = previous;
}
}
});
test("the real routing step uses pinned trusted definitions and defaults to full on fetch failure", () => {
const workflow = readFileSync(new URL(".github/workflows/ci-routing.yml", root), "utf8");
const block = workflow.split(" - name: Plan affected CI tasks from trusted definitions\n")[1];
const shell = block.split(" run: |\n")[1].split("\n").map((line) => line.slice(10)).join("\n");
const directory = mkdtempSync(path.join(tmpdir(), "ci-routing-plan-"));
try {
const repository = path.join(directory, "source");
mkdirSync(repository);
const gitBin = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
const git = (...args) => execFileSync(gitBin, args, { cwd: repository, encoding: "utf8" }).trim();
git("init", "-q", "-b", "main"); git("config", "user.name", "CI"); git("config", "user.email", "ci@example.invalid");
writeFileSync(path.join(repository, "base"), "base"); git("add", "."); git("commit", "-qm", "base");
git("checkout", "-qb", "pr");
mkdirSync(path.join(repository, "ui/src/components"), { recursive: true });
writeFileSync(path.join(repository, "ui/src/components/Sidebar.tsx"), "change");
git("add", "."); git("commit", "-qm", "PR");
const prHead = git("rev-parse", "HEAD");
git("checkout", "-q", "main");
writeFileSync(path.join(repository, "unrelated-master"), "already merged");
git("add", "."); git("commit", "-qm", "advance base");
const base = git("rev-parse", "HEAD");
git("merge", "--no-ff", "pr", "-m", "tested merge");
const merge = git("rev-parse", "HEAD");
writeFileSync(path.join(directory, "git"), `#!/usr/bin/env node
const {spawnSync}=require('node:child_process');
if(process.env.TEST_GIT_FAILURE==='true')process.exit(1);
const args=process.argv.slice(2).map(a=>a==='https://github.com/paperclipai/paperclip.git'?process.env.TEST_GIT_REPOSITORY:a);
const r=spawnSync(${JSON.stringify(gitBin)},args,{stdio:'inherit'});process.exit(r.status??1);
`);
chmodSync(path.join(directory, "git"), 0o755);
const gh = path.join(directory, "gh");
writeFileSync(gh, `#!/usr/bin/env node
const fs = require('node:fs'), path = require('node:path');
const endpoint = process.argv.find(arg => arg.startsWith('/repos/'));
if (endpoint?.endsWith('/git/ref/heads/master')) console.log('a'.repeat(40));
else if (endpoint?.includes('/contents/')) {
if (process.env.TEST_FETCH_FAILURE === 'true') process.exit(1);
const [file, query] = endpoint.split('/contents/')[1].split('?');
if (query !== 'ref=' + 'a'.repeat(40)) process.exit(2);
process.stdout.write(fs.readFileSync(path.join(process.env.TEST_TRUSTED_ROOT, file)));
}
else process.exit(3);
`);
chmodSync(gh, 0o755);
for (const overrides of [{}, { TEST_FETCH_FAILURE: "true" }, { TEST_GIT_FAILURE: "true" }, { PROFILE_CI: "true" }, { STACK_JSON: "{}" }, { FULL_CI: "false" }]) {
const output = path.join(directory, "output");
writeFileSync(output, "");
const result = spawnSync("bash", ["-c", shell], { encoding: "utf8", env: {
...process.env, PATH: `${directory}:${process.env.PATH}`, TEST_TRUSTED_ROOT: root.pathname,
RUNNER_TEMP: directory, FULL_CI: "true", PROFILE_CI: "false", FORCE_FULL_CI: "false",
CI_INCREMENTAL_MODE: "enabled", STACK_JSON: "null", EVENT_BASE_SHA: "b".repeat(40), EVENT_HEAD_SHA: prHead, EVENT_MERGE_SHA: merge,
TEST_GIT_REPOSITORY: repository,
GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: "", ...overrides,
} });
assert.equal(result.status, 0, result.stderr);
const value = readFileSync(output, "utf8").trim();
if (overrides.TEST_FETCH_FAILURE || overrides.TEST_GIT_FAILURE || overrides.FULL_CI === "false") assert.equal(value, "plan=");
else {
assert.notEqual(value, "plan=", `${JSON.stringify(overrides)}: ${result.stderr}`);
const resultPlan = JSON.parse(value.slice("plan=".length));
assert.equal(resultPlan.jobs.general_tests.selected, overrides.PROFILE_CI || overrides.STACK_JSON ? 19 : 2);
assert.equal(resultPlan.diff.base, base);
assert.deepEqual(resultPlan.changed, ["ui/src/components/Sidebar.tsx"]);
}
}
} finally { rmSync(directory, { recursive: true, force: true }); }
});
test("audited prose does not widen source selection, but guidance still runs its uncached guard", () => {
const source = "ui/src/components/Sidebar.tsx";
const sourcePlan = plan([source]);
for (const doc of ["doc/DEVELOPING.md", "doc/DATABASE.md", "doc/plans/2026-10-30-example.md"]) {
const mixed = plan([source, doc]);
assert.equal(mixed.forceReason, null);
assert.deepEqual(mixed.jobs, sourcePlan.jobs);
assert.deepEqual(mixed.documentation, [doc]);
}
const guidance = plan(["doc/DEVELOPING.md"]);
assert.deepEqual([...selectedTasks(guidance)], ["ci:typecheck"]);
assert.equal(config.tasks["//#ci:typecheck"].cache, false);
assert.deepEqual([...selectedTasks(plan(["doc/plans/2026-10-30-example.md"]))], []);
for (const file of ["docs/api/issues.md", "doc/unknown.md", "doc/plans/tool.sh", "skills/paperclip/SKILL.md",
"server/src/onboarding-assets/default/AGENTS.md", "tests/fixtures/example.md"]) {
assert.ok(selectedTasks(plan([source, file])).has("ci:server"), file);
}
});
test("complete merge trees handle more than 300 files, base advancement, renames and deletions", (t) => {
const directory = mkdtempSync(path.join(tmpdir(), "ci-merge-diff-"));
t.after(() => rmSync(directory, { recursive: true, force: true }));
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8" }).trim();
const write = (file, value) => { mkdirSync(path.dirname(path.join(directory, file)), { recursive: true }); writeFileSync(path.join(directory, file), value); };
const commit = () => { git("add", "."); git("commit", "-qm", "fixture"); return git("rev-parse", "HEAD"); };
git("init", "-q", "-b", "main"); git("config", "user.name", "CI"); git("config", "user.email", "ci@example.invalid");
write("ui/src/deleted.ts", "old"); write("ui/src/renamed.ts", "rename"); const staleBase = commit();
git("checkout", "-qb", "pr");
for (let index = 0; index < 350; index++) write(`ui/src/component-${index}.ts`, "new");
git("rm", "ui/src/deleted.ts");
mkdirSync(path.join(directory, "doc/plans"), { recursive: true });
git("mv", "ui/src/renamed.ts", "doc/plans/2026-10-30-moved.md");
const prHead = commit();
git("checkout", "-q", "main"); write(".github/workflows/unrelated.yml", "already merged CI change"); const base = commit();
git("merge", "--no-ff", "pr", "-m", "tested merge"); const head = git("rev-parse", "HEAD");
const result = mergeDiff(head, prHead, directory);
assert.equal(result.diff.base, base); assert.notEqual(result.diff.base, staleBase);
assert.equal(result.changed.length, 353);
assert.ok(result.changed.includes("ui/src/deleted.ts")); assert.ok(result.changed.includes("ui/src/renamed.ts"));
assert.ok(!result.changed.includes(".github/workflows/unrelated.yml"));
assert.equal(plan(result.changed).forceReason, null);
const shallow = path.join(directory, "shallow");
git("clone", "--quiet", "--depth", "2", `file://${directory}`, shallow);
assert.equal(execFileSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: shallow, encoding: "utf8" }).trim(), "true");
assert.deepEqual(mergeDiff(head, prHead, shallow), result);
assert.throws(() => mergeDiff(head, base, directory), /expected PR head/);
assert.throws(() => mergeDiff(prHead, prHead, directory), /merge commit/);
assert.throws(() => mergeDiff("f".repeat(40), prHead, directory));
});