Files
superwork/vscode/src/panel/handlers/worktree.ts
T

336 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
})
}
}