import type { HookContext } from '../../git/worktreeHooks' import type { KanbanWebviewPanel } from '../KanbanPanel' import { execFile } from 'node:child_process' import { commands, Uri, window, workspace } from 'vscode' import { resolveForge } from '../../forge/context' import { expandTilde, killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree' import { runImplTabPostCloseHook, runImplTabPreCreateHook, runPostCreateHook, runPreRemoveHook, } from '../../git/worktreeHooks' import { logger } from '../../logging/logger' import { getSettings } from '../../settings/store' import { makeNonce } from '../../webview/html' import { cleanupFeatureBranch } from './branchCleanup' /** * Open the worktree directory in a **new** VS Code window. The Boolean * third arg to `vscode.openFolder` is "forceNewWindow"; we always force * a new window so the user keeps the kanban window open in parallel. */ export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } // 存量工单可能存了带 `~` 的 worktreePath,必须经 resolveWorktreePath 展开, // 否则 path.join(workspaceRoot, '~/...') 会拼出不存在的路径。 const abs = resolveWorktreePath(relPath, workspaceRoot) // PyCharm 分支用 JetBrains 启动器打开目录。错误在回调里自行处理,不要落进 // 下面 openFolder 的 catch。 const { worktreeOpenWith, pycharmCommand } = getSettings(panel.context) if (worktreeOpenWith === 'pycharm') { // GUI 启动的 VS Code 进程 PATH 常不含 JetBrains 启动器,故命令可配置; // 允许填 `~`/绝对路径,execFile 不走 shell,`~` 需自己展开。 const cmd = expandTilde(pycharmCommand) execFile(cmd, [abs], (err) => { if (err) { const code = (err as NodeJS.ErrnoException).code ?? 'unknown' const message = code === 'ENOENT' ? `找不到 ${cmd} 命令,请在「PyCharm 启动命令」里填启动器的完整路径,或确保它在 PATH 中` : err.message void window.showErrorMessage(`用 PyCharm 打开失败: ${message}`) } }) return } try { await commands.executeCommand('vscode.openFolder', Uri.file(abs), true) } catch (err) { const message = err instanceof Error ? err.message : String(err) void window.showErrorMessage(`打开 worktree 失败: ${message}`) } } /** * Confirm + 递归强删 worktree 目录(removeWorktreeDir)→ 清掉 state JSON 里的 * `worktreePath`/`branch` → 连同该工单的 feature 分支一起删(本地 + 远程,best-effort) * → 刷新看板。目录删除失败(权限 / 护栏拦截)把错误弹给用户;分支清理失败只记日志、 * 不阻断——与「拖到完成」的清理行为一致。 */ export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumber: number, relPath: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } // 先读分支名:确认框要提示「连分支一起删」,删完目录后也据此清分支。 const state = await panel.readIssueState(issueNumber) const branch = typeof state.branch === 'string' ? state.branch : '' const choice = await window.showWarningMessage( branch ? `确认删除 worktree ${relPath} 及其分支 ${branch}?` : `确认删除 worktree ${relPath}?`, { modal: true }, '删除', ) if (choice !== '删除') return const abs = resolveWorktreePath(relPath, workspaceRoot) // Run the pre-remove lifecycle hook so the user can tear down resources // (close IDE windows, etc.) before the worktree dir vanishes. Best-effort // — never blocks the removal. const settingsForHook = getSettings(panel.context) await dispatchWorktreeHook(panel, 'pre-remove', { workspaceRoot, worktreePath: abs, branch, issueNumber, mainBranch: settingsForHook.devBranch || 'main', customScriptPath: settingsForHook.worktreePreRemoveScript, }) // 先杀占用进程(dev server 等),再删目录,避免 ENOTEMPTY 竞态。 const killed = killProcessesUsingWorktree(abs) if (killed.length > 0) { const list = killed .map(h => `pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`) .join('\n') logger.add({ level: 'warn', source: 'panel', message: `删除 worktree 前已杀掉占用进程 (issue #${issueNumber})`, details: list, }) } try { await removeWorktreeDir(workspaceRoot, abs) } catch (err) { const message = err instanceof Error ? err.message : String(err) void window.showErrorMessage(`删除 worktree 失败: ${message}`) return } // Best-effort: clear worktreePath + branch from the state JSON so the // detail panel reflects reality on the next refresh. Failures here are // non-fatal (worktree is already gone on disk). try { await panel.mergeIssueState(issueNumber, { worktreePath: '', branch: '' }) } catch (err) { console.warn('[superpowers] failed to clear worktree state JSON:', err) } // 连同 feature 分支一起删(本地 + 远程,best-effort)。受保护分支跳过、解析不出代码托管仓库只删本地, // 逻辑与「拖到完成」共用同一个 cleanupFeatureBranch。 const resolved = await resolveForge(panel.context) await cleanupFeatureBranch({ workspaceRoot, branch, issueNumber, devBranch: settingsForHook.devBranch, forge: resolved.ok ? resolved.forge : undefined, }) // null 才能过 postMessage 序列化;undefined 会被丢掉,webview 合并不清字段 panel.postMessage({ type: 'issue/patch', issueNumber, patch: { worktreePath: null, branch: null, worktreeExists: false, }, }) void window.showInformationMessage(`已删除 worktree #${issueNumber}${branch ? `(含分支 ${branch})` : ''}`) } /** * Pre-merge a feature branch into the **main worktree** (workspace root) so the * user can inspect/test the merge result locally before committing. Runs * `git merge --no-commit --no-ff `, which never auto-commits and always * forces a merge commit structure. Feedback is surfaced via notifications. */ export async function handleMergePreview(panel: KanbanWebviewPanel, issueNumber: number, branch: string): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') return } if (!branch) { void window.showWarningMessage('该工单没有分支') return } // Best-effort: figure out the branch currently checked out in the main // worktree so the success message can warn about merging into the wrong // place. Never blocks the merge. const currentBranch = await new Promise((resolve) => { execFile( 'git', ['-C', workspaceRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout: 10_000 }, (err, stdout) => { resolve(err ? '?' : (stdout ?? '').trim() || '?') }, ) }) // `git merge` reports conflicts on stdout ("CONFLICT ...") and the // "Automatic merge failed" line on stderr with a non-zero exit code, so we // judge success solely by `err == null`. const result = await new Promise<{ err: Error | null, output: string }>((resolve) => { execFile( 'git', ['-C', workspaceRoot, 'merge', '--no-commit', '--no-ff', branch], { timeout: 60_000 }, (err, stdout, stderr) => { const output = `${stdout ?? ''}${stderr ?? ''}`.trim() resolve({ err: err ?? null, output }) }, ) }) if (result.err == null) { void window.showInformationMessage(`已把 ${branch} 预合并到当前分支 ${currentBranch}(未提交)。检查无误后自行 commit,或执行 git merge --abort 撤销。`) return } const detail = result.output || result.err.message logger.add({ level: 'warn', source: 'panel', message: `git merge --no-commit --no-ff ${branch} 未完成(issue #${issueNumber})`, details: detail, }) void window.showWarningMessage(`git merge 未完成(冲突或出错): ${detail}。如需撤销执行 git merge --abort`) } /** * Run a user-provided shell script (post-create / pre-remove) and surface * the outcome via logger + a toast. Always returns — failures of any * kind never abort the calling flow, per the lifecycle-hook contract: * worktree creation / removal is the source of truth, user scripts are * best-effort sidecars. */ export async function dispatchWorktreeHook( panel: KanbanWebviewPanel, phase: 'post-create' | 'pre-remove' | 'impl-tab-pre-create' | 'impl-tab-post-close', ctx: HookContext, ): Promise { let result switch (phase) { case 'post-create': result = await runPostCreateHook(ctx) break case 'pre-remove': result = await runPreRemoveHook(ctx) break case 'impl-tab-pre-create': result = await runImplTabPreCreateHook(ctx) break case 'impl-tab-post-close': result = await runImplTabPostCloseHook(ctx) break } // 'skipped' = the script simply isn't on disk. That's the intended // default state — stay silent, don't pester the user. if (result.status === 'skipped') return if (result.status === 'ok') { logger.add({ level: 'info', source: 'panel', message: `worktree ${phase} 钩子完成 #${ctx.issueNumber}`, details: `path=${result.scriptPath}\nstdout=${result.stdout ?? ''}\nstderr=${result.stderr ?? ''}`, }) return } // Anything else (failed / timeout / enoent) — warn-level log + a // toast. ToastLevel only has 'info' | 'success' | 'error'; we use // 'info' to keep it non-blocking, matching the "doesn't affect main // flow" semantics. Detailed stdout/stderr stays in the log to avoid // spamming the toast surface. const label = { 'post-create': '创建后', 'pre-remove': '删除前', 'impl-tab-pre-create': '实施 tab 创建前', 'impl-tab-post-close': '实施 tab 关闭后', }[phase] logger.add({ level: 'warn', source: 'panel', message: `worktree ${phase} 钩子失败 #${ctx.issueNumber}: ${result.status}`, details: [ `path=${result.scriptPath ?? '(unresolved)'}`, result.exitCode !== undefined ? `exitCode=${result.exitCode}` : null, result.errorMessage ? `err=${result.errorMessage}` : null, result.stdout ? `stdout=${result.stdout}` : null, result.stderr ? `stderr=${result.stderr}` : null, ].filter((line): line is string => line !== null).join('\n'), }) panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'info', message: `worktree ${label}钩子失败 #${ctx.issueNumber}(不影响后续流程,详情见日志)`, dismissOnTimer: 5000, }) } /** * 实施 cc tab 被关闭后异步触发 impl-tab-post-close 钩子。 * 由 onDidCloseTerminal 同步回调里 fire-and-forget 调用,所以这里把整个 * 错误 catch 进 log,不向上抛。需要从 state JSON 读 worktreePath / branch。 */ export async function dispatchImplTabPostCloseAsync(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { const resolved = await resolveForge(panel.context) if (!resolved.ok) return const { workspaceRoot } = resolved.forge const stateObj = await panel.readIssueState(issueNumber) const branch = typeof stateObj.branch === 'string' ? stateObj.branch : '' const wt = typeof stateObj.worktreePath === 'string' ? stateObj.worktreePath : '' if (!wt) return const absWorktree = resolveWorktreePath(wt, workspaceRoot) const settings = getSettings(panel.context) await dispatchWorktreeHook(panel, 'impl-tab-post-close', { workspaceRoot, worktreePath: absWorktree, branch, issueNumber, mainBranch: settings.devBranch || 'main', customScriptPath: settings.implTabPostCloseScript, }) } catch (err) { logger.add({ level: 'warn', source: 'panel', message: `impl-tab-post-close 钩子调度失败 #${issueNumber}`, details: err instanceof Error ? err.message : String(err), }) } }