import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, setWorktreeIsolationEnabled, } from "../src/worktree.js"; /** * Helper: create a temporary git repo with an initial commit. */ function initGitRepo(): string { const dir = mkdtempSync(join(tmpdir(), "pi-wt-test-")); execFileSync("git", ["init"], { cwd: dir, stdio: "pipe" }); execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: dir, stdio: "pipe" }); execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, stdio: "pipe" }); writeFileSync(join(dir, "README.md"), "# Test repo"); execFileSync("git", ["add", "README.md"], { cwd: dir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "initial"], { cwd: dir, stdio: "pipe" }); return dir; } describe("worktree", () => { let repoDir: string; beforeEach(() => { repoDir = initGitRepo(); }); afterEach(() => { // Clean up any lingering worktrees first, then remove repo try { pruneWorktrees(repoDir); } catch { /* ignore */ } rmSync(repoDir, { recursive: true, force: true }); }); describe("createWorktree", () => { it("creates a worktree in tmpdir", () => { const wt = createWorktree(repoDir, "test-id-1"); expect(wt).toBeDefined(); expect(existsSync(wt!.path)).toBe(true); expect(wt!.branch).toBe("pi-agent-test-id-1"); expect(wt!.baseSha).toBe(execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe", }).toString().trim()); // Verify it's a valid worktree with the repo's files expect(existsSync(join(wt!.path, "README.md"))).toBe(true); // Cleanup try { execFileSync("git", ["worktree", "remove", "--force", wt!.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); it("returns undefined for non-git directory", () => { const nonGit = mkdtempSync(join(tmpdir(), "pi-wt-nongit-")); try { const wt = createWorktree(nonGit, "test-id-2"); expect(wt).toBeUndefined(); } finally { rmSync(nonGit, { recursive: true, force: true }); } }); it("returns undefined for git repo with no commits", () => { const emptyRepo = mkdtempSync(join(tmpdir(), "pi-wt-empty-")); try { execFileSync("git", ["init"], { cwd: emptyRepo, stdio: "pipe" }); const wt = createWorktree(emptyRepo, "no-commits"); expect(wt).toBeUndefined(); } finally { rmSync(emptyRepo, { recursive: true, force: true }); } }); it("workPath equals path when created from the repo root", () => { const wt = createWorktree(repoDir, "root-wp")!; expect(wt.workPath).toBe(wt.path); try { execFileSync("git", ["worktree", "remove", "--force", wt.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); it("workPath preserves subdirectory scoping (monorepo package cwd)", () => { mkdirSync(join(repoDir, "packages", "api"), { recursive: true }); writeFileSync(join(repoDir, "packages", "api", "index.ts"), "export {}"); execFileSync("git", ["add", "-A"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "add package"], { cwd: repoDir, stdio: "pipe" }); const wt = createWorktree(join(repoDir, "packages", "api"), "subdir-wp")!; expect(wt).toBeDefined(); expect(wt.workPath).toBe(join(wt.path, "packages", "api")); expect(existsSync(wt.workPath)).toBe(true); try { execFileSync("git", ["worktree", "remove", "--force", wt.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); it("uses unique paths for multiple worktrees", () => { const wt1 = createWorktree(repoDir, "multi-1"); const wt2 = createWorktree(repoDir, "multi-2"); expect(wt1).toBeDefined(); expect(wt2).toBeDefined(); expect(wt1!.path).not.toBe(wt2!.path); // Cleanup try { execFileSync("git", ["worktree", "remove", "--force", wt1!.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } try { execFileSync("git", ["worktree", "remove", "--force", wt2!.path], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); }); describe("cleanupWorktree", () => { it("removes worktree when no changes made", () => { const wt = createWorktree(repoDir, "clean-1")!; expect(wt).toBeDefined(); const result = cleanupWorktree(repoDir, wt, "test cleanup"); expect(result.hasChanges).toBe(false); expect(result.branch).toBeUndefined(); }); it("commits changes and creates branch when changes exist", () => { const wt = createWorktree(repoDir, "dirty-1")!; expect(wt).toBeDefined(); // Make a change in the worktree writeFileSync(join(wt.path, "new-file.txt"), "agent wrote this"); const result = cleanupWorktree(repoDir, wt, "added new file"); expect(result.hasChanges).toBe(true); expect(result.branch).toBeDefined(); expect(result.branch).toContain("pi-agent-dirty-1"); // Verify the branch exists in the main repo const branches = execFileSync("git", ["branch", "--list", result.branch!], { cwd: repoDir, stdio: "pipe", }).toString().trim(); expect(branches).toContain(result.branch!); // Verify the commit message const log = execFileSync("git", ["log", "--oneline", "-1", result.branch!], { cwd: repoDir, stdio: "pipe", }).toString().trim(); expect(log).toContain("pi-agent: added new file"); // Cleanup branch try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); it("commits changes even when a pre-commit hook rejects (--no-verify)", () => { // A failing pre-commit hook in the main repo also applies to its // worktrees — without --no-verify it would abort the preservation commit. const hookPath = join(repoDir, ".git", "hooks", "pre-commit"); writeFileSync(hookPath, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); const wt = createWorktree(repoDir, "hooked-1")!; expect(wt).toBeDefined(); writeFileSync(join(wt.path, "hooked-file.txt"), "agent wrote this"); const result = cleanupWorktree(repoDir, wt, "hook should not block"); expect(result.hasChanges).toBe(true); expect(result.branch).toBe("pi-agent-hooked-1"); // Cleanup branch try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); it("creates branch when worktree is clean but HEAD moved", () => { const wt = createWorktree(repoDir, "committed-1")!; expect(wt).toBeDefined(); writeFileSync(join(wt.path, "committed-file.txt"), "agent committed this"); execFileSync("git", ["add", "committed-file.txt"], { cwd: wt.path, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "agent commit"], { cwd: wt.path, stdio: "pipe" }); const agentCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: wt.path, stdio: "pipe", }).toString().trim(); const result = cleanupWorktree(repoDir, wt, "already committed"); expect(result.hasChanges).toBe(true); expect(result.branch).toBeDefined(); expect(result.branch).toBe("pi-agent-committed-1"); const branchCommit = execFileSync("git", ["rev-parse", result.branch!], { cwd: repoDir, stdio: "pipe", }).toString().trim(); expect(branchCommit).toBe(agentCommit); expect(existsSync(wt.path)).toBe(false); // Cleanup branch try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); it("does not force-overwrite existing branch", () => { // Create first worktree, make changes, cleanup → creates branch const wt1 = createWorktree(repoDir, "conflict-1")!; writeFileSync(join(wt1.path, "file1.txt"), "first run"); const result1 = cleanupWorktree(repoDir, wt1, "first"); expect(result1.branch).toBe("pi-agent-conflict-1"); // Create second worktree with same agent ID, make changes const wt2 = createWorktree(repoDir, "conflict-1")!; writeFileSync(join(wt2.path, "file2.txt"), "second run"); const result2 = cleanupWorktree(repoDir, wt2, "second"); // Should use a different branch name (timestamp suffix) expect(result2.hasChanges).toBe(true); expect(result2.branch).toBeDefined(); expect(result2.branch).not.toBe("pi-agent-conflict-1"); expect(result2.branch).toContain("pi-agent-conflict-1-"); // Both branches should exist const branches = execFileSync("git", ["branch", "--list", "pi-agent-conflict-1*"], { cwd: repoDir, stdio: "pipe", }).toString().trim(); expect(branches).toContain("pi-agent-conflict-1"); expect(branches).toContain(result2.branch!); // Cleanup try { execFileSync("git", ["branch", "-D", result1.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } try { execFileSync("git", ["branch", "-D", result2.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); it("handles already-deleted worktree gracefully", () => { const wt = createWorktree(repoDir, "gone-1")!; // Manually delete the worktree directory rmSync(wt.path, { recursive: true, force: true }); const result = cleanupWorktree(repoDir, wt, "already gone"); expect(result.hasChanges).toBe(false); }); it("truncates commit message at 200 chars", () => { const wt = createWorktree(repoDir, "long-msg")!; writeFileSync(join(wt.path, "change.txt"), "something"); const longDesc = "x".repeat(300); const result = cleanupWorktree(repoDir, wt, longDesc); expect(result.hasChanges).toBe(true); const log = execFileSync("git", ["log", "--oneline", "-1", result.branch!], { cwd: repoDir, stdio: "pipe", }).toString().trim(); // "pi-agent: " prefix (10 chars) + 200 chars of x = 210 total max expect(log.length).toBeLessThanOrEqual(220); // some slack for hash prefix // Cleanup try { execFileSync("git", ["branch", "-D", result.branch!], { cwd: repoDir, stdio: "pipe" }); } catch { /* ignore */ } }); }); describe("pruneWorktrees", () => { it("does not throw on a clean repo", () => { expect(() => pruneWorktrees(repoDir)).not.toThrow(); }); it("does not throw on non-git directory", () => { const nonGit = mkdtempSync(join(tmpdir(), "pi-wt-nongit-")); try { expect(() => pruneWorktrees(nonGit)).not.toThrow(); } finally { rmSync(nonGit, { recursive: true, force: true }); } }); }); }); // cleanupWorktree's outer catch is the only place in the repo where a caught // error can DESTROY user work while reporting success-shaped output: it removes // the worktree and returns `{ hasChanges: false }`, which the manager renders as // "the agent changed nothing". If the commit or branch step fails, the agent's // commits go with the worktree and nobody is told. describe("cleanupWorktree — failure path", () => { let repoDir: string; beforeEach(() => { repoDir = initGitRepo(); }); afterEach(() => { try { pruneWorktrees(repoDir); } catch { /* ignore */ } rmSync(repoDir, { recursive: true, force: true }); }); it("short-circuits when the worktree directory is already gone", () => { // Hits the existsSync guard at the top of cleanupWorktree, not the outer // catch — cleanup can be called twice (settle path plus dispose), so it has // to be idempotent rather than throw on the second call. const wt = createWorktree(repoDir, "vanished")!; expect(wt).toBeDefined(); rmSync(wt.path, { recursive: true, force: true }); const result = cleanupWorktree(repoDir, wt, "agent that vanished"); expect(result.hasChanges).toBe(false); expect(result.branch).toBeUndefined(); }); it("swallows a git failure inside a still-present worktree and reports no changes", () => { // The outer catch. The directory exists — so the existsSync guard above // does not fire — but git cannot operate in it, which is what a corrupted // or externally-detached worktree looks like. The agent's work is lost // either way; what matters is that cleanup does not throw out of the // manager's settle path and take the whole record down with it. const wt = createWorktree(repoDir, "corrupt")!; writeFileSync(join(wt.path, "work.txt"), "agent output"); // Break the worktree's link back to the repo. writeFileSync(join(wt.path, ".git"), "gitdir: /nonexistent/path/that/is/not/a/repo"); const result = cleanupWorktree(repoDir, wt, "corrupted agent"); expect(result.hasChanges).toBe(false); expect(result.branch).toBeUndefined(); }); it("creates the branch BEFORE removing the worktree, so a removal failure cannot lose commits", () => { // Ordering is the actual safety property. If a refactor moved // removeWorktree above the `git branch` call, the commits would be // unreachable the moment removal succeeded and branching failed. const wt = createWorktree(repoDir, "ordered")!; writeFileSync(join(wt.path, "work.txt"), "agent output"); const result = cleanupWorktree(repoDir, wt, "ordered agent"); expect(result.hasChanges).toBe(true); expect(result.branch).toBeDefined(); // The branch must exist in the MAIN repo after the worktree is gone — // that is what makes the agent's work recoverable. const branches = execFileSync("git", ["branch", "--list", result.branch!], { cwd: repoDir, stdio: "pipe", }).toString(); expect(branches).toContain(result.branch!); expect(existsSync(wt.path)).toBe(false); // And the commit is reachable from that branch. const files = execFileSync("git", ["ls-tree", "--name-only", result.branch!], { cwd: repoDir, stdio: "pipe", }).toString(); expect(files).toContain("work.txt"); }); }); /** * The project switch itself (`worktreeIsolation`, #184). Its consumers — * agent-manager, both tool schemas, the invocation resolver — all mock this * module, so without this block the real singleton is never executed and its * default is never exercised. That default is what every "worktree isolation * still behaves as before" claim rests on. */ describe("worktree isolation switch", () => { afterEach(() => setWorktreeIsolationEnabled(true)); it("defaults to enabled", () => { expect(isWorktreeIsolationEnabled()).toBe(true); }); it("round-trips both ways", () => { setWorktreeIsolationEnabled(false); expect(isWorktreeIsolationEnabled()).toBe(false); setWorktreeIsolationEnabled(true); expect(isWorktreeIsolationEnabled()).toBe(true); }); // The switch gates callers; it deliberately does not disarm createWorktree // itself, so a caller that has already decided (agent-manager checks first) // still gets a real worktree rather than a silent no-op. it("does not disable createWorktree directly", () => { const repoDir = initGitRepo(); try { setWorktreeIsolationEnabled(false); const wt = createWorktree(repoDir, "switch-test"); expect(wt).toBeDefined(); cleanupWorktree(repoDir, wt!, "switch test"); } finally { pruneWorktrees(repoDir); rmSync(repoDir, { recursive: true, force: true }); } }); });