923 lines
30 KiB
TypeScript
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(), "pi-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", "pi-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", "pi-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(), "pi-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");
|
|
});
|
|
});
|