mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45: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>
176 lines
6.8 KiB
TypeScript
176 lines
6.8 KiB
TypeScript
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);
|
|
});
|