291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
/**
|
||
* 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/<hash> 方案)。
|
||
*/
|
||
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<string> {
|
||
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<boolean> {
|
||
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<string | undefined> {
|
||
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<boolean> {
|
||
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<string> {
|
||
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<void> {
|
||
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<void>((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/<pid>/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
|
||
}
|