mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
f237025cf9
commit
97c2e75dfe
@@ -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" }],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user