From 0d078e848cfa925b2fb84ab39b8ce925cd9efea8 Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 31 Jul 2026 06:52:09 +0800 Subject: [PATCH] 11 --- vscode/package.json | 2 +- vscode/src/git/branchSync.test.ts | 87 ++++++++++++++++++++++++++ vscode/src/git/branchSync.ts | 40 +++++++++++- vscode/src/gitea/api.ts | 5 +- vscode/src/panel/handlers/issues.ts | 95 ++++++++++++++++------------- vscode/test/gitea/stateJson.test.ts | 29 +++++++-- 6 files changed, 208 insertions(+), 50 deletions(-) create mode 100644 vscode/src/git/branchSync.test.ts diff --git a/vscode/package.json b/vscode/package.json index b01137b..401b0fa 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.66", + "version": "0.2.70", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/git/branchSync.test.ts b/vscode/src/git/branchSync.test.ts new file mode 100644 index 0000000..2bc0954 --- /dev/null +++ b/vscode/src/git/branchSync.test.ts @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { deleteLocalBranch, resolveFeatureBranch } from './branchSync' + +const tempDirs: string[] = [] + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'branch-sync-')) + tempDirs.push(dir) + return dir +} + +function git(cwd: string, args: string[]): void { + execFileSync('git', ['-C', cwd, ...args], { stdio: 'ignore' }) +} + +function seedCommit(root: string): void { + writeFileSync(path.join(root, 'f'), 'a\n') + git(root, ['add', 'f']) + git(root, ['commit', '-m', 'i']) +} + +function initRepoWithWorktree(branch: string): { root: string, worktree: string } { + const root = tempDir() + git(root, ['init', '-b', 'main']) + git(root, ['config', 'user.email', 't@t.com']) + git(root, ['config', 'user.name', 't']) + seedCommit(root) + const worktree = path.join(root, 'wt') + git(root, ['worktree', 'add', worktree, '-b', branch]) + return { root, worktree } +} + +describe('resolveFeatureBranch', () => { + it('优先用 state.branch', () => { + expect(resolveFeatureBranch({ stateBranch: 'feature/abc', prHeadRef: 'feature/other' })) + .toBe('feature/abc') + }) + + it('state 缺 branch 时回退 PR head.ref', () => { + expect(resolveFeatureBranch({ stateBranch: '', prHeadRef: 'feature/from-pr' })) + .toBe('feature/from-pr') + }) + + it('不回退到 main/master', () => { + expect(resolveFeatureBranch({ stateBranch: undefined, prHeadRef: 'main' })).toBe('') + expect(resolveFeatureBranch({ stateBranch: ' ', prHeadRef: 'master' })).toBe('') + }) +}) + +describe('deleteLocalBranch', () => { + it('强删 worktree 目录后仍能删掉本地 feature 分支', async () => { + const branch = 'feature/leftover' + const { root, worktree } = initRepoWithWorktree(branch) + + // 复现完成列路径:rm -rf 目录(可能留下 prunable 注册) + rmSync(worktree, { recursive: true, force: true }) + + const result = await deleteLocalBranch(root, branch) + expect(result.ok).toBe(true) + + const heads = execFileSync('git', ['-C', root, 'branch', '--list', branch], { encoding: 'utf8' }).trim() + expect(heads).toBe('') + }) + + it('分支不存在时视为已清理', async () => { + const root = tempDir() + git(root, ['init', '-b', 'main']) + git(root, ['config', 'user.email', 't@t.com']) + git(root, ['config', 'user.name', 't']) + seedCommit(root) + + const result = await deleteLocalBranch(root, 'feature/missing') + expect(result.ok).toBe(true) + }) +}) diff --git a/vscode/src/git/branchSync.ts b/vscode/src/git/branchSync.ts index 930cde4..00f92f9 100644 --- a/vscode/src/git/branchSync.ts +++ b/vscode/src/git/branchSync.ts @@ -62,18 +62,54 @@ export async function gitFetch(workspaceRoot: string): Promise<{ ok: boolean, st return { ok: r.ok, stderr: r.stderr, stdout: r.stdout } } +/** + * 解析要清理的 feature 分支名。 + * state.branch 优先;缺省时回退 PR head.ref。永不回退到 main/master。 + */ +export function resolveFeatureBranch(opts: { + stateBranch?: string | null + prHeadRef?: string | null +}): string { + const fromState = (opts.stateBranch ?? '').trim() + if (fromState) + return fromState + const fromPr = (opts.prHeadRef ?? '').trim() + if (!fromPr || fromPr === 'main' || fromPr === 'master') + return '' + return fromPr +} + /** * 删除本地分支。分支不存在时视为已清理,避免重复完成流程产生噪音。 + * + * 完成列路径会先 `rm -rf` worktree 再 prune;若 prune 未生效,git 仍认为 + * 分支被 worktree 占用,`branch -D` 会失败。这里在失败时再 prune 一次并重试。 + * 同时清掉 `refs/remotes/origin/`,避免 UI 里仍显示远程残留。 */ export async function deleteLocalBranch(workspaceRoot: string, branch: string): Promise<{ ok: boolean, stdout: string, stderr: string }> { const exists = await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) - if (!exists.ok) + if (!exists.ok) { + await pruneRemoteTrackingRef(workspaceRoot, branch) return { ok: true, stdout: '', stderr: '' } + } - const r = await runGit(workspaceRoot, ['branch', '-D', branch]) + let r = await runGit(workspaceRoot, ['branch', '-D', branch]) + if (!r.ok) { + await runGit(workspaceRoot, ['worktree', 'prune']) + r = await runGit(workspaceRoot, ['branch', '-D', branch]) + } + await pruneRemoteTrackingRef(workspaceRoot, branch) return { ok: r.ok, stdout: r.stdout, stderr: r.stderr } } +async function pruneRemoteTrackingRef(workspaceRoot: string, branch: string): Promise { + const ref = `refs/remotes/origin/${branch}` + const exists = await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', ref]) + if (!exists.ok) + return + await runGit(workspaceRoot, ['update-ref', '-d', ref]) +} + export interface CheckBranchSyncOpts { workspaceRoot: string /** Already non-empty (caller falls back to devBranch when autoBuild is ''). */ diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index e987e58..d92c673 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -218,6 +218,8 @@ export interface GiteaPullRequest { body: string /** head 分支最新提交 sha:PR diff 的右侧 ref。 */ headSha: string + /** head 分支名,如 `feature/abcd`;完成列清理分支时作 state.branch 兜底。 */ + headRef: string /** base 分支 sha:mergeBase 取不到时的左侧 ref 兜底。 */ baseSha: string /** base 与 head 的合并基:PR diff 的左侧 ref(三点 diff,与 Gitea 网页口径一致)。 */ @@ -248,7 +250,7 @@ export async function getPullRequest(opts: { merged_at?: unknown html_url?: unknown body?: unknown - head?: { sha?: unknown } + head?: { sha?: unknown, ref?: unknown } base?: { sha?: unknown } merge_base?: unknown } @@ -260,6 +262,7 @@ export async function getPullRequest(opts: { html_url: typeof data.html_url === 'string' ? data.html_url : '', body: typeof data.body === 'string' ? data.body : '', headSha: typeof data.head?.sha === 'string' ? data.head.sha : '', + headRef: typeof data.head?.ref === 'string' ? data.head.ref : '', baseSha: typeof data.base?.sha === 'string' ? data.base.sha : '', mergeBase: typeof data.merge_base === 'string' ? data.merge_base : '', } diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index a33d503..f4a7558 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -13,7 +13,7 @@ import { buildCcCommand } from '../../cc/ccCommand' import { getBrainstormPrompt } from '../../cc/prompts' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { spawnClaude } from '../../cc/spawnClaude' -import { gitFetch } from '../../git/branchSync' +import { gitFetch, resolveFeatureBranch } from '../../git/branchSync' import { detectRepo } from '../../git/remote' import { addDependency, @@ -22,7 +22,6 @@ import { deleteIssue, getPullRequest, GiteaApiError, - listIssueComments, mergePullRequest, removeDependency, } from '../../gitea/api' @@ -101,51 +100,37 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: return } - // 1. Re-fetch latest state JSON for this issue. + // 1. 读最新 state JSON(从尾往前找已知字段,避免普通评论盖住 branch)。 let prStr: string | undefined let worktreePath: string | undefined let fromColumn: IssueColumn | undefined let implementSessionId: string | undefined let testSessionId: string | undefined - let featureBranch: string | undefined + let featureBranchFromState: string | undefined try { - const comments = await listIssueComments({ + const stateObj = await readStateJsonComment({ host: remote.host, token, owner: remote.owner, repo: remote.repo, - index: issueNumber, + issueNumber, }) - if (comments.length > 0) { - const lastBody = (comments[comments.length - 1].body ?? '').trim() - if (lastBody) { - try { - const parsed = JSON.parse(lastBody) as unknown - if (parsed && typeof parsed === 'object') { - const obj = parsed as Record - if (typeof obj.pr === 'string' && obj.pr.length > 0) - prStr = obj.pr - if (typeof obj.worktreePath === 'string' && obj.worktreePath.length > 0) - worktreePath = obj.worktreePath - if ( - typeof obj.column === 'string' - && ['todo', 'in-progress', 'review', 'done'].includes(obj.column) - ) { - fromColumn = obj.column as IssueColumn - } - if (typeof obj.implementSessionId === 'string' && obj.implementSessionId.length > 0) - implementSessionId = obj.implementSessionId - if (typeof obj.testSessionId === 'string' && obj.testSessionId.length > 0) - testSessionId = obj.testSessionId - if (typeof obj.branch === 'string' && obj.branch.length > 0) - featureBranch = obj.branch - } - } - catch { - // Non-JSON last comment; leave both undefined. - } - } + if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0) + prStr = stateObj.pr + if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0) + worktreePath = stateObj.worktreePath + if ( + typeof stateObj.column === 'string' + && ['todo', 'in-progress', 'review', 'done'].includes(stateObj.column) + ) { + fromColumn = stateObj.column as IssueColumn } + if (typeof stateObj.implementSessionId === 'string' && stateObj.implementSessionId.length > 0) + implementSessionId = stateObj.implementSessionId + if (typeof stateObj.testSessionId === 'string' && stateObj.testSessionId.length > 0) + testSessionId = stateObj.testSessionId + if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0) + featureBranchFromState = stateObj.branch } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -329,14 +314,18 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: message: `合并 PR #${prIndex} 失败 (issue #${issueNumber})${isConflict ? ' [冲突]' : ''}`, details: message, }) - if (isConflict && featureBranch && worktreePath) { + const conflictBranch = resolveFeatureBranch({ + stateBranch: featureBranchFromState, + prHeadRef: pullRequest.headRef, + }) + if (isConflict && conflictBranch && worktreePath) { // 走冲突解决分支:直接在实施 worktree 里 merge dev 制造冲突落地, // 再开一个临时 cc 会话让 cc 解决。fire-and-forget,不进任何 map / state JSON。 await sessions.startConflictResolution(panel, { issueNumber, prIndex, workspaceRoot, - featureBranch, + featureBranch: conflictBranch, worktreePath, }) } @@ -365,7 +354,13 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: repo: remote.repo, token, issueNumber, - extra: { column: 'done', worktreePath: '', prMerged: true, prMergedAt: pullRequest.merged_at ?? new Date().toISOString() }, + extra: { + column: 'done', + worktreePath: '', + branch: '', + prMerged: true, + prMergedAt: pullRequest.merged_at ?? new Date().toISOString(), + }, }) logger.add({ level: 'info', @@ -471,7 +466,10 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: await panel.dispatchWorktreeHook('pre-remove', { workspaceRoot, worktreePath: abs, - branch: featureBranch ?? '', + branch: resolveFeatureBranch({ + stateBranch: featureBranchFromState, + prHeadRef: pullRequest.headRef, + }), issueNumber, mainBranch: settingsForHook.devBranch || 'main', customScriptPath: settingsForHook.worktreePreRemoveScript, @@ -528,6 +526,10 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: } } + const featureBranch = resolveFeatureBranch({ + stateBranch: featureBranchFromState, + prHeadRef: pullRequest.headRef, + }) if (featureBranch) { const settingsForBranchCleanup = getSettings(panel.context) await cleanupFeatureBranch({ @@ -540,8 +542,16 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: token, }) } + else { + logger.add({ + level: 'warn', + source: 'panel', + message: `工单 #${issueNumber} 完成时未解析到 feature 分支,跳过分支清理`, + details: `state.branch=${featureBranchFromState ?? ''} pr.head.ref=${pullRequest.headRef || ''}`, + }) + } - // 6. 全部成功 → 增量推 done + 清掉 worktreePath + 标记 prMerged。 + // 6. 全部成功 → 增量推 done + 清掉 worktreePath/branch + 标记 prMerged。 // webview 端 `{ ...issue, ...patch }` spread 会把 worktreePath 覆盖成 // undefined,详情面板的 worktree 链接行因此消失。 panel.postMessage({ @@ -550,6 +560,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: patch: { column: 'done', worktreePath: undefined, + branch: undefined, prMerged: true, prMergedAt: pullRequest.merged_at ?? new Date().toISOString(), }, @@ -558,7 +569,9 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: type: 'toast/show', id: makeNonce(), level: 'success', - message: `工单 #${issueNumber} 已完成,worktree 已清理`, + message: featureBranch + ? `工单 #${issueNumber} 已完成,worktree 与分支 ${featureBranch} 已清理` + : `工单 #${issueNumber} 已完成,worktree 已清理`, dismissOnTimer: 5000, }) } diff --git a/vscode/test/gitea/stateJson.test.ts b/vscode/test/gitea/stateJson.test.ts index 62803b1..63074c9 100644 --- a/vscode/test/gitea/stateJson.test.ts +++ b/vscode/test/gitea/stateJson.test.ts @@ -285,15 +285,20 @@ describe('deleteLocalBranch', () => { }) it('treats a missing local branch as already deleted', async () => { - execFile.mockImplementationOnce((_cmd, _args, _opts, cb) => { - cb(new Error('missing'), '', '') - }) + // heads 不存在 + remote-tracking 也不存在 + execFile + .mockImplementationOnce((_cmd, _args, _opts, cb) => { + cb(new Error('missing'), '', '') + }) + .mockImplementationOnce((_cmd, _args, _opts, cb) => { + cb(new Error('missing remote'), '', '') + }) const { deleteLocalBranch } = await import('../../src/git/branchSync.js') const result = await deleteLocalBranch('/repo', 'feature/test') expect(result).toEqual({ ok: true, stdout: '', stderr: '' }) - expect(execFile).toHaveBeenCalledOnce() + expect(execFile).toHaveBeenCalledTimes(2) expect(execFile.mock.calls[0][1]).toEqual([ '-C', '/repo', @@ -302,22 +307,36 @@ describe('deleteLocalBranch', () => { '--quiet', 'refs/heads/feature/test', ]) + expect(execFile.mock.calls[1][1]).toEqual([ + '-C', + '/repo', + 'show-ref', + '--verify', + '--quiet', + 'refs/remotes/origin/feature/test', + ]) }) it('deletes an existing local branch with git args instead of shell interpolation', async () => { execFile + // show-ref heads: exists .mockImplementationOnce((_cmd, _args, _opts, cb) => { cb(null, '', '') }) + // branch -D .mockImplementationOnce((_cmd, _args, _opts, cb) => { cb(null, 'deleted', '') }) + // show-ref remotes: missing + .mockImplementationOnce((_cmd, _args, _opts, cb) => { + cb(new Error('missing remote'), '', '') + }) const { deleteLocalBranch } = await import('../../src/git/branchSync.js') const result = await deleteLocalBranch('/repo', 'feature/weird name;rm -rf') expect(result).toEqual({ ok: true, stdout: 'deleted', stderr: '' }) - expect(execFile).toHaveBeenCalledTimes(2) + expect(execFile).toHaveBeenCalledTimes(3) expect(execFile.mock.calls[1][1]).toEqual([ '-C', '/repo',