Files
superwork/vscode/src/git/worktree.ts
T

291 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: 非零退出是数字 codegit 不在 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。
* 非 Linuxfind 返回空)为 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
}