import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, readlink, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { createGit } from "../src/git.ts"; import type { WorktreeEntry } from "../src/state.ts"; import { applySetup, expandPattern, formatPlan, fnv1a32, loadSetupConfig, parseSetupConfig, planSetup, worktreePort, } from "../src/setup.ts"; const git = createGit(); const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: "pi-worktree-test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "pi-worktree-test", GIT_COMMITTER_EMAIL: "test@example.com", }; function raw(args: string[], cwd: string): string { return execFileSync("git", args, { cwd, env: GIT_ENV, encoding: "utf8" }); } async function makeRepo(): Promise { const dir = await mkdtemp(join(tmpdir(), "pi-worktree-setup-")); raw(["init", "-b", "main"], dir); raw(["config", "user.name", "pi-worktree-test"], dir); raw(["config", "user.email", "test@example.com"], dir); await writeFile(join(dir, "file.txt"), "base\n", "utf8"); raw(["add", "-A"], dir); raw(["commit", "-m", "init"], dir); return dir; } function makeEntry(sourcePath: string, worktreePath: string): WorktreeEntry { return { slug: "feat-x", path: worktreePath, branch: "wt/feat-x", sourcePath, sourceBranch: "main", sourceSha: "0".repeat(40), createdAt: new Date().toISOString(), }; } async function writeConfig(dir: string, config: object): Promise { await mkdir(join(dir, ".pi"), { recursive: true }); await writeFile(join(dir, ".pi", "worktree.json"), JSON.stringify(config), "utf8"); } test("loadSetupConfig: absent file yields undefined", async () => { const repo = await makeRepo(); assert.equal(await loadSetupConfig(repo), undefined); }); test("config validation rejects bad input with clear errors", async () => { const p = "/x/.pi/worktree.json"; assert.throws(() => parseSetupConfig("{oops", p), /不是合法 JSON/u); assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, nope: 1 }), p), /未知键/u); assert.throws(() => parseSetupConfig(JSON.stringify({ version: 2 }), p), /version/u); assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, copy: ["a/**/b"] }), p), /'\*\*'/u); assert.throws( () => parseSetupConfig(JSON.stringify({ version: 1, copy: ["dir*/file"] }), p), /最后一段/u, ); assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, copy: ["/abs"] }), p), /相对路径/u); assert.throws(() => parseSetupConfig(JSON.stringify({ version: 1, copy: ["a/../b"] }), p), /\.\./u); assert.throws( () => parseSetupConfig(JSON.stringify({ version: 1, env: { A: 1 } }), p), /env\.A 必须是字符串/u, ); assert.throws( () => parseSetupConfig(JSON.stringify({ version: 1, postCreate: [{ args: [] }] }), p), /command/u, ); assert.throws( () => parseSetupConfig( JSON.stringify({ version: 1, postCreate: [{ command: "x", args: [1] }] }), p, ), /args/u, ); assert.throws( () => parseSetupConfig( JSON.stringify({ version: 1, postCreate: [{ command: "x", timeoutMs: -1 }] }), p, ), /timeoutMs/u, ); }); test("expandPattern matches wildcards only in the last segment", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-worktree-expand-")); await writeFile(join(dir, ".env.local"), "a", "utf8"); await writeFile(join(dir, ".env.dev.local"), "b", "utf8"); await writeFile(join(dir, ".env.prod.local"), "c", "utf8"); await mkdir(join(dir, "sub")); await writeFile(join(dir, "sub", "a1.txt"), "x", "utf8"); await writeFile(join(dir, "sub", "a22.txt"), "y", "utf8"); assert.deepEqual(await expandPattern(dir, ".env.*.local"), [".env.dev.local", ".env.prod.local"]); assert.deepEqual(await expandPattern(dir, "sub/a?.txt"), ["sub/a1.txt"]); assert.deepEqual(await expandPattern(dir, "file.txt"), []); await writeFile(join(dir, "file.txt"), "z", "utf8"); assert.deepEqual(await expandPattern(dir, "file.txt"), ["file.txt"]); assert.deepEqual(await expandPattern(dir, "missing/*.txt"), []); }); test("planSetup skips tracked copies and non-ignored links", async () => { const repo = await makeRepo(); const wt = join(repo, "..", "wt-out"); await mkdir(wt, { recursive: true }); await writeFile(join(repo, ".env.local"), "SECRET=1\n", "utf8"); await writeFile(join(repo, "tracked.txt"), "t\n", "utf8"); raw(["add", "tracked.txt"], repo); raw(["commit", "-m", "track"], repo); await mkdir(join(repo, "node_modules")); await writeFile(join(repo, "node_modules", "pkg.js"), "m", "utf8"); await mkdir(join(repo, "notignored")); await writeFile(join(repo, "notignored", "f"), "n", "utf8"); await writeFile(join(repo, ".gitignore"), "node_modules/\n.env.local\n.env.worktree\n", "utf8"); // .gitignore itself untracked is fine for check-ignore. const entry = makeEntry(repo, wt); const config = parseSetupConfig( JSON.stringify({ version: 1, copy: [".env.local", "tracked.txt", "missing.txt"], link: ["node_modules", "notignored"], env: { PORT: "{{port}}", DB_NAME: "app_{{slug}}" }, }), "/cfg", ); const plan = await planSetup(git, config, entry, true); assert.deepEqual(plan.copies.map((c) => c.rel), [".env.local"]); assert.deepEqual(plan.links.map((l) => l.rel), ["node_modules"]); assert.ok(plan.notes.some((n) => n.includes("tracked.txt") && n.includes("跟踪"))); assert.ok(plan.notes.some((n) => n.includes("notignored") && n.includes("gitignore"))); assert.equal(plan.envFile?.rel, ".env.worktree"); const expectedPort = 3000 + (fnv1a32("feat-x") % 1000); assert.ok(plan.envFile?.content.includes(`PORT=${expectedPort}`)); assert.ok(plan.envFile?.content.includes("DB_NAME=app_feat-x")); // Deterministic across calls. const again = await planSetup(git, config, entry, true); assert.equal(again.envFile?.content, plan.envFile.content); assert.equal(worktreePort(3000, "feat-x"), expectedPort); }); test("planSetup rejects unknown template variables", async () => { const repo = await makeRepo(); const config = parseSetupConfig( JSON.stringify({ version: 1, env: { A: "{{bogus}}" } }), "/cfg", ); await assert.rejects(() => planSetup(git, config, makeEntry(repo, "/tmp/wt-nowhere"), true), /未知模板变量/u); }); test("planSetup skips postCreate when the project is untrusted", async () => { const repo = await makeRepo(); const config = parseSetupConfig( JSON.stringify({ version: 1, postCreate: [{ command: "echo", args: ["hi"] }] }), "/cfg", ); const plan = await planSetup(git, config, makeEntry(repo, "/tmp/wt-nowhere"), false); assert.equal(plan.postCreate.length, 0); assert.ok(plan.notes.some((n) => n.includes("未受信任"))); assert.ok(formatPlan(plan).includes("未受信任")); }); test("applySetup copies files with modes, links dirs, writes env, runs steps", async () => { const repo = await makeRepo(); // Commit .gitignore (no trailing slash) so the real worktree inherits it // and the artifacts are already ignored — no info/exclude writes here. await writeFile(join(repo, ".gitignore"), "node_modules\n.env.worktree\n", "utf8"); raw(["add", ".gitignore"], repo); raw(["commit", "-m", "gitignore"], repo); const wt = await makeRealWorktree(repo, "wt-apply"); await writeFile(join(repo, ".env.local"), "SECRET=1\n", { mode: 0o600 }); await chmod(join(repo, ".env.local"), 0o600); await mkdir(join(repo, "node_modules")); const entry = makeEntry(repo, wt); const config = parseSetupConfig( JSON.stringify({ version: 1, copy: [".env.local"], link: ["node_modules"], env: { HELLO: "world", SLUG: "{{slug}}" }, postCreate: [ { command: process.execPath, args: [ "-e", "require('fs').writeFileSync('marker.txt', process.env.PI_WT_SLUG + ':' + process.env.PI_WT_PORT)", ], }, { command: process.execPath, args: ["-e", "require('fs').writeFileSync('second.txt','ok')"] }, ], }), "/cfg", ); const plan = await planSetup(git, config, entry, true); const steps: string[] = []; const result = await applySetup(git, plan, entry, config.portBase, { onStep: (t) => steps.push(t) }); assert.deepEqual(result, { status: "ok" }); assert.equal(await readFile(join(wt, ".env.local"), "utf8"), "SECRET=1\n"); assert.equal((await stat(join(wt, ".env.local"))).mode & 0o777, 0o600); assert.equal(await readlink(join(wt, "node_modules")), join(repo, "node_modules")); const envContent = await readFile(join(wt, ".env.worktree"), "utf8"); assert.ok(envContent.includes("HELLO=world")); assert.ok(envContent.includes("SLUG=feat-x")); assert.equal((await stat(join(wt, ".env.worktree"))).mode & 0o777, 0o600); const marker = await readFile(join(wt, "marker.txt"), "utf8"); const [slug, port] = marker.split(":"); assert.equal(slug, "feat-x"); assert.equal(Number(port), worktreePort(3000, "feat-x")); assert.ok(existsSync(join(wt, "second.txt"))); assert.deepEqual( steps.filter((s) => s.startsWith("正在运行")).length, 2, ); assert.ok(steps[0]?.startsWith("正在运行")); // A second plan+apply skips existing artifacts (wx/exclusive semantics). const plan2 = await planSetup(git, config, entry, true); assert.equal(plan2.copies.length, 0); assert.equal(plan2.links.length, 0); assert.equal(plan2.envFile, undefined); }); test("applySetup stops at the first failing postCreate step", async () => { const repo = await makeRepo(); const wt = await mkdtemp(join(tmpdir(), "pi-worktree-apply-fail-")); const entry = makeEntry(repo, wt); const config = parseSetupConfig( JSON.stringify({ version: 1, postCreate: [ { command: process.execPath, args: ["-e", "process.exit(3)"] }, { command: process.execPath, args: ["-e", "require('fs').writeFileSync('never.txt','1')"] }, ], }), "/cfg", ); const plan = await planSetup(git, config, entry, true); const result = await applySetup(git, plan, entry, config.portBase, { onStep: () => undefined }); assert.equal(result.status, "failed"); assert.ok(result.step?.includes("process.exit(3)")); assert.equal(existsSync(join(wt, "never.txt")), false); }); async function makeRealWorktree(repo: string, name: string): Promise { const parent = await mkdtemp(join(tmpdir(), "pi-worktree-wt-")); const wt = join(parent, name); raw(["worktree", "add", "-b", `wt/${name}`, wt, "main"], repo); return wt; } function excludeFile(repo: string): string { const p = raw(["rev-parse", "--git-path", "info/exclude"], repo).trim(); return p.startsWith("/") ? p : join(repo, p); } test("applySetup excludes a symlink that a trailing-slash gitignore rule does not cover", async () => { const repo = await makeRepo(); // Commit the trailing-slash rule so the worktree has it; it still does not // match a symlink, so the exclusion path must fire. await writeFile(join(repo, ".gitignore"), "node_modules/\n", "utf8"); raw(["add", ".gitignore"], repo); raw(["commit", "-m", "gitignore"], repo); const wt = await makeRealWorktree(repo, "wt-trailing"); await mkdir(join(repo, "node_modules")); // Trailing-slash rule does not match the symlink inside the worktree. const entry = makeEntry(repo, wt); const config = parseSetupConfig(JSON.stringify({ version: 1, link: ["node_modules"] }), "/cfg"); const plan = await planSetup(git, config, entry, true); const steps: string[] = []; const result = await applySetup(git, plan, entry, config.portBase, { onStep: (t) => steps.push(t), }); assert.deepEqual(result, { status: "ok" }); raw(["check-ignore", "-q", "node_modules"], wt); const status = raw(["status", "--porcelain", "--untracked-files=all"], wt); assert.ok(!status.includes("node_modules"), status); const exclude = await readFile(excludeFile(repo), "utf8"); assert.ok(exclude.split("\n").includes("/node_modules"), exclude); assert.ok(steps.some((s) => s.includes("info/exclude"))); }); test("applySetup excludes a generated env file that is not gitignored", async () => { const repo = await makeRepo(); const wt = await makeRealWorktree(repo, "wt-envfile"); const entry = makeEntry(repo, wt); const config = parseSetupConfig(JSON.stringify({ version: 1, env: { A: "1" } }), "/cfg"); const plan = await planSetup(git, config, entry, true); const result = await applySetup(git, plan, entry, config.portBase, { onStep: () => undefined }); assert.deepEqual(result, { status: "ok" }); raw(["check-ignore", "-q", ".env.worktree"], wt); const status = raw(["status", "--porcelain", "--untracked-files=all"], wt); assert.ok(!status.includes(".env.worktree"), status); }); test("applySetup leaves info/exclude alone when artifacts are already ignored", async () => { const repo = await makeRepo(); await writeFile(join(repo, ".gitignore"), "node_modules\n.env.worktree\n", "utf8"); raw(["add", ".gitignore"], repo); raw(["commit", "-m", "gitignore"], repo); const wt = await makeRealWorktree(repo, "wt-ignored"); await mkdir(join(repo, "node_modules")); const entry = makeEntry(repo, wt); const config = parseSetupConfig( JSON.stringify({ version: 1, link: ["node_modules"], env: { A: "1" } }), "/cfg", ); const plan = await planSetup(git, config, entry, true); const steps: string[] = []; const result = await applySetup(git, plan, entry, config.portBase, { onStep: (t) => steps.push(t), }); assert.deepEqual(result, { status: "ok" }); let exclude = ""; try { exclude = await readFile(excludeFile(repo), "utf8"); } catch { exclude = ""; } assert.ok(!exclude.split("\n").includes("/node_modules"), exclude); assert.ok(!exclude.split("\n").includes("/.env.worktree"), exclude); assert.ok(!steps.some((s) => s.includes("info/exclude"))); });