Files
harness-tools/extensions/workflow/workspace.test.ts
T

923 lines
30 KiB
TypeScript

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
access,
mkdir,
mkdtemp,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { deflateSync } from "node:zlib";
import {
ApprovedHostCommandExecutor,
ExecFileCommandRunner,
FilesystemPrototypeValidator,
GitStateProbe,
GitWorkspaceManager,
INTERNAL_GIT_TIMEOUT_MS,
MutationScope,
WorkspaceSafetyError,
WorkspaceValidationError,
type CommandResult,
type CommandRunner,
type CommandRunOptions,
} from "./workspace.ts";
const execFileAsync = promisify(execFile);
function crc32(contents: Uint8Array) {
let crc = 0xffffffff;
for (const byte of contents) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer) {
const name = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.byteLength);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(Buffer.concat([name, data])));
return Buffer.concat([length, name, data, checksum]);
}
function grayscalePng(width: number, height: number, value: number) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 0;
const rows = Buffer.alloc((width + 1) * height);
for (let row = 0; row < height; row++) {
rows[row * (width + 1)] = 0;
rows.fill(value, row * (width + 1) + 1, (row + 1) * (width + 1));
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(rows)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(
path.join(tmpdir(), "workflow-workspace-"),
);
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
class FakeRunner implements CommandRunner {
readonly calls: Array<{
cwd: string;
executable: string;
args: string[];
options?: CommandRunOptions;
}> = [];
private readonly respond: (args: string[]) => CommandResult;
constructor(respond: (args: string[]) => CommandResult) {
this.respond = respond;
}
async run(
cwd: string,
executable: string,
args: string[],
options?: CommandRunOptions,
) {
this.calls.push({ cwd, executable, args: [...args], options });
return this.respond(args);
}
}
function result(code = 0, stdout = "", stderr = ""): CommandResult {
return { code, stdout, stderr };
}
function prepareResponses(
repository: string,
options: {
dirty?: boolean;
unmerged?: boolean;
ignored?: boolean;
branchExists?: boolean;
} = {},
) {
return (args: string[]) => {
const command = args.join(" ");
if (command === "rev-parse --show-toplevel")
return result(0, `${repository}\n`);
if (command === "status --porcelain=v1 -z --untracked-files=all") {
if (options.unmerged) return result(0, "UU src/conflict.ts\0");
return result(
0,
options.dirty ? " M src/file.ts\0?? src/new-file.ts\0" : "",
);
}
if (command.startsWith("check-ignore ")) {
return result(options.ignored === false ? 1 : 0);
}
if (command.startsWith("show-ref ")) {
return result(options.branchExists ? 0 : 1);
}
if (command === "rev-parse HEAD") return result(0, "abc123\n");
if (command.startsWith("worktree add ")) return result();
throw new Error(`Unexpected Git command: ${command}`);
};
}
test("mutation ownership rejects traversal, sibling prefixes, and symlinks", async () => {
await withTempDir(async (directory) => {
const root = path.join(directory, "worktree");
const outside = path.join(directory, "outside");
await mkdir(path.join(root, "src"), { recursive: true });
await mkdir(outside, { recursive: true });
const scope = await MutationScope.create(root, ["src"]);
assert.equal(
await scope.assertWritePath("src/new/file.ts"),
path.join(root, "src/new/file.ts"),
);
await scope.mkdir("src/new");
await scope.writeFile("src/new/file.ts", "safe\n");
assert.equal(await readFile(path.join(root, "src/new/file.ts"), "utf8"), "safe\n");
await assert.rejects(scope.assertWritePath("src-other/file.ts"), /outside Assignment ownership/);
await assert.rejects(scope.assertWritePath("../outside/file.ts"), /outside Assignment ownership/);
await symlink(outside, path.join(root, "src", "linked"));
await assert.rejects(
scope.assertWritePath("src/linked/file.ts"),
/symbolic link|outside the worktree/,
);
await symlink(outside, path.join(root, "owned-link"));
const linkedScope = await MutationScope.create(root, ["owned-link"]);
await assert.rejects(
linkedScope.assertWritePath("owned-link/file.ts"),
/symbolic link|outside the worktree/,
);
});
});
test("prototype validation requires real candidates and browser evidence, then proves cleanup", async () => {
await withTempDir(async (directory) => {
const candidate = path.join(directory, "prototypes", "candidate-1");
await mkdir(candidate, { recursive: true });
await writeFile(path.join(candidate, "index.html"), "<main>Prototype</main>", "utf8");
await writeFile(
path.join(candidate, "desktop.png"),
grayscalePng(1440, 900, 200),
);
await writeFile(
path.join(candidate, "mobile.png"),
grayscalePng(390, 844, 100),
);
await writeFile(
path.join(candidate, "desktop.json"),
JSON.stringify({
route: "/prototype?variant=1",
result: "passed",
viewport: { width: 1440, height: 900 },
screenshotPath: "prototypes/candidate-1/desktop.png",
}),
"utf8",
);
await writeFile(
path.join(candidate, "mobile.json"),
JSON.stringify({
route: "/prototype?variant=1",
result: "passed",
viewport: { width: 390, height: 844 },
screenshotPath: "prototypes/candidate-1/mobile.png",
}),
"utf8",
);
const validator = new FilesystemPrototypeValidator();
const validation = await validator.validateCandidate({
worktreeRoot: directory,
assignment: { ownedPaths: ["prototypes/candidate-1"] },
output: {
path: "prototypes/candidate-1",
route: "/prototype?variant=1",
browserEvidence: [
"prototypes/candidate-1/mobile.json",
"prototypes/candidate-1/desktop.json",
],
},
});
assert.equal(validation.artifactFiles.length, 4);
assert.match(validation.visualDigest, /^[a-f0-9]{64}$/);
await writeFile(
path.join(candidate, "fake.png"),
Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
Buffer.from("not an image"),
]),
);
await writeFile(
path.join(candidate, "fake.json"),
JSON.stringify({
route: "/prototype?variant=1",
result: "passed",
viewport: { width: 390, height: 844 },
screenshotPath: "prototypes/candidate-1/fake.png",
}),
"utf8",
);
await assert.rejects(
validator.validateCandidate({
worktreeRoot: directory,
assignment: { ownedPaths: ["prototypes/candidate-1"] },
output: {
path: "prototypes/candidate-1",
route: "/prototype?variant=1",
browserEvidence: ["prototypes/candidate-1/fake.json"],
},
}),
/not a decodable PNG/,
);
await assert.rejects(
validator.validateCandidate({
worktreeRoot: directory,
assignment: { ownedPaths: ["prototypes/candidate-1"] },
output: {
path: "prototypes/candidate-1",
route: "/prototype?variant=1",
browserEvidence: ["prototypes/candidate-1/missing.json"],
},
}),
/missing|ENOENT/,
);
await assert.rejects(
validator.validateCleanup({
worktreeRoot: directory,
candidates: [{ path: "prototypes/candidate-1" }],
}),
/remains after cleanup/,
);
await rm(candidate, { recursive: true });
await validator.validateCleanup({
worktreeRoot: directory,
candidates: [{ path: "prototypes/candidate-1" }],
});
});
});
test("Git probe and approved host commands enforce identity and ownership", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
await mkdir(path.join(directory, "src"), { recursive: true });
await mkdir(path.join(directory, "other"), { recursive: true });
await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8");
await writeFile(path.join(directory, "other", "file.ts"), "before\n", "utf8");
await writeFile(
path.join(directory, "mutate.cjs"),
'require("node:fs").writeFileSync(process.argv[2], "after\\n")',
"utf8",
);
await writeFile(
path.join(directory, "src", "bytecode_module.py"),
"VALUE = 1\n",
"utf8",
);
await writeFile(
path.join(directory, "src", "check_bytecode.py"),
"import bytecode_module\nassert bytecode_module.VALUE == 1\n",
"utf8",
);
await writeFile(
path.join(directory, "check-offline.cjs"),
'process.exit(process.env.PI_OFFLINE === "1" ? 0 : 1)',
"utf8",
);
await writeFile(
path.join(directory, "spawn-hanging-child.cjs"),
[
'const { spawn } = require("node:child_process");',
'const { writeFileSync } = require("node:fs");',
'const child = spawn(process.execPath, ["delayed-write.cjs"], { stdio: "ignore" });',
'writeFileSync("src/timeout-pids.json", JSON.stringify([process.pid, child.pid]));',
"setInterval(() => {}, 1_000);",
].join("\n"),
"utf8",
);
await writeFile(
path.join(directory, "delayed-write.cjs"),
[
'const { writeFileSync } = require("node:fs");',
'process.on("SIGTERM", () => {});',
'setTimeout(() => writeFileSync("src/delayed.txt", "late\\n"), 1_200);',
"setInterval(() => {}, 1_000);",
].join("\n"),
"utf8",
);
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
const probe = new GitStateProbe();
const initial = await probe.capture(directory);
assert.equal(initial.changedPaths.length, 0);
const executor = new ApprovedHostCommandExecutor();
const allowed = await executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["mutate.cjs", "src/file.ts"],
});
assert.equal(allowed.result.code, 0);
assert.deepEqual(allowed.after.changedPaths, ["src/file.ts"]);
const bytecodeDisabled = await executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "python3",
args: ["src/check_bytecode.py"],
});
assert.equal(bytecodeDisabled.result.code, 0);
await assert.rejects(
access(path.join(directory, "src", "__pycache__")),
/ENOENT/,
);
const offline = await executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["PI_OFFLINE=1", process.execPath, "check-offline.cjs"],
});
assert.equal(offline.result.code, 0);
const boundedExecutor = new ApprovedHostCommandExecutor(
new ExecFileCommandRunner(),
undefined,
250,
);
const startedAt = Date.now();
const timedOut = await boundedExecutor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["spawn-hanging-child.cjs"],
});
assert.equal(timedOut.result.code, 124);
assert.match(timedOut.result.stderr, /timed out after 250ms/);
assert.ok(Date.now() - startedAt < 2_500);
const timedOutProcessIds = JSON.parse(
await readFile(path.join(directory, "src", "timeout-pids.json"), "utf8"),
) as number[];
for (const processId of timedOutProcessIds) {
assert.throws(
() => process.kill(processId, 0),
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
);
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
await assert.rejects(
access(path.join(directory, "src", "delayed.txt")),
/ENOENT/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["OTHER=value", process.execPath, "check-offline.cjs"],
}),
/cannot invoke a command wrapper or shell/,
);
const blockedOfflineCommands: Array<{
args: string[];
expected: RegExp;
}> = [
{
args: ["PI_OFFLINE=1", "OTHER=value", process.execPath],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "sh", "-c", "touch src/bypass"],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "git", "clean", "-fd"],
expected: /Git host commands are not allowed/,
},
{
args: ["PI_OFFLINE=1", process.execPath, "-e", "process.exit(0)"],
expected: /cannot execute inline interpreter code/,
},
{
args: ["PI_OFFLINE=1", "env", "PI_OFFLINE=1", process.execPath],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "npm", "exec", "git"],
expected: /cannot invoke package-manager executable wrappers/,
},
];
for (const blocked of blockedOfflineCommands) {
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: blocked.args,
}),
blocked.expected,
);
}
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["mutate.cjs", "other/file.ts"],
}),
/outside ownership: other\/file.ts/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "git",
args: ["reset", "--hard"],
}),
/Git host commands are not allowed/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "git",
args: ["-C", ".", "reset", "--hard"],
}),
/Git host commands are not allowed/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "git",
args: ["-c", "alias.x=commit", "x", "-m", "bypass"],
}),
/Git host commands are not allowed/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["git", "clean", "-fd"],
}),
/cannot invoke a command wrapper or shell/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["-e", 'require("node:child_process").execFileSync("git")'],
}),
/cannot execute inline interpreter code/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "sh",
args: ["-c", "touch src/shell-created"],
}),
/cannot invoke a command wrapper or shell/,
);
});
});
test("command runner reports bounded termination failure as exit 125", async () => {
await withTempDir(async (directory) => {
const runner = new ExecFileCommandRunner({
async terminateProcessTree(processId) {
if (process.platform !== "win32") {
process.kill(-processId, "SIGKILL");
} else {
process.kill(processId, "SIGKILL");
}
throw new Error("injected termination failure");
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /injected termination failure/);
assert.ok(Date.now() - started < 2_000);
});
});
test("command runner force-kills a child when the termination helper never settles", async () => {
await withTempDir(async (directory) => {
let childProcessId: number | undefined;
const runner = new ExecFileCommandRunner({
terminateProcessTree(processId) {
childProcessId = processId;
return new Promise<void>(() => {});
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /termination did not finish within its deadline/);
assert.ok(Date.now() - started < 2_000);
const terminatedProcessId = childProcessId;
assert.ok(terminatedProcessId);
assert.throws(
() => process.kill(terminatedProcessId, 0),
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
);
});
});
test("Git change detection excludes unchanged pre-existing dirty paths", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
await mkdir(path.join(directory, "src"), { recursive: true });
await writeFile(path.join(directory, "src", "existing.ts"), "base\n", "utf8");
await writeFile(path.join(directory, "src", "generated.ts"), "base\n", "utf8");
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
await writeFile(path.join(directory, "src", "existing.ts"), "dirty\n", "utf8");
const probe = new GitStateProbe();
const before = await probe.capture(directory);
await writeFile(path.join(directory, "src", "generated.ts"), "generated\n", "utf8");
const after = await probe.capture(directory);
assert.deepEqual(probe.changedSince(before, after), ["src/generated.ts"]);
});
});
test("post-command Git probe failures are workspace safety failures", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
await mkdir(path.join(directory, "src"), { recursive: true });
await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8");
await writeFile(
path.join(directory, "corrupt.cjs"),
'require("node:fs").renameSync(".git", ".git-broken")',
"utf8",
);
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
await assert.rejects(
new ApprovedHostCommandExecutor().run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["corrupt.cjs"],
}),
(error: unknown) =>
error instanceof WorkspaceSafetyError &&
/Unable to verify Git safety after host command/.test(error.message),
);
});
});
test("workspace preparation validates Git state and builds the exact feature branch", async () => {
await withTempDir(async (repository) => {
const runner = new FakeRunner(prepareResponses(repository));
const manager = new GitWorkspaceManager(runner);
const plan = await manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "search-dashboard",
runId: "run-1",
});
assert.equal(plan.branch, "feature/search-dashboard");
assert.equal(
plan.worktreePath,
path.join(repository, ".worktrees", "workflow-search-dashboard-run-1"),
);
assert.equal(plan.baseHead, "abc123");
assert.equal(plan.branchExists, false);
const workspace = await manager.create(plan);
assert.equal(workspace.state, "ready");
assert.deepEqual(runner.calls.at(-1)?.args, [
"worktree",
"add",
"-b",
"feature/search-dashboard",
plan.worktreePath,
"abc123",
]);
assert.ok(runner.calls.every((call) => call.executable === "git"));
assert.ok(
runner.calls.every(
(call) => call.options?.timeoutMs === INTERNAL_GIT_TIMEOUT_MS,
),
);
});
});
test("real Git creation and reconciliation preserve branch and HEAD identity", async () => {
await withTempDir(async (repository) => {
const git = (args: string[]) =>
execFileAsync("git", args, { cwd: repository, encoding: "utf8" });
await git(["init", "-b", "main"]);
await git(["config", "user.email", "workflow@example.invalid"]);
await git(["config", "user.name", "Pi Workflow Test"]);
await writeFile(
path.join(repository, ".gitignore"),
".worktrees/\n",
"utf8",
);
await writeFile(path.join(repository, "README.md"), "# Fixture\n", "utf8");
await git(["add", ".gitignore", "README.md"]);
await git(["commit", "-m", "initial fixture"]);
const manager = new GitWorkspaceManager();
const plan = await manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "real-feature",
runId: "run-1",
});
const workspace = await manager.create(plan);
assert.equal(workspace.state, "ready");
assert.equal(
(
await manager.reconcile({
...workspace,
state: "creating",
})
).state,
"ready",
);
const branch = await execFileAsync("git", ["branch", "--show-current"], {
cwd: plan.worktreePath,
encoding: "utf8",
});
assert.equal(branch.stdout.trim(), "feature/real-feature");
});
});
test("dirty sources require approval with bounded evidence while conflicts remain blocked", async () => {
await withTempDir(async (repository) => {
const dirty = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { dirty: true })),
);
await assert.rejects(
dirty.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) => {
assert.ok(error instanceof WorkspaceValidationError);
assert.equal(error.code, "dirty-source");
assert.deepEqual(error.sourceChanges, [
{ path: "src/file.ts", status: " M" },
{ path: "src/new-file.ts", status: "??" },
]);
assert.equal(error.sourceChangesOmitted, 0);
return true;
},
);
const allowed = await dirty.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
});
assert.equal(allowed.baseHead, "abc123");
assert.equal(allowed.dirtySourceAllowed, true);
assert.equal(allowed.sourceChanges.length, 2);
const unmerged = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { unmerged: true })),
);
await assert.rejects(
unmerged.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "unmerged-source",
);
const unignored = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { ignored: false })),
);
await assert.rejects(
unignored.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "unignored-root",
);
});
});
test("workspace preparation rejects a symlinked worktree root", async () => {
await withTempDir(async (repository) => {
const outside = await mkdtemp(path.join(tmpdir(), "workflow-outside-"));
try {
await symlink(outside, path.join(repository, ".worktrees"));
const manager = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository)),
);
await assert.rejects(
manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "unignored-root" &&
/symlink/.test(error.message),
);
} finally {
await rm(outside, { recursive: true, force: true });
}
});
});
test("workspace preparation reports branch collisions and existing paths", async () => {
await withTempDir(async (repository) => {
const manager = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { branchExists: true })),
);
const plan = await manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
});
assert.equal(plan.branchExists, true);
await mkdir(plan.worktreePath, { recursive: true });
await assert.rejects(
manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "existing-worktree-path",
);
});
});
test("interrupted workspace creation reconciles only an exact path and branch", async () => {
await withTempDir(async (repository) => {
const root = path.join(repository, ".worktrees", "run-1");
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
const manager = new GitWorkspaceManager(
new FakeRunner((args) =>
args.join(" ") === "worktree list --porcelain"
? result(0, listed)
: result(1, "", "unexpected"),
),
);
const workspace = {
state: "creating" as const,
sourceRoot: repository,
root,
branch: "feature/feature",
baseHead: "abc123",
};
assert.equal((await manager.reconcile(workspace)).state, "ready");
assert.equal(
(
await manager.reconcile({
...workspace,
branch: "feature/other",
})
).state,
"failed",
);
assert.equal(
(await manager.reconcile({ ...workspace, baseHead: "different" })).state,
"failed",
);
assert.equal(
(await manager.reconcile(workspace, path.join(repository, "other")))
.state,
"failed",
);
const absent = new GitWorkspaceManager(new FakeRunner(() => result(0, "")));
assert.equal((await absent.reconcile(workspace)).state, "creating");
});
});
test("uncertain worktree-add results are reconciled before returning", async () => {
await withTempDir(async (repository) => {
const root = path.join(repository, ".worktrees", "run-1");
const plan = {
sourceRoot: repository,
worktreeRoot: path.dirname(root),
worktreePath: root,
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
const partial = new GitWorkspaceManager(
new FakeRunner((args) => {
if (args[0] === "show-ref") return result(1);
return args[0] === "worktree" && args[1] === "add"
? result(128, "", "interrupted")
: result(0, listed);
}),
);
assert.equal((await partial.create(plan)).state, "ready");
const absent = new GitWorkspaceManager(
new FakeRunner((args) => {
if (args[0] === "show-ref") return result(1);
return args[0] === "worktree" && args[1] === "add"
? result(128, "", "interrupted")
: result(0, "");
}),
);
const uncertain = await absent.create(plan);
assert.equal(uncertain.state, "creating");
assert.match(uncertain.error ?? "", /returned 128/);
});
});
test("retry adopts an exact orphan branch left by interrupted creation", async () => {
await withTempDir(async (repository) => {
const root = path.join(repository, ".worktrees", "run-1");
const runner = new FakeRunner((args) => {
if (args[0] === "show-ref") return result(0);
if (args[0] === "rev-parse") return result(0, "abc123\n");
if (args[0] === "worktree" && args[1] === "add") return result(0);
return result(1, "", "unexpected");
});
const manager = new GitWorkspaceManager(runner);
const plan = {
sourceRoot: repository,
worktreeRoot: path.dirname(root),
worktreePath: root,
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
assert.equal((await manager.create(plan)).state, "ready");
assert.deepEqual(runner.calls.at(-1)?.args, [
"worktree",
"add",
root,
"feature/feature",
]);
const mismatch = new GitWorkspaceManager(
new FakeRunner((args) =>
args[0] === "show-ref" ? result(0) : result(0, "different\n"),
),
);
assert.equal((await mismatch.create(plan)).state, "failed");
});
});