/** * git worktree 的薄封装:实施流用 ensureWorktree 保证「这个 feature 有一个可用 * worktree」,不依赖 git 库,走用户机器上的系统 git。 */ import { execFile } from 'node:child_process' import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' /** * 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径 * 里的 `~` 不会被展开,需要我们自己处理。 */ export function expandTilde(p: string): string { return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p } /** * 把存储的 worktreePath 解析成可用的绝对路径。三态合一: * 开头 `~` → 展开为 home(存量工单里可能存了带 `~` 的字面量);已是绝对 → 原样; * 相对 → 拼到 workspaceRoot 下(旧的 .claude/worktrees/ 方案)。 */ export function resolveWorktreePath(stored: string, workspaceRoot: string): string { const p = expandTilde(stored) return path.isAbsolute(p) ? p : path.join(workspaceRoot, p) } export interface WorktreeOpts { workspaceRoot: string /** 模板 path;① 已有 live worktree 时返回值可能与此不同。 */ worktreePath: string branch: string } function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise { return new Promise((resolve, reject) => { execFile('git', ['-C', workspaceRoot, ...args], { timeout: 30_000 }, (err, stdout, stderr) => { if (err) { // execFile: 非零退出是数字 code;git 不在 PATH 时是 'ENOENT' const code = (err as NodeJS.ErrnoException).code ?? 'unknown' const trimmed = (stderr ?? '').toString().trim() || err.message const label = failLabel ?? `git ${args.join(' ')}` reject(new Error(`${label} 失败 (${code}): ${trimmed}`)) return } resolve((stdout ?? '').toString()) }) }) } async function pathExists(p: string): Promise { try { await fsp.stat(p) return true } catch { return false } } function parseWorktreePorcelain(stdout: string): Array<{ path: string, branch: string | null, prunable: boolean }> { const entries: Array<{ path: string, branch: string | null, prunable: boolean }> = [] let current: { path: string, branch: string | null, prunable: boolean } | null = null const flush = (): void => { if (current?.path) entries.push(current) current = null } for (const line of stdout.split('\n')) { if (line.startsWith('worktree ')) { flush() current = { path: line.slice('worktree '.length), branch: null, prunable: false } continue } if (!current) continue if (line.startsWith('branch ')) { const ref = line.slice('branch '.length) current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref } else if (line === 'detached') { current.branch = null } else if (line === 'prunable' || line.startsWith('prunable ')) { current.prunable = true } else if (line === '') { flush() } } flush() return entries } async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise { const stdout = await runGit(workspaceRoot, ['worktree', 'list', '--porcelain']) for (const entry of parseWorktreePorcelain(stdout)) { if (entry.prunable || entry.branch !== branch) continue if (await pathExists(entry.path)) return entry.path } return undefined } async function localBranchExists(workspaceRoot: string, branch: string): Promise { try { await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) return true } catch { return false } } /** * 保证这个 feature 有一个可用 worktree。优先复用 live,绝不删 feature 分支 * —— 拖到「进行中」是开始实施,不是销毁上次进度。 */ export async function ensureWorktree(opts: WorktreeOpts): Promise { const { workspaceRoot, branch } = opts const worktreePath = path.resolve(opts.worktreePath) // ① 该 branch 已在某个 live worktree → 原路返回(path ≠ 模板也不搬,避免丢未提交改动) const live = await findLiveWorktreeForBranch(workspaceRoot, branch) if (live) { console.log(`[superpowers] 复用已有 worktree ${live} (${branch})`) return live } // ② 模板 path 被垃圾占着(存在,但不是本仓库该 branch 的 live worktree) if (await pathExists(worktreePath)) { killProcessesUsingWorktree(worktreePath) await removeWorktreeDir(workspaceRoot, worktreePath) console.log(`[superpowers] 已清模板路径垃圾 ${worktreePath}`) } const branchExists = await localBranchExists(workspaceRoot, branch) const addArgs = branchExists ? ['worktree', 'add', worktreePath, branch] // ③ 有分支无 live worktree → 不加 -b,挂上已有分支,保留 commit : ['worktree', 'add', worktreePath, '-b', branch] // ④ 分支也不存在 → -b 新建 await runGit(workspaceRoot, addArgs, 'git worktree add') return worktreePath } /** * 删除一个 worktree 目录并清掉 git 侧注册。 * * 为什么不用 `git worktree remove`:它只删自己跟踪的文件 + admin 注册, * 不删 gitignored 的构建产物(如 .next / node_modules)。删完后这些残留把目录 * 撑住,git 最后一步 rmdir 撞 ENOTEMPTY(目录非空)而整体失败——`--force` * 只解决"工作树脏",救不了这个。故直接递归强删目录,再 `worktree prune` 抹掉 * git 侧可能残留的死注册。 * * rm -rf 危险,删前用护栏挡住明显异常的路径:非绝对、根、home、workspaceRoot * 本身或其祖先、层级过浅——任一命中直接抛错、绝不删。 */ export async function removeWorktreeDir(workspaceRoot: string, absWorktreePath: string): Promise { const abs = path.resolve(absWorktreePath) const wsAbs = path.resolve(workspaceRoot) const shallow = abs.split(path.sep).filter(Boolean).length < 3 const isRootOrHome = abs === path.parse(abs).root || abs === os.homedir() // abs 等于 workspaceRoot 本身、或是它的祖先目录,都会把主仓库一起端走。 const engulfsWorkspace = abs === wsAbs || wsAbs.startsWith(abs + path.sep) if (!absWorktreePath || !path.isAbsolute(absWorktreePath) || shallow || isRootOrHome || engulfsWorkspace) throw new Error(`拒绝删除可疑的 worktree 路径: ${absWorktreePath}`) await fsp.rm(abs, { recursive: true, force: true }) // git 侧可能还留着指向已删目录的死注册;prune 抹掉它。目录已删是主目标, // prune 失败只是 `worktree list` 里留一条死记录,非致命。 await new Promise((resolve) => { execFile('git', ['-C', workspaceRoot, 'worktree', 'prune'], { timeout: 30_000 }, (err) => { if (err) console.warn(`[superpowers] git worktree prune 失败(非致命): ${err.message}`) resolve() }) }) } export interface WorktreeProcHit { pid: string cmd: string } /** * 扫 /proc 找出 cwd 或打开的 fd 落在 worktree 目录(含子目录)下的进程。 * * 扫占用 worktree 的进程。dev server 等常驻进程会让递归删除撞 ENOTEMPTY * (删期间目录被重新填充);调用方应先 killProcessesUsingWorktree 再删。 * * 非 Linux(无 /proc)返回空数组 → 调用方降级为直接删除。 */ export function findProcessesUsingWorktree(absWorktreePath: string): WorktreeProcHit[] { const hits: WorktreeProcHit[] = [] const target = path.resolve(absWorktreePath) const prefix = target.endsWith(path.sep) ? target : target + path.sep let pids: string[] try { pids = readdirSync('/proc').filter(n => /^\d+$/.test(n)) } catch { return hits // 无 /proc → 无从扫描 } for (const pid of pids) { let used = false // ① cwd 落在 worktree 下(dev server 常把工作目录设在里面) try { const cwd = readlinkSync(`/proc/${pid}/cwd`) if (cwd === target || cwd.startsWith(prefix)) // (deleted) 后缀也照样命中前缀 used = true } catch {} // 进程已退出 / 无权限 → 跳过 // ② 否则查打开的 fd 是否指向 worktree 下的文件(构建产物写句柄) if (!used) { try { for (const fd of readdirSync(`/proc/${pid}/fd`)) { try { const p = readlinkSync(`/proc/${pid}/fd/${fd}`) if (p === target || p.startsWith(prefix)) { used = true break } } catch {} // 单个 fd 竞态关闭 → 跳过 } } catch {} // /proc//fd 读不到 → 跳过 } if (used) { let cmd = '' try { cmd = readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim() } catch {} // cmdline 读不到就留空,pid 仍报给用户 hits.push({ pid, cmd }) } } return hits } /** * 删 worktree 前杀掉占用目录的进程(SIGTERM → 短暂等待 → SIGKILL)。 * * 跳过 process.pid / process.ppid,避免误杀扩展宿主或父 VS Code。 * 非 Linux(find 返回空)为 no-op。返回被尝试杀掉的 hits,供调用方记日志。 */ export function killProcessesUsingWorktree(absWorktreePath: string): WorktreeProcHit[] { const selfPid = String(process.pid) const parentPid = String(process.ppid) // ① 扫占用 → 过滤掉自身/父进程 const hits = findProcessesUsingWorktree(absWorktreePath) .filter(h => h.pid !== selfPid && h.pid !== parentPid) if (hits.length === 0) return hits // ② SIGTERM 温和退出(ESRCH=已死 → 忽略) for (const h of hits) { try { process.kill(Number(h.pid), 'SIGTERM') } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code !== 'ESRCH') console.warn(`[superpowers] SIGTERM pid ${h.pid} 失败:`, err) } } // ③ 同步等 ~400ms,给 dev server 关句柄的时间 const sab = new SharedArrayBuffer(4) const ia = new Int32Array(sab) Atomics.wait(ia, 0, 0, 400) // ④ 仍存活的 SIGKILL 兜底 for (const h of hits) { try { process.kill(Number(h.pid), 0) // 探测是否仍在 process.kill(Number(h.pid), 'SIGKILL') } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code !== 'ESRCH') console.warn(`[superpowers] SIGKILL pid ${h.pid} 失败:`, err) } } return hits }