Files

374 lines
15 KiB
TypeScript

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 });
}
});
});