mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
提供 /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>
359 lines
14 KiB
TypeScript
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")));
|
|
});
|