diff --git a/vscode/src/git/worktree.ts b/vscode/src/git/worktree.ts index 2fda7e2..0edb555 100644 --- a/vscode/src/git/worktree.ts +++ b/vscode/src/git/worktree.ts @@ -1,9 +1,6 @@ /** - * Thin wrapper around `git worktree add -b` for the 实施 flow. - * - * We invoke the system `git` binary via `execFile` rather than depend on a - * git library — keeps the surface minimal and lets the user benefit from - * whatever git they already have configured. + * git worktree 的薄封装:实施流用 ensureWorktree 保证「这个 feature 有一个可用 + * worktree」,不依赖 git 库,走用户机器上的系统 git。 */ import { execFile } from 'node:child_process' @@ -30,43 +27,123 @@ export function resolveWorktreePath(stored: string, workspaceRoot: string): stri } export interface WorktreeOpts { - /** Absolute path to the main workspace root. */ workspaceRoot: string - /** Absolute path of the worktree to create. */ + /** 模板 path;① 已有 live worktree 时返回值可能与此不同。 */ worktreePath: string - /** New branch name; e.g. `feature/abcd1234`. */ branch: string } -/** - * Create a new git worktree with a freshly-created branch off current HEAD. - * git -C worktree add -b - * - * Rejects with a descriptive Error if git exits non-zero. The caller should - * surface stderr to the user via a toast. - */ -export async function createWorktree(opts: WorktreeOpts): Promise { - const { workspaceRoot, worktreePath, branch } = opts - return new Promise((resolve, reject) => { - execFile( - 'git', - ['-C', workspaceRoot, 'worktree', 'add', worktreePath, '-b', branch], - { timeout: 30_000 }, - (err, _stdout, stderr) => { - if (err) { - // execFile sets `err.code` for non-zero exits; for spawn failures - // (e.g. git not on PATH) it's a string like 'ENOENT'. - const code = (err as NodeJS.ErrnoException).code ?? 'unknown' - const trimmed = (stderr ?? '').toString().trim() || err.message - reject(new Error(`git worktree add 失败 (${code}): ${trimmed}`)) - return - } - resolve() - }, - ) +function runGit(workspaceRoot: string, args: string[], failLabel?: string): Promise { + return new Promise((resolve, reject) => { + execFile('git', ['-C', workspaceRoot, ...args], { timeout: 30_000 }, (err, stdout, stderr) => { + if (err) { + // execFile: 非零退出是数字 code;git 不在 PATH 时是 'ENOENT' + const code = (err as NodeJS.ErrnoException).code ?? 'unknown' + const trimmed = (stderr ?? '').toString().trim() || err.message + const label = failLabel ?? `git ${args.join(' ')}` + reject(new Error(`${label} 失败 (${code}): ${trimmed}`)) + return + } + resolve((stdout ?? '').toString()) + }) }) } +async function pathExists(p: string): Promise { + try { + await fsp.stat(p) + return true + } + catch { + return false + } +} + +function parseWorktreePorcelain(stdout: string): Array<{ path: string, branch: string | null, prunable: boolean }> { + const entries: Array<{ path: string, branch: string | null, prunable: boolean }> = [] + let current: { path: string, branch: string | null, prunable: boolean } | null = null + const flush = (): void => { + if (current?.path) + entries.push(current) + current = null + } + for (const line of stdout.split('\n')) { + if (line.startsWith('worktree ')) { + flush() + current = { path: line.slice('worktree '.length), branch: null, prunable: false } + continue + } + if (!current) + continue + if (line.startsWith('branch ')) { + const ref = line.slice('branch '.length) + current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref + } + else if (line === 'detached') { + current.branch = null + } + else if (line === 'prunable' || line.startsWith('prunable ')) { + current.prunable = true + } + else if (line === '') { + flush() + } + } + flush() + return entries +} + +async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string): Promise { + const stdout = await runGit(workspaceRoot, ['worktree', 'list', '--porcelain']) + for (const entry of parseWorktreePorcelain(stdout)) { + if (entry.prunable || entry.branch !== branch) + continue + if (await pathExists(entry.path)) + return entry.path + } + 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 分支 + * —— 拖到「进行中」是开始实施,不是销毁上次进度。 + */ +export async function ensureWorktree(opts: WorktreeOpts): Promise { + const { workspaceRoot, branch } = opts + const worktreePath = path.resolve(opts.worktreePath) + + // ① 该 branch 已在某个 live worktree → 原路返回(path ≠ 模板也不搬,避免丢未提交改动) + const live = await findLiveWorktreeForBranch(workspaceRoot, branch) + if (live) { + console.log(`[superpowers] 复用已有 worktree ${live} (${branch})`) + return live + } + + // ② 模板 path 被垃圾占着(存在,但不是本仓库该 branch 的 live worktree) + 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') + return worktreePath +} + /** * 删除一个 worktree 目录并清掉 git 侧注册。 * diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index a8caab4..f98f48b 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -16,7 +16,7 @@ import { resolveProfilePath } from '../../cc/profiles' import { getBrainstormContinuePrompt, getImplementPlanPrompt } from '../../cc/prompts' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { detectRepo } from '../../git/remote' -import { createWorktree, resolveWorktreePath } from '../../git/worktree' +import { ensureWorktree, resolveWorktreePath } from '../../git/worktree' import { updateIssueAssignees } from '../../gitea/api' import { logger } from '../../logging/logger' import { getSettings } from '../../settings/store' @@ -766,45 +766,7 @@ export async function handleImplement( // 留空时回退到默认 ~/Sources/worktree/<项目>/。 const worktreeTemplate = getSettings(panel.context).worktreeDirectory || '~/Sources/worktree/$project_name/$feature_name' - const worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug) - - // Pre-flight: refuse if either the directory or the branch already - // exists, since `git worktree add -b` would fail and we'd have to - // unwind partial state. - let worktreeExists = false - try { - await fsp.stat(worktreePath) - worktreeExists = true - } - catch { - // ENOENT — good. - } - let branchExists = false - try { - branchExists = await new Promise((resolve) => { - execFile( - 'git', - ['-C', workspaceRoot, 'branch', '--list', branch], - { timeout: 10_000 }, - (err, stdout) => { - if (err) { - resolve(false) - return - } - resolve((stdout ?? '').trim().length > 0) - }, - ) - }) - } - catch { - branchExists = false - } - if (worktreeExists || branchExists) { - void window.showErrorMessage( - `feature ${slug} 的 worktree 或分支已存在,请先清理`, - ) - return - } + let worktreePath = resolveWorktreeDir(worktreeTemplate, workspaceRoot, slug) // 工单级 profilePath > 默认 fallback。工单的 profile 锁定可在工单详情面板里覆盖。 const effectiveProfilePath = panel.resolveImplementProfilePath(profilePath) @@ -834,20 +796,19 @@ export async function handleImplement( return } - // Create the worktree before anything else, so a worktree-add failure - // doesn't leave half-written state behind. + // 先保证 worktree 可用:后面写 state / 开终端都依赖实际 path。 try { // worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在; // git worktree add 不会创建多层父目录,先补齐。 await fsp.mkdir(path.dirname(worktreePath), { recursive: true }) - await createWorktree({ workspaceRoot, worktreePath, branch }) + worktreePath = await ensureWorktree({ workspaceRoot, worktreePath, branch }) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.add({ level: 'error', source: 'implement', - message: 'git worktree add 失败', + message: 'ensureWorktree 失败', details: message, }) void window.showErrorMessage(message) diff --git a/vscode/test/git/worktreeEnsure.test.ts b/vscode/test/git/worktreeEnsure.test.ts new file mode 100644 index 0000000..e8d39a2 --- /dev/null +++ b/vscode/test/git/worktreeEnsure.test.ts @@ -0,0 +1,181 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, promises as fsp, 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 { ensureWorktree } from '../../src/git/worktree' + +interface Fixture { + root: string + repo: string + templatePath: string +} + +const gitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + GIT_AUTHOR_NAME: 'ensure-test', + GIT_AUTHOR_EMAIL: 'ensure@test.local', + GIT_COMMITTER_NAME: 'ensure-test', + GIT_COMMITTER_EMAIL: 'ensure@test.local', +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim() +} + +async function setupRepo(): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-ensure-')) + const repo = path.join(root, 'sw-ensure', 'repo') + await fsp.mkdir(repo, { recursive: true }) + git(repo, ['init', '-b', 'main']) + git(repo, ['config', 'user.email', 'ensure@test.local']) + git(repo, ['config', 'user.name', 'ensure-test']) + writeFileSync(path.join(repo, 'README.md'), 'hello\n') + git(repo, ['add', 'README.md']) + git(repo, ['commit', '-m', 'init']) + const templatePath = path.join(root, 'sw-ensure', 'wt', 'feature-x') + await fsp.mkdir(path.dirname(templatePath), { recursive: true }) + return { root, repo, templatePath } +} + +async function cleanup(fx: Fixture | undefined): Promise { + if (!fx) + return + try { + const out = git(fx.repo, ['worktree', 'list', '--porcelain']) + const extras: string[] = [] + for (const line of out.split('\n')) { + if (!line.startsWith('worktree ')) + continue + const p = line.slice('worktree '.length) + if (path.resolve(p) !== path.resolve(fx.repo)) + extras.push(p) + } + for (const p of extras) { + try { + git(fx.repo, ['worktree', 'remove', '--force', p]) + } + catch { + rmSync(p, { recursive: true, force: true }) + } + } + } + catch { + // repo 可能已残,下面整棵 tmp 仍要删 + } + rmSync(fx.root, { recursive: true, force: true }) +} + +describe('ensureWorktree', () => { + let fx: Fixture | undefined + + afterEach(async () => { + await cleanup(fx) + fx = undefined + }) + + 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') + git(fx.repo, ['add', 'README.md']) + git(fx.repo, ['commit', '-m', 'extra 1']) + writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\n') + git(fx.repo, ['add', 'README.md']) + git(fx.repo, ['commit', '-m', 'extra 2']) + writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\nextra-2\nextra-3\n') + git(fx.repo, ['add', 'README.md']) + git(fx.repo, ['commit', '-m', 'extra 3']) + git(fx.repo, ['checkout', 'main']) + const ahead = git(fx.repo, ['log', '--oneline', 'main..feature/x']) + expect(ahead.split('\n').filter(Boolean).length).toBe(3) + + const actual = await ensureWorktree({ + workspaceRoot: fx.repo, + worktreePath: fx.templatePath, + branch: 'feature/x', + }) + + 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'])) + }) + + it('分支和 worktree 都不存在时创建新分支 worktree', async () => { + fx = await setupRepo() + + const actual = await ensureWorktree({ + workspaceRoot: fx.repo, + worktreePath: fx.templatePath, + branch: 'feature/fresh', + }) + + expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) + expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh') + expect(git(fx.repo, ['show-ref', '--verify', '--quiet', 'refs/heads/feature/fresh'])).toBe('') + }) + + it('该 branch 已有 live worktree 时返回已有 path,不建第二个', async () => { + fx = await setupRepo() + git(fx.repo, ['branch', 'feature/x']) + const existing = path.join(fx.root, 'sw-ensure', 'wt', 'already-here') + await fsp.mkdir(path.dirname(existing), { recursive: true }) + git(fx.repo, ['worktree', 'add', existing, 'feature/x']) + + const actual = await ensureWorktree({ + workspaceRoot: fx.repo, + worktreePath: fx.templatePath, + 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') + const listed = git(fx.repo, ['worktree', 'list', '--porcelain']) + const wtLines = listed.split('\n').filter(l => l.startsWith('worktree ')) + expect(wtLines).toHaveLength(2) + }) + + it('模板 path 是垃圾目录且分支不存在时删掉后 add -b 成功', async () => { + fx = await setupRepo() + await fsp.mkdir(fx.templatePath, { recursive: true }) + writeFileSync(path.join(fx.templatePath, 'junk.txt'), 'garbage') + + const actual = await ensureWorktree({ + workspaceRoot: fx.repo, + worktreePath: fx.templatePath, + branch: 'feature/fresh', + }) + + expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath)) + expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/fresh') + expect(existsSync(path.join(fx.templatePath, 'junk.txt'))).toBe(false) + expect(existsSync(path.join(fx.templatePath, 'README.md'))).toBe(true) + }) + + it('无 leftover 时可重复调用,第二次返回同一 path', async () => { + fx = await setupRepo() + + const first = await ensureWorktree({ + workspaceRoot: fx.repo, + worktreePath: fx.templatePath, + branch: 'feature/x', + }) + const second = await ensureWorktree({ + workspaceRoot: fx.repo, + worktreePath: fx.templatePath, + branch: 'feature/x', + }) + + expect(path.resolve(second)).toBe(path.resolve(first)) + expect(git(second, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x') + const wtLines = git(fx.repo, ['worktree', 'list', '--porcelain']) + .split('\n') + .filter(l => l.startsWith('worktree ')) + expect(wtLines).toHaveLength(2) + }) +})