✨ 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:
1 parent
ee33ae2440
commit
706057075d
6 files changed
+186
-24
No files matched your search
+80
-16
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@
|
||||
* 读设置、弹 QuickPick、发 toast、维护 `panel.releaseRunning` 互斥。
|
||||
*/
|
||||
|
||||
import type { CherryPickConflict } from '../../git/release'
|
||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||
import { window, workspace } from 'vscode'
|
||||
import { buildCcCommand } from '../../cc/ccCommand'
|
||||
import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { gitFetch } from '../../git/branches'
|
||||
import {
|
||||
attachPulls,
|
||||
@@ -19,9 +22,12 @@ import {
|
||||
listUnreleasedCommits,
|
||||
todayReleaseBranch,
|
||||
} from '../../git/release'
|
||||
import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir } from '../../git/worktreePath'
|
||||
import { triggerJenkinsBuild } from '../../jenkins/trigger'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { dismiss, toast } from './handoffFlow'
|
||||
import { submitToSessionTerminal } from './terminals'
|
||||
|
||||
export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise<void> {
|
||||
const root = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
@@ -141,14 +147,72 @@ export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas:
|
||||
const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current })
|
||||
const order = new Map(commits.map((c, i) => [c.sha, i]))
|
||||
const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0))
|
||||
// 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。
|
||||
const worktreePath = resolveWorktreeDir(s.worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE, root, current.replace(/\//g, '-'))
|
||||
const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 })
|
||||
let conflict: CherryPickConflict | undefined
|
||||
try {
|
||||
await cherryPickToRelease({ cwd: root, branch: current, shas: ordered })
|
||||
conflict = await cherryPickToRelease({ cwd: root, branch: current, shas: ordered, worktreePath })
|
||||
}
|
||||
finally {
|
||||
dismiss(panel, spinner)
|
||||
}
|
||||
toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${current}`)
|
||||
if (!conflict) {
|
||||
toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${current}`)
|
||||
return
|
||||
}
|
||||
openReleaseConflictSession(panel, { branch: current, conflict, subjectOf: new Map(commits.map(c => [c.sha, c.subject])) })
|
||||
toast(panel, 'info', `${conflict.sha.slice(0, 7)} ${conflict.subject} 冲突,已开 ${current} 冲突解决会话接手`, { dismissOnTimer: 10_000 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘修复冲突后的接力:worktree 里 cherry-pick 停在中间态,开一个 cc 会话进去解决、
|
||||
* 按原顺序摘完剩余提交并推远端。同名终端还活着就把提示词直接打进去,不再新开。
|
||||
*/
|
||||
function openReleaseConflictSession(panel: KanbanWebviewPanel, o: { branch: string, conflict: CherryPickConflict, subjectOf: Map<string, string> }): void {
|
||||
const { branch, conflict } = o
|
||||
const remaining = conflict.remaining.length === 0
|
||||
? ' (没有剩余提交)'
|
||||
: conflict.remaining.map(sha => ` - ${sha} ${o.subjectOf.get(sha) ?? ''}`.trimEnd()).join('\n')
|
||||
const prompt = `当前目录是发布分支 ${branch} 的 worktree(detached HEAD),执行 \`git cherry-pick -x ${conflict.sha}\`(${conflict.subject})时出现冲突,冲突已落地。你的任务:
|
||||
|
||||
1. 跑 \`git status\` 查看冲突文件
|
||||
2. 逐个解决冲突,保留这个提交的改动意图
|
||||
3. \`git add\` 已解决的文件,然后 \`git cherry-pick --continue\`(保留默认提交说明)
|
||||
4. 按下面的顺序继续 \`git cherry-pick -x <sha>\`,再冲突就同样处理:
|
||||
${remaining}
|
||||
5. 全部完成后 \`git push origin HEAD:refs/heads/${branch}\`
|
||||
|
||||
注意:
|
||||
- 不要 rebase、不要 force push、不要碰其他分支
|
||||
- 全部推完后告诉用户「${branch} 已摘完并推送」`
|
||||
|
||||
const terminalName = `${branch}-冲突解决`
|
||||
const live = panel.findExistingTerminal(terminalName)
|
||||
if (live) {
|
||||
live.show(false)
|
||||
submitToSessionTerminal(live, prompt)
|
||||
logger.add({ level: 'info', source: 'panel', message: `摘修复冲突:已把提示词打进 ${terminalName}` })
|
||||
return
|
||||
}
|
||||
const profilePath = resolveProfilePath(getSettings(panel.context).conflictResolutionProfilePath.trim() || undefined)
|
||||
if (profilePath.includes('\'')) {
|
||||
toast(panel, 'error', `冲突解决失败:profilePath 含单引号 (${profilePath})`, { dismissOnTimer: 10_000 })
|
||||
return
|
||||
}
|
||||
const terminal = window.createTerminal({
|
||||
name: terminalName,
|
||||
cwd: conflict.worktreePath,
|
||||
location: panel.resolveTerminalLocation(false),
|
||||
})
|
||||
terminal.show(false)
|
||||
// buildCcCommand 用单引号包提示词,内部的 ' 要转成 '\'' 才不会破壳。
|
||||
terminal.sendText(buildCcCommand({ profilePath, effort: 'high', prompt: prompt.replace(/'/g, '\'\\\'\'') }))
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'panel',
|
||||
message: `摘修复冲突:cc 会话已启动 (${branch}, ${conflict.sha.slice(0, 7)}, cwd ${conflict.worktreePath}, profile ${profilePath})`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user