🐛 fix(vscode): 实施启动自动丢掉残留 worktree 再新建
This commit is contained in:
+14
-23
@@ -7,6 +7,7 @@ import { execFile } from 'node:child_process'
|
|||||||
import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:fs'
|
import { promises as fsp, readdirSync, readFileSync, readlinkSync } from 'node:fs'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
|
import { deleteLocalBranch } from './branchSync'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径
|
* 把开头的 `~` 展开为用户 home 目录。execFile 不走 shell,传给它的命令/路径
|
||||||
@@ -28,7 +29,7 @@ export function resolveWorktreePath(stored: string, workspaceRoot: string): stri
|
|||||||
|
|
||||||
export interface WorktreeOpts {
|
export interface WorktreeOpts {
|
||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
/** 模板 path;① 已有 live worktree 时返回值可能与此不同。 */
|
/** 模板 path;返回值即此路径。 */
|
||||||
worktreePath: string
|
worktreePath: string
|
||||||
branch: string
|
branch: string
|
||||||
}
|
}
|
||||||
@@ -104,43 +105,33 @@ async function findLiveWorktreeForBranch(workspaceRoot: string, branch: string):
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function localBranchExists(workspaceRoot: string, branch: string): Promise<boolean> {
|
|
||||||
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<string> {
|
export async function ensureWorktree(opts: WorktreeOpts): Promise<string> {
|
||||||
const { workspaceRoot, branch } = opts
|
const { workspaceRoot, branch } = opts
|
||||||
const worktreePath = path.resolve(opts.worktreePath)
|
const worktreePath = path.resolve(opts.worktreePath)
|
||||||
|
|
||||||
// ① 该 branch 已在某个 live worktree → 原路返回(path ≠ 模板也不搬,避免丢未提交改动)
|
// ① 该 branch 的 live worktree 可能不在模板 path → kill + 整目录删掉
|
||||||
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
|
const live = await findLiveWorktreeForBranch(workspaceRoot, branch)
|
||||||
if (live) {
|
if (live) {
|
||||||
console.log(`[superpowers] 复用已有 worktree ${live} (${branch})`)
|
killProcessesUsingWorktree(live)
|
||||||
return live
|
await removeWorktreeDir(workspaceRoot, live)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ② 模板 path 被垃圾占着(存在,但不是本仓库该 branch 的 live worktree)
|
// ② 模板 path 仍被垃圾目录占着 → 同样 kill + remove,否则 ④ add 撞路径
|
||||||
if (await pathExists(worktreePath)) {
|
if (await pathExists(worktreePath)) {
|
||||||
killProcessesUsingWorktree(worktreePath)
|
killProcessesUsingWorktree(worktreePath)
|
||||||
await removeWorktreeDir(workspaceRoot, worktreePath)
|
await removeWorktreeDir(workspaceRoot, worktreePath)
|
||||||
console.log(`[superpowers] 已清模板路径垃圾 ${worktreePath}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const branchExists = await localBranchExists(workspaceRoot, branch)
|
// ③ leftover 本地分支必须删掉;失败不能静默,否则 ④ `add -b` 撞同名分支
|
||||||
const addArgs = branchExists
|
const deleted = await deleteLocalBranch(workspaceRoot, branch)
|
||||||
? ['worktree', 'add', worktreePath, branch] // ③ 有分支无 live worktree → 不加 -b,挂上已有分支,保留 commit
|
if (!deleted.ok)
|
||||||
: ['worktree', 'add', worktreePath, '-b', branch] // ④ 分支也不存在 → -b 新建
|
throw new Error(`删除本地分支失败 ${branch}: ${deleted.stderr || deleted.stdout}`)
|
||||||
await runGit(workspaceRoot, addArgs, 'git worktree add')
|
|
||||||
|
// ④ 始终 -b 从当前 HEAD 新建,不挂旧分支
|
||||||
|
await runGit(workspaceRoot, ['worktree', 'add', worktreePath, '-b', branch], 'git worktree add')
|
||||||
return worktreePath
|
return worktreePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { getSettings } from '../../settings/store'
|
|||||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||||
import { decideAutoReviewPath } from '../autoReviewDecision'
|
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
|
|
||||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||||
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
||||||
@@ -798,6 +799,17 @@ export async function handleImplement(
|
|||||||
|
|
||||||
// 先保证 worktree 可用:后面写 state / 开终端都依赖实际 path。
|
// 先保证 worktree 可用:后面写 state / 开终端都依赖实际 path。
|
||||||
try {
|
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/<项目>/...),父目录可能不存在;
|
// worktree 现在建在工作区外(~/Sources/worktree/<项目>/...),父目录可能不存在;
|
||||||
// git worktree add 不会创建多层父目录,先补齐。
|
// git worktree add 不会创建多层父目录,先补齐。
|
||||||
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
|
await fsp.mkdir(path.dirname(worktreePath), { recursive: true })
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe('ensureWorktree', () => {
|
|||||||
fx = undefined
|
fx = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
it('本地已有 feature 分支且无 worktree 时挂上该分支并保留 commit', async () => {
|
it('本地已有 feature 分支且无 worktree 时丢掉旧 commit 再新建', async () => {
|
||||||
fx = await setupRepo()
|
fx = await setupRepo()
|
||||||
git(fx.repo, ['checkout', '-b', 'feature/x'])
|
git(fx.repo, ['checkout', '-b', 'feature/x'])
|
||||||
writeFileSync(path.join(fx.repo, 'README.md'), 'hello\nextra-1\n')
|
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(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||||
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||||
expect(git(fx.repo, ['log', '--oneline', 'main..feature/x'])).toBe(ahead)
|
expect(git(fx.repo, ['log', '--oneline', 'main..feature/x'])).toBe('')
|
||||||
expect(git(actual, ['rev-parse', 'HEAD'])).toBe(git(fx.repo, ['rev-parse', 'feature/x']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('分支和 worktree 都不存在时创建新分支 worktree', async () => {
|
it('分支和 worktree 都不存在时创建新分支 worktree', async () => {
|
||||||
@@ -119,7 +118,7 @@ describe('ensureWorktree', () => {
|
|||||||
expect(git(fx.repo, ['show-ref', '--verify', '--quiet', 'refs/heads/feature/fresh'])).toBe('')
|
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()
|
fx = await setupRepo()
|
||||||
git(fx.repo, ['branch', 'feature/x'])
|
git(fx.repo, ['branch', 'feature/x'])
|
||||||
const existing = path.join(fx.root, 'sw-ensure', 'wt', 'already-here')
|
const existing = path.join(fx.root, 'sw-ensure', 'wt', 'already-here')
|
||||||
@@ -132,9 +131,9 @@ describe('ensureWorktree', () => {
|
|||||||
branch: 'feature/x',
|
branch: 'feature/x',
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(path.resolve(actual)).toBe(path.resolve(existing))
|
expect(path.resolve(actual)).toBe(path.resolve(fx.templatePath))
|
||||||
expect(existsSync(fx.templatePath)).toBe(false)
|
expect(existsSync(existing)).toBe(false)
|
||||||
expect(git(existing, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
expect(git(actual, ['rev-parse', '--abbrev-ref', 'HEAD'])).toBe('feature/x')
|
||||||
const listed = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
const listed = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||||
const wtLines = listed.split('\n').filter(l => l.startsWith('worktree '))
|
const wtLines = listed.split('\n').filter(l => l.startsWith('worktree '))
|
||||||
expect(wtLines).toHaveLength(2)
|
expect(wtLines).toHaveLength(2)
|
||||||
@@ -157,7 +156,7 @@ describe('ensureWorktree', () => {
|
|||||||
expect(existsSync(path.join(fx.templatePath, 'README.md'))).toBe(true)
|
expect(existsSync(path.join(fx.templatePath, 'README.md'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('无 leftover 时可重复调用,第二次返回同一 path', async () => {
|
it('第二次调用仍成功且在同一模板 path', async () => {
|
||||||
fx = await setupRepo()
|
fx = await setupRepo()
|
||||||
|
|
||||||
const first = await ensureWorktree({
|
const first = await ensureWorktree({
|
||||||
@@ -165,14 +164,16 @@ describe('ensureWorktree', () => {
|
|||||||
worktreePath: fx.templatePath,
|
worktreePath: fx.templatePath,
|
||||||
branch: 'feature/x',
|
branch: 'feature/x',
|
||||||
})
|
})
|
||||||
|
writeFileSync(path.join(first, 'leftover.txt'), 'should-be-gone')
|
||||||
const second = await ensureWorktree({
|
const second = await ensureWorktree({
|
||||||
workspaceRoot: fx.repo,
|
workspaceRoot: fx.repo,
|
||||||
worktreePath: fx.templatePath,
|
worktreePath: fx.templatePath,
|
||||||
branch: 'feature/x',
|
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(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'])
|
const wtLines = git(fx.repo, ['worktree', 'list', '--porcelain'])
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter(l => l.startsWith('worktree '))
|
.filter(l => l.startsWith('worktree '))
|
||||||
|
|||||||
Reference in New Issue
Block a user