Files
my-pi/pi-worktree/test/setup.test.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 97c2e75dfe feat(pi-worktree): 新增斜杠命令 git worktree 管理扩展
提供 /wt(别名 /worktree)一键拆分/合并:
- /wt new 建 worktree 后经 SessionManager.forkFrom + switchSession
  迁移会话 cwd,历史上下文保留;合并/切换同理
- /wt merge 先确认再 rebase 到来源分支、默认 --ff-only(可选
  --squash),来源改动用 stash 兜底,冲突可中止或保留后用
  /wt merge --continue / --abort 处理,永不 push
- 来源 checkout 的 .pi/worktree.json 声明环境复刻(copy/link/env/
  postCreate),确认框先展示计划再执行;postCreate 仅在项目已信任
  时以 argv 形式 execFile,失败可 /wt reopen 增量重跑
- 登记文件 <agentDir>/pi-worktree/state.json 原子写入并加锁
  串行化;会话切换后向模型注入 cwd 变更提示;未被 .gitignore
  覆盖的软链/环境文件写入 .git/info/exclude 防止误提交
- 仅注册用户显式触发的斜杠命令,不暴露模型可调用工具

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 17:45:41 -07:00

359 lines
14 KiB
TypeScript

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<string> {
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<void> {
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<string> {
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")));
});