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>
152 lines
5.1 KiB
TypeScript
152 lines
5.1 KiB
TypeScript
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);
|
|
}
|
|
});
|