From 235cd9ea65e45a680f60adb1543b114343fe60e8 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 8 Jul 2026 11:48:23 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20worktree=20?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=94=B9=20rm=20-rf=20+=20prune=EF=BC=8C?= =?UTF-8?q?=E7=BB=95=E5=BC=80=20gitignored=20=E4=BA=A7=E7=89=A9=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E7=9B=AE=E5=BD=95=E9=9D=9E=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git worktree remove 只删跟踪文件+admin 注册,不删 .next/node_modules 等 gitignored 产物,删完残留把目录撑住、最后 rmdir 撞 ENOTEMPTY。新增 removeWorktreeDir(带路径护栏的递归强删 + git worktree prune),替换 3 处 worktree remove 调用点。 --- vscode/src/git/worktree.ts | 36 +++++++++++++++++++++++ vscode/src/panel/handlers/issues.ts | 42 ++++----------------------- vscode/src/panel/handlers/worktree.ts | 26 ++++------------- 3 files changed, 47 insertions(+), 57 deletions(-) diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index 5445079..1e98f5b 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -7,6 +7,7 @@ */ import { execFile } from 'node:child_process' +import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' @@ -65,3 +66,38 @@ export async function createWorktree(opts: WorktreeOpts): Promise { ) }) } + +/** + * 删除一个 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() + }) + }) +} diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index 6ec93bc..1b8c813 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -1,13 +1,12 @@ import type { IssueColumn } from '../../gitea/types' import type { KanbanWebviewPanel } from '../KanbanPanel' import { Buffer } from 'node:buffer' -import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import * as fs from 'node:fs' import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { resolveWorktreePath } from '../../git/worktree' +import { removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode' import { getToken } from '../../auth/secrets' import { buildCcCommand } from '../../cc/ccCommand' @@ -450,9 +449,8 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: if (worktreePath) { const abs = resolveWorktreePath(worktreePath, workspaceRoot) if (fs.existsSync(abs)) { - // worktree 还被占用时(实施/测试会话终端 cwd 在里面、cc/codex 持有 git - // 锁或打开文件)git worktree remove 会失败。先 dispose 该工单的全部会话 - // 终端,再等一小会让进程退出释放占用,然后再删。 + // 删前先 dispose 该工单的全部会话终端并等一小会:实施/测试会话可能仍在 + // worktree 里跑(写构建产物、持有 cwd),先让它们退出,避免边删边被写。 let disposedAny = false for (const [terminal, origin] of panel.terminalOrigin) { if (origin.issueNumber === issueNumber) { @@ -481,21 +479,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: customScriptPath: settingsForHook.worktreePreRemoveScript, }) try { - await new Promise((resolve, reject) => { - execFile( - 'git', - ['-C', workspaceRoot, 'worktree', 'remove', '--force', abs], - { timeout: 30_000 }, - (err, _stdout, stderr) => { - if (err) { - const detail = (stderr ?? '').trim() || err.message - reject(new Error(detail)) - return - } - resolve() - }, - ) - }) + await removeWorktreeDir(workspaceRoot, abs) logger.add({ level: 'info', source: 'panel', @@ -956,7 +940,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: } } - // 5. 删 worktree(如有)— git worktree remove --force,失败立即停 + // 5. 删 worktree(如有)— 递归强删目录(removeWorktreeDir),失败立即停 if (worktreePath) { const absWorktree = resolveWorktreePath(worktreePath, workspaceRoot) if (fs.existsSync(absWorktree)) { @@ -972,21 +956,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber: customScriptPath: settingsForHook.worktreePreRemoveScript, }) try { - await new Promise((resolve, reject) => { - execFile( - 'git', - ['-C', workspaceRoot, 'worktree', 'remove', '--force', absWorktree], - { timeout: 30_000 }, - (err, _stdout, stderr) => { - if (err) { - const detail = (stderr ?? '').trim() || err.message - reject(new Error(detail)) - return - } - resolve() - }, - ) - }) + await removeWorktreeDir(workspaceRoot, absWorktree) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/vscode/src/panel/handlers/worktree.ts b/vscode/src/panel/handlers/worktree.ts index 099fd9c..2ced5f3 100644 --- a/vscode/src/panel/handlers/worktree.ts +++ b/vscode/src/panel/handlers/worktree.ts @@ -4,7 +4,7 @@ import { execFile } from 'node:child_process' import { commands, Uri, window, workspace } from 'vscode' import { getToken } from '../../auth/secrets' import { detectRepo } from '../../git/remote' -import { expandTilde, resolveWorktreePath } from '../../git/worktree' +import { expandTilde, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { runImplTabPostCloseHook, runImplTabPreCreateHook, @@ -59,10 +59,8 @@ export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: str } /** - * Confirm + run `git worktree remove ` (no --force), then clear - * `worktreePath`/`branch` from the issue's state JSON and refresh the - * board. If `git` rejects due to uncommitted changes we surface stderr - * verbatim — the user can resolve manually and re-try. + * Confirm + 递归强删 worktree 目录(removeWorktreeDir),然后清掉 state JSON 里的 + * `worktreePath`/`branch` 并刷新看板。删除失败(如权限、护栏拦截)把错误弹给用户。 */ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumber: number, relPath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath @@ -94,25 +92,11 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe }) try { - await new Promise((resolve, reject) => { - execFile( - 'git', - ['-C', workspaceRoot, 'worktree', 'remove', abs], - { timeout: 30_000 }, - (err, _stdout, stderr) => { - if (err) { - const detail = (stderr ?? '').trim() || err.message - reject(new Error(detail)) - return - } - resolve() - }, - ) - }) + await removeWorktreeDir(workspaceRoot, abs) } catch (err) { const message = err instanceof Error ? err.message : String(err) - void window.showErrorMessage(`git worktree remove 失败: ${message}`) + void window.showErrorMessage(`删除 worktree 失败: ${message}`) return }