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
+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);
});