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>
This commit is contained in:
云服务部-叶林立
2026-10-19 17:45:41 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent f237025cf9
commit 97c2e75dfe
22 changed files with 3195 additions and 1 deletions
+64
View File
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { argumentCompletions } from "../index.ts";
import { addEntry, updateState, type WorktreeEntry } from "../src/state.ts";
function entry(overrides: Partial<WorktreeEntry> = {}): WorktreeEntry {
return {
slug: "demo",
path: "/nonexistent/demo",
branch: "wt/demo",
sourcePath: "/nonexistent/source",
sourceBranch: "main",
sourceSha: "0".repeat(40),
createdAt: new Date().toISOString(),
...overrides,
};
}
test("first-token completion returns subcommand names", async () => {
const items = await argumentCompletions("mer");
assert.ok(items?.some((item) => item.value === "merge"));
});
test("merge flag completions expand to the full argument line", async () => {
const items = await argumentCompletions("merge --c");
assert.deepEqual(
items?.map((item) => ({ value: item.value, label: item.label })),
[{ value: "merge --continue", label: "--continue" }],
);
});
test("bare -- after merge offers all four flags as full lines", async () => {
const items = await argumentCompletions("merge --");
assert.equal(items?.length, 4);
for (const item of items ?? []) {
assert.ok(item.value.startsWith("merge --"), item.value);
}
});
test("slug completions keep the subcommand in the replacement value", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
await updateState((s) => addEntry(s, entry()));
const switchItems = await argumentCompletions("switch ");
const values = switchItems?.map((item) => item.value) ?? [];
assert.ok(values.includes("switch main"), JSON.stringify(values));
assert.ok(values.includes("switch demo"), JSON.stringify(values));
const removeItems = await argumentCompletions("remove d");
assert.deepEqual(
removeItems?.map((item) => ({ value: item.value, label: item.label })),
[{ value: "remove demo", label: "demo" }],
);
});
+175
View File
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, realpath, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createGit, GitError } from "../src/git.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" });
}
/** Creates <tmp>/repo as a git repo; sibling dirs under <tmp> host worktrees. */
async function makeRepo(): Promise<{ parent: string; repo: string }> {
const parent = await mkdtemp(join(tmpdir(), "pi-worktree-git-"));
const repo = join(parent, "repo");
await mkdir(repo);
raw(["init", "-b", "main"], repo);
raw(["config", "user.name", "pi-worktree-test"], repo);
raw(["config", "user.email", "test@example.com"], repo);
await writeFile(join(repo, "file.txt"), "base\n", "utf8");
raw(["add", "-A"], repo);
raw(["commit", "-m", "init"], repo);
return { parent, repo };
}
async function commitFile(dir: string, name: string, content: string, message: string): Promise<void> {
await writeFile(join(dir, name), content, "utf8");
raw(["add", "-A"], dir);
raw(["commit", "-m", message], dir);
}
async function worktreeOf(parent: string, repo: string, name: string): Promise<string> {
const path = join(parent, name);
await git.addWorktree(repo, path, `wt/${name}`, "HEAD");
return path;
}
test("addWorktree creates the directory and branch", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-a");
assert.ok(existsSync(wt));
assert.equal(await git.getCurrentBranch(wt), "wt/feat-a");
assert.ok(await git.branchExists(repo, "wt/feat-a"));
});
test("ff-only merge brings source HEAD to the worktree HEAD", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-b");
await commitFile(wt, "new.txt", "work\n", "wt commit");
const rebaseResult = await git.rebase(wt, "main");
assert.deepEqual(rebaseResult, { ok: true, conflict: false });
assert.equal(await git.aheadCount(wt, "main", "wt/feat-b"), 1);
await git.mergeFfOnly(repo, "wt/feat-b");
assert.equal(await git.getHeadSha(repo), await git.getHeadSha(wt));
});
test("rebase conflict stays in progress, lists unmerged files, and aborts cleanly", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-c");
await commitFile(wt, "file.txt", "worktree version\n", "wt edit");
await commitFile(repo, "file.txt", "source version\n", "source edit");
const before = await git.getHeadSha(wt);
const result = await git.rebase(wt, "main");
assert.deepEqual(result, { ok: false, conflict: true });
assert.equal(await git.rebaseInProgress(wt), true);
assert.deepEqual(await git.unmergedFiles(wt), ["file.txt"]);
await git.rebaseAbort(wt);
assert.equal(await git.rebaseInProgress(wt), false);
assert.equal(await git.getHeadSha(wt), before);
assert.ok(!(await git.isDirty(wt)));
});
test("rebaseContinue finishes once conflicts are resolved and added", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-c2");
await commitFile(wt, "file.txt", "worktree version\n", "wt edit");
await commitFile(repo, "file.txt", "source version\n", "source edit");
const result = await git.rebase(wt, "main");
assert.deepEqual(result, { ok: false, conflict: true });
assert.equal(await git.rebaseInProgress(wt), true);
await writeFile(join(wt, "file.txt"), "resolved\n", "utf8");
raw(["add", "file.txt"], wt);
const continued = await git.rebaseContinue(wt);
assert.deepEqual(continued, { ok: true, conflict: false });
assert.equal(await git.rebaseInProgress(wt), false);
assert.equal(await git.isAncestor(repo, "main", "wt/feat-c2"), true);
});
test("mergeSquash produces exactly one new commit on the source", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-d");
await commitFile(wt, "a.txt", "1\n", "c1");
await commitFile(wt, "b.txt", "2\n", "c2");
const before = await git.getHeadSha(repo);
await git.rebase(wt, "main");
await git.mergeSquash(repo, "wt/feat-d", "squashed");
const after = await git.getHeadSha(repo);
assert.notEqual(before, after);
assert.equal(raw(["rev-list", "--count", `${before}..${after}`], repo).trim(), "1");
assert.equal(await readFile(join(repo, "a.txt"), "utf8"), "1\n");
});
test("stashPush reports whether anything was stashed and stashPop restores it", async () => {
const { repo } = await makeRepo();
assert.equal(await git.stashPush(repo, "nothing"), false);
await writeFile(join(repo, "dirty.txt"), "dirty\n", "utf8");
assert.equal(await git.stashPush(repo, "pi-worktree test"), true);
assert.equal(existsSync(join(repo, "dirty.txt")), false);
await git.stashPop(repo);
assert.equal(await readFile(join(repo, "dirty.txt"), "utf8"), "dirty\n");
});
test("rebase onto a nonexistent ref throws the original rebase error", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-e");
await assert.rejects(
() => git.rebase(wt, "no-such-ref"),
(error: unknown) => {
assert.ok(error instanceof GitError, `expected GitError, got ${String(error)}`);
assert.equal(error.args[0], "rebase");
assert.ok(!error.message.includes("--abort"), error.message);
return true;
},
);
assert.equal(await git.rebaseInProgress(wt), false);
});
test("removeWorktree with force deletes the directory while the branch stays", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-f");
await writeFile(join(wt, "junk.bin"), "x", "utf8");
await git.removeWorktree(repo, wt, true);
assert.equal(existsSync(wt), false);
assert.ok(await git.branchExists(repo, "wt/feat-f"));
});
test("listWorktrees parses main, linked, and detached entries", async () => {
const { parent, repo } = await makeRepo();
const wt = await worktreeOf(parent, repo, "feat-g");
const detachedPath = join(parent, "detached-wt");
raw(["worktree", "add", "--detach", detachedPath, "HEAD"], repo);
const entries = await git.listWorktrees(repo);
assert.equal(entries.length, 3);
const byPath = new Map(
await Promise.all(
entries.map(async (e) => [(await realpath(e.path).catch(() => e.path)), e] as const),
),
);
const main = entries[0];
assert.equal(main?.branch, "main");
assert.equal(byPath.get(await realpath(wt))?.branch, "wt/feat-g");
assert.equal(byPath.get(await realpath(detachedPath))?.detached, true);
assert.equal(await git.getMainWorktreePath(repo), main?.path);
});
+147
View File
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { CURRENT_SESSION_VERSION, SessionManager } from "@earendil-works/pi-coding-agent";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { prepareSessionForCwd, switchToCwd } from "../src/session.ts";
interface FakeContext {
sessionManager: { getSessionFile(): string | undefined };
}
function fakeCtx(getSessionFile: () => string | undefined): FakeContext {
return { sessionManager: { getSessionFile } };
}
test("prepareSessionForCwd forks an existing session file into the target cwd", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const root = await mkdtemp(join(tmpdir(), "pi-worktree-session-"));
const sourceCwd = join(root, "source");
const targetCwd = join(root, "target");
await mkdir(sourceCwd, { recursive: true });
await mkdir(targetCwd, { recursive: true });
// Hand-written session file: header plus one user message entry.
const sessionDir = join(root, "sessions");
await mkdir(sessionDir, { recursive: true });
const file = join(sessionDir, "source-session.jsonl");
const header = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: "11111111-1111-4111-8111-111111111111",
timestamp: new Date().toISOString(),
cwd: sourceCwd,
};
const message = {
type: "message",
id: "22222222-2222-4222-8222-222222222222",
parentId: null,
timestamp: new Date().toISOString(),
message: {
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
},
};
await writeFile(file, `${JSON.stringify(header)}\n${JSON.stringify(message)}\n`, "utf8");
const newFile = prepareSessionForCwd(fakeCtx(() => file), targetCwd);
assert.ok(existsSync(newFile));
assert.notEqual(newFile, file);
const opened = SessionManager.open(newFile);
assert.equal(opened.getCwd(), resolve(targetCwd));
const entries = opened.getEntries();
assert.ok(entries.some((e) => e.type === "message"), "forked session keeps the history");
});
test("prepareSessionForCwd writes a minimal session file when none exists yet", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const targetCwd = await mkdtemp(join(tmpdir(), "pi-worktree-target-"));
const newFile = prepareSessionForCwd(fakeCtx(() => undefined), targetCwd);
assert.ok(existsSync(newFile));
assert.equal(SessionManager.open(newFile).getCwd(), resolve(targetCwd));
});
test("switchToCwd appends a cwd-change notice before running the callback", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const targetCwd = await mkdtemp(join(tmpdir(), "pi-worktree-target-"));
const order: string[] = [];
const sent: { message: unknown; options: unknown }[] = [];
const replacement = {
sendMessage: async (message: unknown, options: unknown) => {
order.push("sendMessage");
sent.push({ message, options });
},
};
const ctx = {
cwd: "/a",
waitForIdle: async () => undefined,
sessionManager: { getSessionFile: () => undefined },
switchSession: async (_file: string, opts: { withSession: (c: unknown) => Promise<void> }) => {
await opts.withSession(replacement);
return { cancelled: false };
},
};
const switched = await switchToCwd(ctx as never, targetCwd, async () => {
order.push("withSession");
});
assert.equal(switched.cancelled, false);
assert.deepEqual(order, ["sendMessage", "withSession"]);
assert.equal(sent.length, 1);
const { message, options } = sent[0] as { message: { customType?: string; content?: string; display?: boolean }; options: unknown };
assert.equal(message.customType, "pi-worktree");
assert.equal(message.display, true);
assert.ok(message.content?.includes("/a"), "notice names the previous cwd");
assert.ok(message.content?.includes(targetCwd), "notice names the new cwd");
assert.equal(options, undefined);
});
test("switchToCwd skips the notice when the cwd did not change", async (t) => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-worktree-agent-"));
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
t.after(() => {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
});
const targetCwd = await mkdtemp(join(tmpdir(), "pi-worktree-target-"));
let sent = 0;
const ctx = {
cwd: targetCwd,
waitForIdle: async () => undefined,
sessionManager: { getSessionFile: () => undefined },
switchSession: async (_file: string, opts: { withSession: (c: unknown) => Promise<void> }) => {
await opts.withSession({ sendMessage: async () => sent++ });
return { cancelled: false };
},
};
await switchToCwd(ctx as never, targetCwd, async () => undefined);
assert.equal(sent, 0);
});
+358
View File
@@ -0,0 +1,358 @@
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")));
});
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import { toBranchName, toPathSlug, validateSlug } from "../src/slug.ts";
test("accepts valid slugs", () => {
for (const slug of ["feat/login", "fix-1", "simple", "a_b.c", "deep/nested/name"]) {
assert.equal(validateSlug(slug), null, slug);
}
});
test("rejects invalid slugs", () => {
for (const slug of ["..", "a..b", "-x", "x.lock", "a".repeat(65), "main", "HEAD", "", "a b", "a//b", "/a", "a/"]) {
assert.notEqual(validateSlug(slug), null, slug);
}
});
test("maps slug to path slug and branch name", () => {
assert.equal(toPathSlug("feat/login"), "feat-login");
assert.equal(toPathSlug("fix-1"), "fix-1");
assert.equal(toBranchName("wt/", "feat/login"), "wt/feat/login");
});
+151
View File
@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, realpath, stat, symlink, unlink, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import {
addEntry,
findByCwd,
findBySlug,
loadState,
removeEntry,
saveState,
stateFilePath,
updateState,
type WorktreeEntry,
} from "../src/state.ts";
function entry(overrides: Partial<WorktreeEntry> = {}): WorktreeEntry {
return {
slug: "feat",
path: "/nonexistent/path",
branch: "wt/feat",
sourcePath: "/nonexistent/source",
sourceBranch: "main",
sourceSha: "0".repeat(40),
createdAt: new Date().toISOString(),
...overrides,
};
}
test("state roundtrip with owner-only permissions", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "pi-worktree", "state.json");
const state = await loadState(path);
assert.deepEqual(state, { version: 1, worktrees: [] });
addEntry(state, entry());
await saveState(state, path);
const loaded = await loadState(path);
assert.equal(loaded.worktrees.length, 1);
assert.equal(loaded.worktrees[0]?.slug, "feat");
assert.equal((await stat(path)).mode & 0o777, 0o600);
assert.equal((await stat(dirname(path))).mode & 0o777, 0o700);
removeEntry(loaded, "feat");
assert.equal(loaded.worktrees.length, 0);
assert.equal(findBySlug(loaded, "feat"), undefined);
});
test("corrupt or unknown-version state throws instead of overwriting", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const bad = join(dir, "bad.json");
await writeFile(bad, "not json{", "utf8");
await assert.rejects(() => loadState(bad), /损坏/u);
const wrongVersion = join(dir, "v2.json");
await writeFile(wrongVersion, JSON.stringify({ version: 2, worktrees: [] }), "utf8");
await assert.rejects(() => loadState(wrongVersion), /版本不受支持|结构无效/u);
});
test("findByCwd matches nested subdir via realpath through a symlink", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const target = join(root, "wt-dir");
await mkdir(join(target, "sub", "deep"), { recursive: true });
const link = join(root, "link");
await symlink(target, link);
const state = { version: 1 as const, worktrees: [entry({ path: await realpath(target) })] };
const found = await findByCwd(state, join(link, "sub", "deep"));
assert.equal(found?.slug, "feat");
assert.equal(await findByCwd(state, root), undefined);
});
test("stateFilePath lives under the agent dir, not my-pi", () => {
const previous = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = join(tmpdir(), "pi-worktree-agentdir-check");
try {
const path = stateFilePath();
assert.equal(path, join(process.env.PI_CODING_AGENT_DIR, "pi-worktree", "state.json"));
assert.ok(!path.includes("my-pi"));
} finally {
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previous;
}
});
test("updateState serializes concurrent mutations without losing entries", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
await Promise.all(
Array.from({ length: 20 }, (_, i) =>
updateState(
(s) => addEntry(s, entry({ slug: `feat-${i}`, branch: `wt/feat-${i}`, path: `/p/${i}` })),
path,
),
),
);
const loaded = await loadState(path);
assert.equal(loaded.worktrees.length, 20);
assert.equal(new Set(loaded.worktrees.map((e) => e.slug)).size, 20);
assert.equal(existsSync(`${path}.lock`), false);
});
test("updateState breaks a stale lock older than the stale threshold", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
const lock = `${path}.lock`;
await mkdir(dir, { recursive: true });
await writeFile(lock, "1 stale", "utf8");
const old = new Date(Date.now() - 60_000);
await utimes(lock, old, old);
await updateState((s) => addEntry(s, entry()), path);
assert.equal((await loadState(path)).worktrees.length, 1);
assert.equal(existsSync(lock), false);
});
test("updateState does not save when the mutator throws and releases the lock", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
await updateState((s) => addEntry(s, entry()), path);
await assert.rejects(
updateState(() => {
throw new Error("boom");
}, path),
/boom/,
);
const loaded = await loadState(path);
assert.equal(loaded.worktrees.length, 1);
assert.equal(existsSync(`${path}.lock`), false);
});
test("updateState rejects when a fresh lock is held past the timeout", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-worktree-state-"));
const path = join(dir, "state.json");
const lock = `${path}.lock`;
await mkdir(dir, { recursive: true });
await writeFile(lock, "1 fresh", "utf8");
try {
await assert.rejects(
updateState(() => undefined, path, { timeoutMs: 300, staleMs: 60_000 }),
/状态文件被占用/,
);
} finally {
await unlink(lock).catch(() => undefined);
}
});