🐛 fix(vscode): worktree 清理改 rm -rf + prune,绕开 gitignored 产物导致的目录非空
git worktree remove 只删跟踪文件+admin 注册,不删 .next/node_modules 等 gitignored 产物,删完残留把目录撑住、最后 rmdir 撞 ENOTEMPTY。新增 removeWorktreeDir(带路径护栏的递归强删 + git worktree prune),替换 3 处 worktree remove 调用点。
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFile } from 'node:child_process'
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
|
|
||||||
@@ -65,3 +66,38 @@ export async function createWorktree(opts: WorktreeOpts): Promise<void> {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一个 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import type { IssueColumn } from '../../gitea/types'
|
import type { IssueColumn } from '../../gitea/types'
|
||||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||||
import { Buffer } from 'node:buffer'
|
import { Buffer } from 'node:buffer'
|
||||||
import { execFile } from 'node:child_process'
|
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
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 { commands, env, ThemeColor, Uri, window, workspace } from 'vscode'
|
||||||
import { getToken } from '../../auth/secrets'
|
import { getToken } from '../../auth/secrets'
|
||||||
import { buildCcCommand } from '../../cc/ccCommand'
|
import { buildCcCommand } from '../../cc/ccCommand'
|
||||||
@@ -450,9 +449,8 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
if (worktreePath) {
|
if (worktreePath) {
|
||||||
const abs = resolveWorktreePath(worktreePath, workspaceRoot)
|
const abs = resolveWorktreePath(worktreePath, workspaceRoot)
|
||||||
if (fs.existsSync(abs)) {
|
if (fs.existsSync(abs)) {
|
||||||
// worktree 还被占用时(实施/测试会话终端 cwd 在里面、cc/codex 持有 git
|
// 删前先 dispose 该工单的全部会话终端并等一小会:实施/测试会话可能仍在
|
||||||
// 锁或打开文件)git worktree remove 会失败。先 dispose 该工单的全部会话
|
// worktree 里跑(写构建产物、持有 cwd),先让它们退出,避免边删边被写。
|
||||||
// 终端,再等一小会让进程退出释放占用,然后再删。
|
|
||||||
let disposedAny = false
|
let disposedAny = false
|
||||||
for (const [terminal, origin] of panel.terminalOrigin) {
|
for (const [terminal, origin] of panel.terminalOrigin) {
|
||||||
if (origin.issueNumber === issueNumber) {
|
if (origin.issueNumber === issueNumber) {
|
||||||
@@ -481,21 +479,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await removeWorktreeDir(workspaceRoot, abs)
|
||||||
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'panel',
|
source: 'panel',
|
||||||
@@ -956,7 +940,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 删 worktree(如有)— git worktree remove --force,失败立即停
|
// 5. 删 worktree(如有)— 递归强删目录(removeWorktreeDir),失败立即停
|
||||||
if (worktreePath) {
|
if (worktreePath) {
|
||||||
const absWorktree = resolveWorktreePath(worktreePath, workspaceRoot)
|
const absWorktree = resolveWorktreePath(worktreePath, workspaceRoot)
|
||||||
if (fs.existsSync(absWorktree)) {
|
if (fs.existsSync(absWorktree)) {
|
||||||
@@ -972,21 +956,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await removeWorktreeDir(workspaceRoot, absWorktree)
|
||||||
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { execFile } from 'node:child_process'
|
|||||||
import { commands, Uri, window, workspace } from 'vscode'
|
import { commands, Uri, window, workspace } from 'vscode'
|
||||||
import { getToken } from '../../auth/secrets'
|
import { getToken } from '../../auth/secrets'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
import { expandTilde, resolveWorktreePath } from '../../git/worktree'
|
import { expandTilde, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||||
import {
|
import {
|
||||||
runImplTabPostCloseHook,
|
runImplTabPostCloseHook,
|
||||||
runImplTabPreCreateHook,
|
runImplTabPreCreateHook,
|
||||||
@@ -59,10 +59,8 @@ export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm + run `git worktree remove <abs>` (no --force), then clear
|
* Confirm + 递归强删 worktree 目录(removeWorktreeDir),然后清掉 state JSON 里的
|
||||||
* `worktreePath`/`branch` from the issue's state JSON and refresh the
|
* `worktreePath`/`branch` 并刷新看板。删除失败(如权限、护栏拦截)把错误弹给用户。
|
||||||
* board. If `git` rejects due to uncommitted changes we surface stderr
|
|
||||||
* verbatim — the user can resolve manually and re-try.
|
|
||||||
*/
|
*/
|
||||||
export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumber: number, relPath: string): Promise<void> {
|
export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumber: number, relPath: string): Promise<void> {
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
@@ -94,25 +92,11 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await removeWorktreeDir(workspaceRoot, abs)
|
||||||
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()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
void window.showErrorMessage(`git worktree remove 失败: ${message}`)
|
void window.showErrorMessage(`删除 worktree 失败: ${message}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user