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

313 lines
11 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'
import process from 'node:process'
import { deleteLocalBranch } from './branches'
/**
* 把开头的 `~` 展开为用户 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;返回值即此路径。 */
worktreePath: string
branch: string
}
export 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
}
export 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
}
/**
* 拖到「进行中」= 从当前 HEAD 全新实施:清 leftover worktree / 本地分支,再 `add -b`。
*/
export async function ensureWorktree(opts: WorktreeOpts): Promise<string> {
const { workspaceRoot, branch } = opts
const worktreePath = path.resolve(opts.worktreePath)
// ① 该 branch 的 live worktree 可能不在模板 path → kill + 整目录删掉
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
if (live) {
killProcessesUsingWorktree(live)
await removeWorktreeDir(workspaceRoot, live)
}
// ② 模板 path 仍被垃圾目录占着 → 同样 kill + remove,否则 ④ add 撞路径
if (await pathExists(worktreePath)) {
killProcessesUsingWorktree(worktreePath)
await removeWorktreeDir(workspaceRoot, worktreePath)
}
// ③ leftover 本地分支必须删掉;失败不能静默,否则 ④ `add -b` 撞同名分支
const deleted = await deleteLocalBranch(workspaceRoot, branch)
if (!deleted.ok)
throw new Error(`删除本地分支失败 ${branch}: ${deleted.stderr || deleted.stdout}`)
// ④ 始终 -b 从当前 HEAD 新建,不挂旧分支
await runGit(workspaceRoot, ['worktree', 'add', worktreePath, '-b', branch], 'git worktree add')
return worktreePath
}
/**
* 接管方重建 worktree:分支已在 origin 上,本机从远端 checkout。
* `-B` 让本地同名残留分支直接对齐远端(发送方移交前已校验全部 push)。
*/
export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<string> {
const { workspaceRoot, branch } = opts
const worktreePath = path.resolve(opts.worktreePath)
await runGit(workspaceRoot, ['fetch', 'origin', branch], `拉取远端分支 ${branch}`)
// ① 已经有 live worktree → 复用,不动模板路径
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
if (live)
return live
// ② 模板路径被垃圾目录占着 → kill + 删,否则 add 撞路径
if (await pathExists(worktreePath)) {
killProcessesUsingWorktree(worktreePath)
await removeWorktreeDir(workspaceRoot, worktreePath)
}
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
await runGit(
workspaceRoot,
['worktree', 'add', '-B', branch, worktreePath, `origin/${branch}`],
'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
}