✨ feat(vscode): 摘修复冲突不再中止,保留 worktree 自动开冲突解决会话接手

摘修复改在固定的发布 worktree(worktreeDirectory 模板,目录名为分支名
/ 换 -)里按序 cherry-pick -x。全部成功推远端并删 worktree;中途冲突则
保留冲突现场、远端不变,开 `<发布分支>-冲突解决` claude 会话解冲突、
--continue、按原顺序摘完剩余提交再推远端。同名终端活着直接注入提示词。

- 上次冲突没处理完再摘会被拒绝;目录模板解析到工作区本身也拒绝
- 目录被非 worktree 的垃圾占着时清掉重建
- 测试覆盖成功清理、冲突接力(含 -x 尾注保留)、拒绝工作区本身
- CLAUDE.md 补一条:收尾三步默认直接做,不问不提醒

Claude-Session: https://claude.ai/code/session_018HDxMmNDyfLa3bMTUctBzt
This commit is contained in:
cruldra committed 2026-09-06 13:32:47 +08:00
1 parent ee33ae2440
commit 706057075d
6 files changed
+186 -24

No files matched your search

+80 -16
View File
@@ -1,11 +1,11 @@
/**
* 发布分支管理的纯 git 逻辑:切发布点、统计未发布提交、cherry-pick 修复到发布分支。
* 不依赖 VS Code API;所有写操作只碰远端引用和临时 worktree,不改用户工作区。
* 不依赖 VS Code API;所有写操作只碰远端引用和发布 worktree,不改用户工作区。
*/
import { execFile } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import * as os from 'node:os'
import { access, mkdir, realpath } from 'node:fs/promises'
import * as path from 'node:path'
import { removeWorktreeDir } from './worktree'
const DEFAULT_PREFIX = 'release/'
@@ -37,6 +37,14 @@ export interface ReleaseCommitGroup {
commits: UnreleasedCommit[]
}
/** cherry-pick 半路冲突:worktree 停在中间态等人接手,没摘完的提交按顺序带回去。 */
export interface CherryPickConflict {
sha: string
subject: string
worktreePath: string
remaining: string[]
}
export class ReleaseError extends Error {
constructor(message: string, public readonly detail?: string) {
super(message)
@@ -308,24 +316,80 @@ export async function cutRelease(o: { cwd: string, devBranch: string, branch: st
}
/** 在临时 worktree 里按给定顺序(旧→新)cherry-pick,成功才推远端;无论成败都清理 worktree */
export async function cherryPickToRelease(o: { cwd: string, branch: string, shas: string[] }): Promise<void> {
export async function cherryPickToRelease(o: { cwd: string, branch: string, shas: string[], worktreePath: string }): Promise<CherryPickConflict | undefined> {
await mustGit(o.cwd, ['fetch', 'origin', '--prune'], '拉取远端失败')
const tmp = await mkdtemp(path.join(os.tmpdir(), 'sw-release-'))
await prepareReleaseWorktree(o.cwd, o.branch, o.worktreePath)
let conflict: CherryPickConflict | undefined
try {
await mustGit(o.cwd, ['worktree', 'add', '--detach', tmp, `origin/${o.branch}`], '创建临时 worktree 失败')
for (const sha of o.shas) {
const picked = await runGit(tmp, ['cherry-pick', '-x', sha])
if (!picked.ok) {
await runGit(tmp, ['cherry-pick', '--abort'])
const subject = (await runGit(tmp, ['log', '-1', '--format=%s', sha])).stdout.trim()
throw new ReleaseError(`${sha.slice(0, 7)} ${subject} 冲突,请手动处理`, picked.stderr)
for (let i = 0; i < o.shas.length; i++) {
const sha = o.shas[i]
const picked = await runGit(o.worktreePath, ['cherry-pick', '-x', sha])
if (picked.ok)
continue
const subject = (await runGit(o.worktreePath, ['log', '-1', '--format=%s', sha])).stdout.trim()
const unmerged = (await runGit(o.worktreePath, ['diff', '--name-only', '--diff-filter=U'])).stdout.trim()
if (unmerged) {
conflict = { sha, subject, worktreePath: o.worktreePath, remaining: o.shas.slice(i + 1) }
return conflict
}
await runGit(o.worktreePath, ['cherry-pick', '--abort'])
throw new ReleaseError(`${sha.slice(0, 7)} ${subject} 摘取失败`, picked.stderr)
}
await mustGit(tmp, ['push', 'origin', `HEAD:refs/heads/${o.branch}`], `推送 ${o.branch} 失败`)
await mustGit(o.worktreePath, ['push', 'origin', `HEAD:refs/heads/${o.branch}`], `推送 ${o.branch} 失败`)
return undefined
}
finally {
await runGit(o.cwd, ['worktree', 'remove', '--force', tmp])
await rm(tmp, { recursive: true, force: true })
await runGit(o.cwd, ['worktree', 'prune'])
if (!conflict)
await removeWorktreeDir(o.cwd, o.worktreePath)
}
}
/**
* 让 worktreePath 成为 `origin/<branch>` 的 detached 检出,供 cherry-pick 用:
* ① 已注册为本仓库 worktree → 上次冲突没处理完就拒绝,否则硬重置到远端 ← 复用,冲突现场靠它留住
* ② 路径被别的东西占着 → 删掉重建 ← 否则 add 撞路径
* ③ 不存在 → 新建
*/
async function prepareReleaseWorktree(cwd: string, branch: string, worktreePath: string): Promise<void> {
if (await isRegisteredWorktree(cwd, worktreePath)) {
const pending = await runGit(worktreePath, ['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'])
if (pending.ok)
throw new ReleaseError(`上次摘修复到 ${branch} 的冲突还没处理完,先到 ${worktreePath} 处理`)
await mustGit(worktreePath, ['reset', '--hard', `origin/${branch}`], '重置发布 worktree 失败')
await mustGit(worktreePath, ['clean', '-fd'], '清理发布 worktree 失败')
return
}
if (await pathExists(worktreePath))
await removeWorktreeDir(cwd, worktreePath)
await runGit(cwd, ['worktree', 'prune'])
await mkdir(path.dirname(worktreePath), { recursive: true })
await mustGit(cwd, ['worktree', 'add', '--detach', worktreePath, `origin/${branch}`], '创建发布 worktree 失败')
}
/** 按真实路径比对 `git worktree list`;解析到工作区本身直接拒绝,硬重置会毁掉用户的改动。 */
async function isRegisteredWorktree(cwd: string, worktreePath: string): Promise<boolean> {
const target = await realpath(worktreePath).catch(() => undefined)
if (!target)
return false
if (target === await realpath(cwd))
throw new ReleaseError(`发布 worktree 目录解析到工作区本身,拒绝:${worktreePath}`)
const list = await mustGit(cwd, ['worktree', 'list', '--porcelain'], '读取 worktree 列表失败')
for (const line of list.split('\n')) {
if (!line.startsWith('worktree '))
continue
const p = await realpath(line.slice('worktree '.length)).catch(() => undefined)
if (p === target)
return true
}
return false
}
async function pathExists(p: string): Promise<boolean> {
try {
await access(p)
return true
}
catch {
return false
}
}