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) { 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"), "
Prototype
", "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(() => {}); }, }); 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"); }); });