336 lines
13 KiB
TypeScript
336 lines
13 KiB
TypeScript
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 { getToken } from '../../auth/secrets'
|
||
import { detectRepo } from '../../git/remote'
|
||
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 '../KanbanPanel'
|
||
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<void> {
|
||
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<void> {
|
||
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)。受保护分支跳过、缺 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,
|
||
remote: remote ? { host: remote.host, owner: remote.owner, repo: remote.repo } : undefined,
|
||
token,
|
||
})
|
||
|
||
// 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 <branch>`, 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<void> {
|
||
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<string>((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<void> {
|
||
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<void> {
|
||
try {
|
||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||
if (!workspaceRoot)
|
||
return
|
||
const remote = await detectRepo(workspaceRoot)
|
||
if (!remote)
|
||
return
|
||
const token = await getToken(panel.context, remote.host)
|
||
if (!token)
|
||
return
|
||
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),
|
||
})
|
||
}
|
||
}
|