import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { detectVcs, createIsolatedWorkspace, cleanupWorkspace, getVcsInfo, adaptWorktreeSpawnCommand } from "../src/workspace.mjs"; import { CodexAppServer } from "../src/app-server.mjs"; import { MockBackendFactory } from "../src/backend/mock.mjs"; const exec = promisify(execFile); test("detectVcs returns none for plain directory", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "plain-dir-")); try { const vcs = await detectVcs(tmp); assert.equal(vcs.type, "none"); assert.equal(vcs.root, tmp); } finally { await fs.rm(tmp, { recursive: true, force: true }); } }); test("detectVcs and createIsolatedWorkspace handle git repositories", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "git-repo-")); const wsBase = await fs.mkdtemp(path.join(os.tmpdir(), "git-ws-base-")); try { await exec("git", ["init", tmp]); await exec("git", ["-C", tmp, "config", "user.name", "Test"]); await exec("git", ["-C", tmp, "config", "user.email", "test@example.com"]); await exec("git", ["-C", tmp, "config", "commit.gpgsign", "false"]); await fs.writeFile(path.join(tmp, "file.txt"), "hello"); await exec("git", ["-C", tmp, "add", "file.txt"]); await exec("git", ["-C", tmp, "commit", "-m", "init"]); const vcs = await detectVcs(tmp); assert.equal(vcs.type, "git"); const info = await getVcsInfo(tmp); assert.ok(info); assert.ok(info.sha); const ws = await createIsolatedWorkspace({ threadId: "test_thread_git", sourceCwd: tmp, isolationMode: "git", baseDir: wsBase, }); assert.equal(ws.isolated, true); assert.equal(ws.type, "git"); assert.ok(ws.cwd.startsWith(wsBase)); // Verify worktree has the file const content = await fs.readFile(path.join(ws.cwd, "file.txt"), "utf8"); assert.equal(content, "hello"); // Clean up await cleanupWorkspace(ws); const exists = await fs.access(ws.cwd).then(() => true).catch(() => false); assert.equal(exists, false); } finally { await fs.rm(tmp, { recursive: true, force: true }); await fs.rm(wsBase, { recursive: true, force: true }); } }); test("detectVcs and createIsolatedWorkspace handle jj repositories", async () => { let jjAvailable = false; try { await exec("jj", ["--version"]); jjAvailable = true; } catch {} if (!jjAvailable) return; const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "jj-repo-")); const wsBase = await fs.mkdtemp(path.join(os.tmpdir(), "jj-ws-base-")); try { await exec("jj", ["git", "init", tmp]); await fs.writeFile(path.join(tmp, "jj_file.txt"), "hello jj"); const vcs = await detectVcs(tmp); assert.equal(vcs.type, "jj"); const info = await getVcsInfo(tmp); assert.ok(info); assert.ok(info.sha); const ws = await createIsolatedWorkspace({ threadId: "test_thread_jj", sourceCwd: tmp, isolationMode: "jj", baseDir: wsBase, }); assert.equal(ws.isolated, true); assert.equal(ws.type, "jj"); assert.ok(ws.cwd.startsWith(wsBase)); // Clean up await cleanupWorkspace(ws); const exists = await fs.access(ws.cwd).then(() => true).catch(() => false); assert.equal(exists, false); } finally { await fs.rm(tmp, { recursive: true, force: true }); await fs.rm(wsBase, { recursive: true, force: true }); } }); test("CodexAppServer creates isolated workspace on thread/start with isolation", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "appserver-ws-")); const stateFile = path.join(tmp, "state.json"); const repoDir = path.join(tmp, "repo"); await fs.mkdir(repoDir); await exec("git", ["init", repoDir]); await exec("git", ["-C", repoDir, "config", "user.name", "Test"]); await exec("git", ["-C", repoDir, "config", "user.email", "test@example.com"]); await exec("git", ["-C", repoDir, "config", "commit.gpgsign", "false"]); await fs.writeFile(path.join(repoDir, "test.txt"), "base"); await exec("git", ["-C", repoDir, "add", "test.txt"]); await exec("git", ["-C", repoDir, "commit", "-m", "initial"]); const server = new CodexAppServer({ backendFactory: new MockBackendFactory(), stateFile, cwd: repoDir, }); const messages = []; const peer = server.createPeer((msg) => messages.push(msg)); await server.handle(peer, { id: 1, method: "initialize", params: { clientInfo: { name: "test", version: "1" } } }); await server.handle(peer, { method: "initialized", params: {} }); await server.handle(peer, { id: 2, method: "thread/start", params: { cwd: repoDir, workspaceIsolation: "git", }, }); const startRes = messages.find((m) => m.id === 2); const thread = startRes.result.thread; assert.ok(thread); assert.ok(thread.workspace); assert.equal(thread.workspace.isolated, true); assert.equal(thread.workspace.type, "git"); assert.notEqual(thread.cwd, repoDir); // List threads with repoDir should match the isolated thread await server.handle(peer, { id: 3, method: "thread/list", params: { cwd: repoDir }, }); const listRes = messages.find((m) => m.id === 3); assert.equal(listRes.result.data.length, 1); assert.equal(listRes.result.data[0].id, thread.id); // Hooks list with cwds array await server.handle(peer, { id: 4, method: "hooks/list", params: { cwds: [repoDir, thread.workspace.cwd] }, }); const hooksRes = messages.find((m) => m.id === 4); assert.equal(hooksRes.result.data.length, 2); assert.equal(hooksRes.result.data[0].cwd, path.resolve(repoDir)); // Delete thread cleans up workspace const wsPath = thread.workspace.cwd; await server.handle(peer, { id: 5, method: "thread/delete", params: { threadId: thread.id } }); const exists = await fs.access(wsPath).then(() => true).catch(() => false); assert.equal(exists, false); await fs.rm(tmp, { recursive: true, force: true }); }); test("adaptWorktreeSpawnCommand wraps worktree creation and removal scripts", () => { const createCmd = ["/bin/sh", "-lc", 'git_root="."\nworktree_git_root="wt"\ngit worktree add --detach "$worktree_git_root" "$starting_ref"']; const adaptedCreate = adaptWorktreeSpawnCommand(createCmd, {}); assert.equal(adaptedCreate[0], "/bin/sh"); assert.ok(adaptedCreate[2].includes("jj workspace add")); assert.ok(adaptedCreate[2].includes("_real_git")); // Direct git worktree array commands are wrapped through /bin/sh const directCmd = ["git", "-C", "/path/to/repo", "worktree", "add", "--detach", "/tmp/wt", "HEAD"]; const adaptedDirect = adaptWorktreeSpawnCommand(directCmd, {}); assert.equal(adaptedDirect[0], "/bin/sh"); assert.equal(adaptedDirect[1], "-c"); assert.ok(adaptedDirect[2].includes("jj workspace add")); assert.equal(adaptedDirect[3], "git"); assert.equal(adaptedDirect[4], "-C"); assert.equal(adaptedDirect[5], "/path/to/repo"); // Regular commands are completely untouched const normalCmd = ["/bin/sh", "-lc", "cargo build --release"]; const untouched = adaptWorktreeSpawnCommand(normalCmd, {}); assert.equal(untouched[2], "cargo build --release"); const normalBin = ["cargo", "build", "--release"]; const untouchedBin = adaptWorktreeSpawnCommand(normalBin, {}); assert.deepEqual(untouchedBin, normalBin); });