diff --git a/src/kernel/worktree.ts b/src/kernel/worktree.ts new file mode 100644 index 0000000..3cabf6a --- /dev/null +++ b/src/kernel/worktree.ts @@ -0,0 +1,168 @@ +import { exec } from "node:child_process" +import { promisify } from "node:util" +import path from "node:path" +import { branchFor, worktreeFor } from "./slug.js" +import type { BranchType } from "./slug.js" +import { pathExists } from "../util/fs.js" +import { escapeShellArg } from "../util/shell.js" + +const execAsync = promisify(exec) + +type GitFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let worktreeGitExecutor: GitFn | null = null + +export function setWorktreeGitExecutor(fn: GitFn | null): void { + worktreeGitExecutor = fn +} + +async function git(args: string): Promise<{ stdout: string; stderr: string }> { + if (worktreeGitExecutor) { + return worktreeGitExecutor(args) + } + return execAsync(`git ${args}`) +} + +// ─── worktree-start:创建 / 复用校验 ─── + +export type WorktreeStartResult = + | { ok: true; branch: string; path: string; reused: boolean } + | { ok: false; code: "BRANCH_EXISTS" | "BRANCH_EXISTS_UNMERGED" | "BRANCH_MISMATCH" | "GIT_FAILED"; message: string } + +export interface WorktreeStartInput { + projectDir: string + slug: string + base: string + branchType?: BranchType +} + +/** 分支已存在(本地 ref),未合并 → BRANCH_EXISTS_UNMERGED;已合并残留 → BRANCH_EXISTS */ +export async function worktreeStart(input: WorktreeStartInput): Promise { + const branch = branchFor(input.slug, input.branchType ?? "feat") + const worktreeDir = path.join(input.projectDir, worktreeFor(input.slug)) + + if (await pathExists(worktreeDir)) { + return reuseWorktree(worktreeDir, branch) + } + + if (await branchExists(branch)) { + const merged = await isMergedToBase(branch, input.base) + return merged + ? { ok: false, code: "BRANCH_EXISTS", message: `branch ${branch} already exists and is merged; remove leftover branch manually` } + : { ok: false, code: "BRANCH_EXISTS_UNMERGED", message: `branch ${branch} already exists and is not merged into ${input.base}` } + } + + try { + await git(`worktree add -b ${branch} '${escapeShellArg(worktreeDir)}' ${input.base}`) + return { ok: true, branch, path: worktreeDir, reused: false } + } catch (err) { + return { ok: false, code: "GIT_FAILED", message: String(err) } + } +} + +/** 目录已存在:HEAD 分支与期望分支一致才复用,否则报错 */ +async function reuseWorktree(worktreeDir: string, branch: string): Promise { + try { + const { stdout } = await git(`-C '${escapeShellArg(worktreeDir)}' rev-parse --abbrev-ref HEAD`) + const current = stdout.trim() + if (current !== branch) { + return { + ok: false, + code: "BRANCH_MISMATCH", + message: `worktree at ${worktreeDir} is on branch ${current}, expected ${branch}`, + } + } + return { ok: true, branch, path: worktreeDir, reused: true } + } catch (err) { + return { ok: false, code: "GIT_FAILED", message: String(err) } + } +} + +async function branchExists(branch: string): Promise { + try { + const { stdout } = await git(`rev-parse --verify refs/heads/${branch}`) + return stdout.trim() !== "" + } catch { + return false + } +} + +/** branch 是否是 base 的祖先(已合并);非祖先或 git 失败 → false */ +async function isMergedToBase(branch: string, base: string): Promise { + try { + await git(`merge-base --is-ancestor ${branch} ${base}`) + return true + } catch { + return false + } +} + +// ─── destroy-worktree:preflight 销毁(无 --force) ─── + +export type DestroyReason = "OK" | "DIRTY_WORKTREE" | "PR_NOT_MERGED" | "BRANCH_NOT_PUSHED" | "NOT_FOUND" + +export type DestroyWorktreeResult = + | { ok: true; reason: "OK" | "NOT_FOUND" } + | { ok: false; reason: "DIRTY_WORKTREE" | "PR_NOT_MERGED" | "BRANCH_NOT_PUSHED" | "GIT_FAILED"; message: string } + +export interface DestroyWorktreeInput { + worktreeDir: string + branch: string + prMerged: boolean + userConfirmed: boolean +} + +/** + * preflight 顺序:不存在 → 幂等跳过;脏 → 拒绝(不 --force); + * 未合并 → 需 user_confirmed 且分支已推送;合并且干净 → 自动删除。 + */ +export async function destroyWorktree(input: DestroyWorktreeInput): Promise { + if (!(await pathExists(input.worktreeDir))) { + return { ok: true, reason: "NOT_FOUND" } + } + + if (await isDirty(input.worktreeDir)) { + return { ok: false, reason: "DIRTY_WORKTREE", message: `worktree ${input.worktreeDir} has uncommitted changes; refusing to remove without --force` } + } + + if (!input.prMerged) { + if (!input.userConfirmed) { + return { ok: false, reason: "PR_NOT_MERGED", message: "PR is not merged; require user_confirmed to destroy the worktree" } + } + if (!(await isBranchPushed(input.branch))) { + return { ok: false, reason: "BRANCH_NOT_PUSHED", message: `branch ${input.branch} is not pushed to origin` } + } + } + + try { + await git(`worktree remove '${escapeShellArg(input.worktreeDir)}'`) + await removeLocalBranch(input.branch) + return { ok: true, reason: "OK" } + } catch (err) { + return { ok: false, reason: "GIT_FAILED", message: String(err) } + } +} + +async function isDirty(worktreeDir: string): Promise { + const { stdout } = await git(`-C '${escapeShellArg(worktreeDir)}' status --porcelain`) + return stdout.trim() !== "" +} + +/** 分支是否已推送到 origin(ls-remote 命中 refs/heads/) */ +async function isBranchPushed(branch: string): Promise { + try { + const { stdout } = await git(`ls-remote --heads origin ${branch}`) + return stdout.includes(`refs/heads/${branch}`) + } catch { + return false + } +} + +async function removeLocalBranch(branch: string): Promise { + try { + await git(`rev-parse --verify refs/heads/${branch}`) + } catch { + return // 本地分支不存在,跳过 + } + await git(`branch -D ${branch}`) +} diff --git a/test/kernel/worktree.test.ts b/test/kernel/worktree.test.ts new file mode 100644 index 0000000..1859cfd --- /dev/null +++ b/test/kernel/worktree.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest" +import { mkdtemp, mkdir, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { worktreeStart, destroyWorktree } from "../../src/kernel/worktree.js" +import type { setWorktreeGitExecutor as SetGitExecutorType } from "../../src/kernel/worktree.js" + +type GitFn = (args: string) => Promise<{ stdout: string; stderr: string }> + +let setGit: typeof SetGitExecutorType +let projectDir: string + +beforeAll(async () => { + const mod = await import("../../src/kernel/worktree.js") + setGit = mod.setWorktreeGitExecutor +}) + +beforeEach(async () => { + projectDir = await mkdtemp(path.join(os.tmpdir(), "worktree-test-")) + setGit(() => { + throw new Error("unexpected git call") + }) +}) + +afterEach(async () => { + setGit(null) + await rm(projectDir, { recursive: true, force: true }) +}) + +describe("worktreeStart", () => { + it("创建:目录不存在时按 slug 派生分支并 git worktree add", async () => { + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.startsWith("worktree add")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result).toEqual({ + ok: true, + branch: "feat/user-auth-login", + path: path.join(projectDir, ".worktree/user-auth-login"), + reused: false, + }) + // 先查分支是否已存在,再 add + expect(calls.some(c => c.includes("rev-parse --verify refs/heads/feat/user-auth-login"))).toBe(true) + const addCall = calls.find(c => c.startsWith("worktree add")) + expect(addCall).toBeDefined() + if (addCall) { + expect(addCall).toContain("worktree add -b feat/user-auth-login") + expect(addCall).toContain(".worktree/user-auth-login") + expect(addCall).toContain("main") + expect(addCall).not.toContain("--force") + } + }) + + it("创建:支持非 feat 分支类型", async () => { + setGit(async (args) => { + if (args.startsWith("worktree add")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "fix-auth-timeout", base: "main", branchType: "fix" }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.branch).toBe("fix/fix-auth-timeout") + } + }) + + it("创建:分支已存在且未合并 → 拒绝,不执行 add", async () => { + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" } + if (args.includes("merge-base --is-ancestor")) throw new Error("not an ancestor") + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("BRANCH_EXISTS_UNMERGED") + } + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + }) + + it("创建:分支已存在且已合并 → 拒绝残留,不执行 add", async () => { + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" } + if (args.includes("merge-base --is-ancestor")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("BRANCH_EXISTS") + } + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + }) + + it("复用:目录存在且分支一致 → 返回 reused,不执行任何写命令", async () => { + await mkdir(path.join(projectDir, ".worktree/user-auth-login"), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --abbrev-ref HEAD")) return { stdout: "feat/user-auth-login", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result).toEqual({ + ok: true, + branch: "feat/user-auth-login", + path: path.join(projectDir, ".worktree/user-auth-login"), + reused: true, + }) + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + }) + + it("复用:目录存在但分支不一致 → 报错 BRANCH_MISMATCH", async () => { + await mkdir(path.join(projectDir, ".worktree/user-auth-login"), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("rev-parse --abbrev-ref HEAD")) return { stdout: "main", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("BRANCH_MISMATCH") + expect(result.message).toContain("main") + expect(result.message).toContain("feat/user-auth-login") + } + expect(calls.some(c => c.startsWith("worktree add"))).toBe(false) + }) + + it("创建:git add 失败 → GIT_FAILED", async () => { + setGit(async (args) => { + if (args.startsWith("worktree add")) throw new Error("fatal: could not create worktree") + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await worktreeStart({ projectDir, slug: "user-auth-login", base: "main" }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.code).toBe("GIT_FAILED") + } + }) +}) + +describe("destroyWorktree", () => { + const worktreeDir = (): string => path.join(projectDir, ".worktree/user-auth-login") + const branch = "feat/user-auth-login" + + it("PR 已合并且干净 → git worktree remove + branch -D,无 --force", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.startsWith("worktree remove")) return { stdout: "", stderr: "" } + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" } + if (args.startsWith("branch -D")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: true, userConfirmed: false }) + expect(result).toEqual({ ok: true, reason: "OK" }) + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(true) + expect(calls.some(c => c.startsWith("branch -D"))).toBe(true) + for (const c of calls) { + expect(c).not.toContain("--force") + } + }) + + it("PR 已合并但 worktree 脏 → 拒绝 DIRTY_WORKTREE,不执行 remove", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: " M src/kernel/worktree.ts", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: true, userConfirmed: false }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe("DIRTY_WORKTREE") + } + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(false) + }) + + it("PR 未合并且未确认 → 拒绝 PR_NOT_MERGED,不查推送", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: false, userConfirmed: false }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe("PR_NOT_MERGED") + } + expect(calls.some(c => c.includes("ls-remote"))).toBe(false) + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(false) + }) + + it("PR 未合并、已确认但分支未推送 → 拒绝 BRANCH_NOT_PUSHED", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.includes("ls-remote")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: false, userConfirmed: true }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toBe("BRANCH_NOT_PUSHED") + } + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(false) + }) + + it("PR 未合并、已确认且分支已推送 → 删除 OK", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.includes("ls-remote")) return { stdout: `abc123\trefs/heads/${branch}`, stderr: "" } + if (args.startsWith("worktree remove")) return { stdout: "", stderr: "" } + if (args.includes("rev-parse --verify refs/heads/")) return { stdout: "abc123", stderr: "" } + if (args.startsWith("branch -D")) return { stdout: "", stderr: "" } + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: false, userConfirmed: true }) + expect(result).toEqual({ ok: true, reason: "OK" }) + expect(calls.some(c => c.startsWith("worktree remove"))).toBe(true) + for (const c of calls) { + expect(c).not.toContain("--force") + } + }) + + it("worktree 不存在 → 幂等 NOT_FOUND,不执行任何 git 调用", async () => { + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + return { stdout: "", stderr: "" } + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: false, userConfirmed: false }) + expect(result).toEqual({ ok: true, reason: "NOT_FOUND" }) + expect(calls).toHaveLength(0) + }) + + it("本地分支不存在时跳过 branch -D", async () => { + await mkdir(worktreeDir(), { recursive: true }) + const calls: string[] = [] + setGit(async (args) => { + calls.push(args) + if (args.includes("status --porcelain")) return { stdout: "", stderr: "" } + if (args.startsWith("worktree remove")) return { stdout: "", stderr: "" } + if (args.includes("rev-parse --verify refs/heads/")) throw new Error("branch not found") + throw new Error(`unexpected git call: ${args}`) + }) + + const result = await destroyWorktree({ worktreeDir: worktreeDir(), branch, prMerged: true, userConfirmed: false }) + expect(result).toEqual({ ok: true, reason: "OK" }) + expect(calls.some(c => c.startsWith("branch -D"))).toBe(false) + }) +})