🐛 fix(vscode): 实施启动改为复用已有 feature 分支

This commit is contained in:
2026-08-25 14:45:53 +08:00
parent 0db43f4cc3
commit 24b1ae2ecf
3 changed files with 297 additions and 78 deletions
+111 -34
View File
@@ -1,9 +1,6 @@
/**
* Thin wrapper around `git worktree add -b` for the 实施 flow.
*
* We invoke the system `git` binary via `execFile` rather than depend on a
* git library — keeps the surface minimal and lets the user benefit from
* whatever git they already have configured.
* git worktree 的薄封装:实施流用 ensureWorktree 保证「这个 feature 有一个可用
* worktree」,不依赖 git 库,走用户机器上的系统 git。
*/
import { execFile } from 'node:child_process'
@@ -30,43 +27,123 @@ export function resolveWorktreePath(stored: string, workspaceRoot: string): stri
}
export interface WorktreeOpts {
/** Absolute path to the main workspace root. */
workspaceRoot: string
/** Absolute path of the worktree to create. */
/** 模板 path;① 已有 live worktree 时返回值可能与此不同。 */
worktreePath: string
/** New branch name; e.g. `feature/abcd1234`. */
branch: string
}
/**
* Create a new git worktree with a freshly-created branch off current HEAD.
* git -C <workspaceRoot> worktree add <worktreePath> -b <branch>
*
* Rejects with a descriptive Error if git exits non-zero. The caller should
* surface stderr to the user via a toast.
*/
export async function createWorktree(opts: WorktreeOpts): Promise<void> {
const { workspaceRoot, worktreePath, branch } = opts
return new Promise<void>((resolve, reject) => {
execFile(
'git',
['-C', workspaceRoot, 'worktree', 'add', worktreePath, '-b', branch],
{ timeout: 30_000 },
(err, _stdout, stderr) => {
if (err) {
// execFile sets `err.code` for non-zero exits; for spawn failures
// (e.g. git not on PATH) it's a string like 'ENOENT'.
const code = (err as NodeJS.ErrnoException).code ?? 'unknown'
const trimmed = (stderr ?? '').toString().trim() || err.message
reject(new Error(`git worktree add 失败 (${code}): ${trimmed}`))
return
}
resolve()
},
)
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 侧注册。
*