feat(vscode): 手动删除 worktree 时连同 feature 分支一起删(本地+远程)

抽出共享 cleanupFeatureBranch(handlers/branchCleanup.ts):受保护分支跳过、
远程走 gitea API、本地强删(git branch -D),全程 best-effort 只记日志。
「拖到完成」的分支清理改为复用它(71 行 → 12 行);handleDeleteWorktree 在
删完 worktree 目录后同样调用它,确认框与完成提示带上分支名。

放在 handlers 层而非 git/branchSync.ts:后者刻意不依赖 vscode(有单测覆盖
deleteLocalBranch),本函数需 logger(→vscode)+ gitea API。
This commit is contained in:
2026-07-08 19:05:48 +08:00
parent 045e203590
commit f39e61aa7f
3 changed files with 108 additions and 71 deletions
@@ -0,0 +1,72 @@
import { deleteLocalBranch } from '../../git/branchSync'
import { deleteBranch } from '../../gitea/api'
import { logger } from '../../logging/logger'
/**
* 清理一个 feature 分支(远程 + 本地),供「拖到完成」和「手动删除 worktree」两处共用。
*
* - 受保护分支(main/master/devBranch/autoBuildBranch)直接跳过,绝不删。
* - 远程删除走 gitea API,需 remote + token;缺任一则只删本地,不阻断。
* - 本地删除是强删(deleteLocalBranch 内部 `git branch -D`):调用方语义是「连分支一起删」,
* 未合并也照删。
* - 全程 best-effort:远程 / 本地各自 try/catch,失败只记日志——分支清理从不回滚已完成的主流程
* worktree 已删、PR 已合并)。branch 为空直接返回。
*
* 放在 handlers 层而非 `git/branchSync.ts`:后者刻意不依赖 `vscode`(有单测覆盖
* deleteLocalBranch),而本函数要用 logger(→ vscode)和 gitea API。
*/
export async function cleanupFeatureBranch(opts: {
workspaceRoot: string
branch: string
issueNumber: number
devBranch?: string
autoBuildBranch?: string
/** 缺 remote 或 token 时跳过远程删除,只删本地。 */
remote?: { host: string, owner: string, repo: string }
token?: string
}): Promise<void> {
const { workspaceRoot, branch, issueNumber, devBranch, autoBuildBranch, remote, token } = opts
if (!branch)
return
const protectedBranches = new Set(
['main', 'master', devBranch, autoBuildBranch].filter((b): b is string => Boolean(b)),
)
if (protectedBranches.has(branch)) {
logger.add({ level: 'warn', source: 'panel', message: `跳过受保护分支清理 ${branch} (issue #${issueNumber})` })
return
}
if (remote && token) {
try {
await deleteBranch({ host: remote.host, token, owner: remote.owner, repo: remote.repo, branch })
logger.add({ level: 'info', source: 'panel', message: `已删除远程 feature 分支 ${branch} (issue #${issueNumber})` })
}
catch (err) {
logger.add({
level: 'warn',
source: 'panel',
message: `删除远程 feature 分支失败 ${branch} (issue #${issueNumber})`,
details: err instanceof Error ? err.message : String(err),
})
}
}
try {
const deleted = await deleteLocalBranch(workspaceRoot, branch)
logger.add({
level: deleted.ok ? 'info' : 'warn',
source: 'panel',
message: `${deleted.ok ? '已删除' : '删除失败'}本地 feature 分支 ${branch} (issue #${issueNumber})`,
details: deleted.stderr || deleted.stdout,
})
}
catch (err) {
logger.add({
level: 'warn',
source: 'panel',
message: `删除本地 feature 分支抛错 ${branch} (issue #${issueNumber})`,
details: err instanceof Error ? err.message : String(err),
})
}
}
+11 -66
View File
@@ -13,7 +13,7 @@ import { buildCcCommand } from '../../cc/ccCommand'
import { getBrainstormPrompt } from '../../cc/prompts' import { getBrainstormPrompt } from '../../cc/prompts'
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
import { spawnClaude } from '../../cc/spawnClaude' import { spawnClaude } from '../../cc/spawnClaude'
import { deleteLocalBranch, gitFetch } from '../../git/branchSync' import { gitFetch } from '../../git/branchSync'
import { detectRepo } from '../../git/remote' import { detectRepo } from '../../git/remote'
import { import {
addDependency, addDependency,
@@ -33,6 +33,7 @@ import { getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator' import { webhookCoordinator } from '../../webhook/coordinator'
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor' import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
import { DEFAULT_PROFILE_PATH, makeNonce, PR_DIFF_SUMMARY_PROFILE_PATH } from '../KanbanPanel' import { DEFAULT_PROFILE_PATH, makeNonce, PR_DIFF_SUMMARY_PROFILE_PATH } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import * as sessions from './sessions' import * as sessions from './sessions'
export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> { export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> {
@@ -509,71 +510,15 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
if (featureBranch) { if (featureBranch) {
const settingsForBranchCleanup = getSettings(panel.context) const settingsForBranchCleanup = getSettings(panel.context)
const protectedBranches = new Set( await cleanupFeatureBranch({
['main', 'master', settingsForBranchCleanup.devBranch, settingsForBranchCleanup.autoBuildBranch] workspaceRoot,
.filter((branch): branch is string => Boolean(branch)), branch: featureBranch,
) issueNumber,
if (protectedBranches.has(featureBranch)) { devBranch: settingsForBranchCleanup.devBranch,
logger.add({ autoBuildBranch: settingsForBranchCleanup.autoBuildBranch,
level: 'warn', remote,
source: 'panel', token,
message: `跳过受保护分支清理 ${featureBranch} (issue #${issueNumber})`, })
})
}
else {
try {
await deleteBranch({
host: remote.host,
token,
owner: remote.owner,
repo: remote.repo,
branch: featureBranch,
})
logger.add({
level: 'info',
source: 'panel',
message: `已删除远程 feature 分支 ${featureBranch} (issue #${issueNumber})`,
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'panel',
message: `删除远程 feature 分支失败 ${featureBranch} (issue #${issueNumber})`,
details: message,
})
}
try {
const deleted = await deleteLocalBranch(workspaceRoot, featureBranch)
if (deleted.ok) {
logger.add({
level: 'info',
source: 'panel',
message: `已删除本地 feature 分支 ${featureBranch} (issue #${issueNumber})`,
details: deleted.stdout || deleted.stderr,
})
}
else {
logger.add({
level: 'warn',
source: 'panel',
message: `删除本地 feature 分支失败 ${featureBranch} (issue #${issueNumber})`,
details: deleted.stderr || deleted.stdout,
})
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'panel',
message: `删除本地 feature 分支抛错 ${featureBranch} (issue #${issueNumber})`,
details: message,
})
}
}
} }
// 6. 全部成功 → 增量推 done + 清掉 worktreePath + 标记 prMerged。 // 6. 全部成功 → 增量推 done + 清掉 worktreePath + 标记 prMerged。
+25 -5
View File
@@ -14,6 +14,7 @@ import {
import { logger } from '../../logging/logger' import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store' import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel' import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
/** /**
* Open the worktree directory in a **new** VS Code window. The Boolean * Open the worktree directory in a **new** VS Code window. The Boolean
@@ -59,8 +60,10 @@ export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: str
} }
/** /**
* Confirm + 递归强删 worktree 目录(removeWorktreeDir,然后清掉 state JSON 里的 * Confirm + 递归强删 worktree 目录(removeWorktreeDir清掉 state JSON 里的
* `worktreePath`/`branch` 并刷新看板。删除失败(如权限、护栏拦截)把错误弹给用户。 * `worktreePath`/`branch` → 连同该工单的 feature 分支一起删(本地 + 远程,best-effort
* → 刷新看板。目录删除失败(权限 / 护栏拦截)把错误弹给用户;分支清理失败只记日志、
* 不阻断——与「拖到完成」的清理行为一致。
*/ */
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
@@ -68,8 +71,11 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe
void window.showErrorMessage('请先打开一个工作区文件夹') void window.showErrorMessage('请先打开一个工作区文件夹')
return return
} }
// 先读分支名:确认框要提示「连分支一起删」,删完目录后也据此清分支。
const state = await panel.readIssueState(issueNumber)
const branch = typeof state.branch === 'string' ? state.branch : ''
const choice = await window.showWarningMessage( const choice = await window.showWarningMessage(
`确认删除 worktree ${relPath}?`, branch ? `确认删除 worktree ${relPath} 及其分支 ${branch}?` : `确认删除 worktree ${relPath}?`,
{ modal: true }, { modal: true },
'删除', '删除',
) )
@@ -85,7 +91,7 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe
await dispatchWorktreeHook(panel, 'pre-remove', { await dispatchWorktreeHook(panel, 'pre-remove', {
workspaceRoot, workspaceRoot,
worktreePath: abs, worktreePath: abs,
branch: '', branch,
issueNumber, issueNumber,
mainBranch: settingsForHook.devBranch || 'main', mainBranch: settingsForHook.devBranch || 'main',
customScriptPath: settingsForHook.worktreePreRemoveScript, customScriptPath: settingsForHook.worktreePreRemoveScript,
@@ -110,6 +116,20 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe
console.warn('[superpowers] failed to clear worktree state JSON:', err) console.warn('[superpowers] failed to clear worktree state JSON:', err)
} }
// 连同 feature 分支一起删(本地 + 远程,best-effort)。受保护分支跳过、缺 token 只删本地,
// 逻辑与「拖到完成」共用同一个 cleanupFeatureBranch。
const remote = await detectRepo(workspaceRoot)
const token = remote ? await getToken(panel.context, remote.host) : undefined
await cleanupFeatureBranch({
workspaceRoot,
branch,
issueNumber,
devBranch: settingsForHook.devBranch,
autoBuildBranch: settingsForHook.autoBuildBranch,
remote: remote ? { host: remote.host, owner: remote.owner, repo: remote.repo } : undefined,
token,
})
panel.postMessage({ panel.postMessage({
type: 'issue/patch', type: 'issue/patch',
issueNumber, issueNumber,
@@ -119,7 +139,7 @@ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumbe
worktreeExists: false, worktreeExists: false,
}, },
}) })
void window.showInformationMessage(`已删除 worktree #${issueNumber}`) void window.showInformationMessage(`已删除 worktree #${issueNumber}${branch ? `(含分支 ${branch}` : ''}`)
} }
/** /**