From afdb98ad556914133591e71b80908203f80049ac Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 25 Aug 2026 16:04:45 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E5=90=AF=E5=8A=A8=E8=87=AA=E5=8A=A8=E4=B8=A2=E6=8E=89?= =?UTF-8?q?=E6=AE=8B=E7=95=99=20worktree=20=E5=86=8D=E6=96=B0=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/git/worktree.ts | 37 ++++++++++---------------- vscode/src/panel/handlers/sessions.ts | 12 +++++++++ vscode/test/git/worktreeEnsure.test.ts | 19 ++++++------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index 0edb555..ea0f8b1 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -7,6 +7,7 @@ import { execFile } from 'node:child_process' import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' +import { deleteLocalBranch } from './branchSync' /** * 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径 @@ -28,7 +29,7 @@ export function resolveWorktreePath(stored: string, workspaceRoot: string): stri export interface WorktreeOpts { workspaceRoot: string - /** 模板 path;① 已有 live worktree 时返回值可能与此不同。 */ + /** 模板 path;返回值即此路径。 */ worktreePath: string branch: string } @@ -104,43 +105,33 @@ async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): return undefined } -async function localBranchExists(workspaceRoot: string, branch: string): Promise { - try { - await runGit(workspaceRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]) - return true - } - catch { - return false - } -} - /** - * 保证这个 feature 有一个可用 worktree。优先复用 live,绝不删 feature 分支 - * —— 拖到「进行中」是开始实施,不是销毁上次进度。 + * 拖到「进行中」= 从当前 HEAD 全新实施:清 leftover worktree / 本地分支,再 `add -b`。 */ export async function ensureWorktree(opts: WorktreeOpts): Promise { const { workspaceRoot, branch } = opts const worktreePath = path.resolve(opts.worktreePath) - // ① 该 branch 已在某个 live worktree → 原路返回(path ≠ 模板也不搬,避免丢未提交改动) + // ① 该 branch 的 live worktree 可能不在模板 path → kill + 整目录删掉 const live = await findLiveWorktreeForBranch(workspaceRoot, branch) if (live) { - console.log(`[superpowers] 复用已有 worktree ${live} (${branch})`) - return live + killProcessesUsingWorktree(live) + await removeWorktreeDir(workspaceRoot, live) } - // ② 模板 path 被垃圾占着(存在,但不是本仓库该 branch 的 live worktree) + // ② 模板 path 仍被垃圾目录占着 → 同样 kill + remove,否则 ④ add 撞路径 if (await pathExists(worktreePath)) { killProcessesUsingWorktree(worktreePath) await removeWorktreeDir(workspaceRoot, worktreePath) - console.log(`[superpowers] 已清模板路径垃圾 ${worktreePath}`) } - const branchExists = await localBranchExists(workspaceRoot, branch) - const addArgs = branchExists - ? ['worktree', 'add', worktreePath, branch] // ③ 有分支无 live worktree → 不加 -b,挂上已有分支,保留 commit - : ['worktree', 'add', worktreePath, '-b', branch] // ④ 分支也不存在 → -b 新建 - await runGit(workspaceRoot, addArgs, 'git worktree add') + // ③ leftover 本地分支必须删掉;失败不能静默,否则 ④ `add -b` 撞同名分支 + const deleted = await deleteLocalBranch(workspaceRoot, branch) + if (!deleted.ok) + throw new Error(`删除本地分支失败 ${branch}: ${deleted.stderr || deleted.stdout}`) + + // ④ 始终 -b 从当前 HEAD 新建,不挂旧分支 + await runGit(workspaceRoot, ['worktree', 'add', worktreePath, '-b', branch], 'git worktree add') return worktreePath } diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index f98f48b..13b25ac 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -23,6 +23,7 @@ import { getSettings } from '../../settings/store' import { webhookCoordinator } from '../../webhook/coordinator' import { decideAutoReviewPath } from '../autoReviewDecision' import { makeNonce } from '../KanbanPanel' +import { cleanupFeatureBranch } from './branchCleanup' export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise { // kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造 @@ -798,6 +799,17 @@ export async function handleImplement( // 先保证 worktree 可用:后面写 state / 开终端都依赖实际 path。 try { + // 先清远程残留;本地 leftover 由 ensureWorktree 丢掉。必须在 add -b 之前, + // 否则 cleanup 会把刚建的新分支删掉。live worktree 上本地 -D 失败只 warn。 + await cleanupFeatureBranch({ + workspaceRoot, + branch, + issueNumber, + devBranch: settings.devBranch, + autoBuildBranch: settings.autoBuildBranch, + remote, + token, + }) // worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在; // git worktree add 不会创建多层父目录,先补齐。 await fsp.mkdir(path.dirname(worktreePath), { recursive: true }) diff --git a/vscode/test/git/worktreeEnsure.test.ts b/vscode/test/git/worktreeEnsure.test.ts index e8d39a2..cf08807 100644 --- a/vscode/test/git/worktreeEnsure.test.ts +++ b/vscode/test/git/worktreeEnsure.test.ts @@ -77,7 +77,7 @@ describe('ensureWorktree', () => { fx = undefined }) - it('本地已有 feature 分支且无 worktree 时挂上该分支并保留 commit', async () => { + it('本地已有 feature 分支且无 worktree 时丢掉旧 commit 再新建', async () => { fx = await setupRepo() git(fx.repo, ['checkout', '-b', 'feature/x']) writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\n') @@ -101,8 +101,7 @@ describe('ensureWorktree', () => { expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') - expect(git(fx.repo, ['log', '--oneline', 'main..feature/x'])).toBe(ahead) - expect(git(actual, ['rev-parse', 'HEAD'])).toBe(git(fx.repo, ['rev-parse', 'feature/x'])) + expect(git(fx.repo, ['log', '--oneline', 'main..feature/x'])).toBe('') }) it('分支和 worktree 都不存在时创建新分支 worktree', async () => { @@ -119,7 +118,7 @@ describe('ensureWorktree', () => { expect(git(fx.repo, ['show-ref', '--verify', '--quiet', 'refs/heads/feature/fresh'])).toBe('') }) - it('该 branch 已有 live worktree 时返回已有 path,不建第二个', async () => { + it('该 branch 已有 live worktree 时删掉旧 path 在模板 path 新建', async () => { fx = await setupRepo() git(fx.repo, ['branch', 'feature/x']) const existing = path.join(fx.root, 'sw-ensure', 'wt', 'already-here') @@ -132,9 +131,9 @@ describe('ensureWorktree', () => { branch: 'feature/x', }) - expect(path.resolve(actual)).toBe(path.resolve(existing)) - expect(existsSync(fx.templatePath)).toBe(false) - expect(git(existing, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') + expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) + expect(existsSync(existing)).toBe(false) + expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') const listed = git(fx.repo, ['worktree', 'list', '--porcelain']) const wtLines = listed.split('\n').filter(l => l.startsWith('worktree ')) expect(wtLines).toHaveLength(2) @@ -157,7 +156,7 @@ describe('ensureWorktree', () => { expect(existsSync(path.join(fx.templatePath, 'README.md'))).toBe(true) }) - it('无 leftover 时可重复调用,第二次返回同一 path', async () => { + it('第二次调用仍成功且在同一模板 path', async () => { fx = await setupRepo() const first = await ensureWorktree({ @@ -165,14 +164,16 @@ describe('ensureWorktree', () => { worktreePath: fx.templatePath, branch: 'feature/x', }) + writeFileSync(path.join(first, 'leftover.txt'), 'should-be-gone') const second = await ensureWorktree({ workspaceRoot: fx.repo, worktreePath: fx.templatePath, branch: 'feature/x', }) - expect(path.resolve(second)).toBe(path.resolve(first)) + expect(path.resolve(second)).toBe(path.resolve(fx.templatePath)) expect(git(second, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') + expect(existsSync(path.join(second, 'leftover.txt'))).toBe(false) const wtLines = git(fx.repo, ['worktree', 'list', '--porcelain']) .split('\n') .filter(l => l.startsWith('worktree '))