diff --git a/vscode/CLAUDE.md b/vscode/CLAUDE.md index ff13400..327711c 100644 --- a/vscode/CLAUDE.md +++ b/vscode/CLAUDE.md @@ -3,5 +3,6 @@ - 具体编码任务一律派子代理(Agent 工具)去做,主会话只讨论需求和方案 - **子代理实施完成后自动 commit**,使用本仓库的 emoji 前缀风格(参考 `git log`) - **改完扩展代码必须收尾**:`package.json` version patch +1 → `pnpm ext:package` 打包 VSIX(生成在 `vscode/` 目录,供用户拿去安装)→ 版本号改动一并 commit,不许留脏的 package.json +- **收尾三步(bump → 打包 → 本机安装)是默认动作,直接做完,不要问要不要做、也不要在回复末尾提醒用户** - **本机安装 VSIX 必须带 `--profile augment`**(用户的 VS Code 窗口用 augment profile,不带 profile 参数会装进默认 profile,窗口里看不见);两台 MBP 用默认 profile,直接 `code --install-extension` 即可 - 代码文件用 Serena MCP 工具读写(`mcp__serena__find_symbol` / `replace_symbol_body` 等),非代码文件才用 built-in Read/Edit diff --git a/vscode/README.md b/vscode/README.md index 3ef4351..7cea98f 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -24,7 +24,7 @@ 5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。 6. **回退**:进行中 / 审查中的卡可「重置为待办」,关 PR、删 worktree、清分支和实施痕迹,spec / plan 保留。 7. **移交**:前置条件:两台机器的 spx 都已 `cd cli && make install` 到含 `handoffAttachmentId` 的版本,否则移交期间旧 spx 的 state 写入会被 schema 拒绝。工单 tab 头部「移交」→ 选同事。插件校验 worktree 已全部 push,关掉该工单的终端,把头脑风暴 / 实施 / 测试的 claude 会话(jsonl + 子目录)和 codex 审查会话打成 `spx-handoff-issue-N.tgz` 挂到工单附件,写 `handoffAttachmentId` / `handoffFrom`,把工单指派给对方,最后删本机 worktree 与本地分支(远端分支保留)。对方看板上该卡显示「待接管」,工单 tab 点「接管」:从 origin 重建 worktree(`git worktree add -B … origin/`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。 -8. **发布**:工具栏「发布」按钮下拉三项。「切新发布」从 `origin/` 切当天的 `YYYY-MM-DD` 并推远端(纯远端,不建本地分支),配了 `jenkinsJob` 会顺带问要不要立刻部署。「摘修复到当期发布」列出 `devBranch` 上还没进发布分支的提交,多选后在临时 worktree 里 `cherry-pick -x` 并推远端,冲突则中止、远端不变。「部署到正式服」选一个发布分支(默认当期,选旧的即回滚)执行 `opencli jks build -p REF=<分支> -p REMOTE_HOST=`。 +8. **发布**:工具栏「发布」按钮下拉三项。「切新发布」从 `origin/` 切当天的 `YYYY-MM-DD` 并推远端(纯远端,不建本地分支),配了 `jenkinsJob` 会顺带问要不要立刻部署。「摘修复到当期发布」列出 `devBranch` 上还没进发布分支的提交,多选后在发布 worktree(`worktreeDirectory` 模板,目录名为分支名把 `/` 换成 `-`)里按序 `cherry-pick -x` 并推远端,成功后删 worktree。中途冲突则保留冲突现场、远端不变,开 `<发布分支>-冲突解决` 会话让 claude 解冲突、`cherry-pick --continue`、按序摘完剩余提交再推远端;上次冲突还没处理完时再摘会被拒绝。「部署到正式服」选一个发布分支(默认当期,选旧的即回滚)执行 `opencli jks build -p REF=<分支> -p REMOTE_HOST=`。 其他看板规则: @@ -106,6 +106,7 @@ cd cli && make install # 编译到 bin/spx 并安装到 ~/.local/bin/ | 审查 | `issue-N-审查` | codex | worktree | 设置里的 `codexModel` / `codexReasoningEffort` | | 测试 | `issue-N-测试` | claude | worktree | 工单 `testProfilePath` | | 冲突解决 | `issue-N-冲突解决` | claude `--effort high` | worktree | 设置 `conflictResolutionProfilePath` | +| 摘修复冲突解决 | `<发布分支>-冲突解决` | claude `--effort high` | 发布 worktree | 设置 `conflictResolutionProfilePath` | 所有终端固定开在编辑器第二分组。claude 会话统一以 `--settings --dangerously-skip-permissions` 启动,后台无头调用会剥掉 `ANTHROPIC_*` 环境变量,让 profile 成为唯一鉴权来源。 diff --git a/vscode/package.json b/vscode/package.json index 24afb3f..014bc8a 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.119", + "version": "0.2.120", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts index d6bdb81..bad49a5 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -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 { +export async function cherryPickToRelease(o: { cwd: string, branch: string, shas: string[], worktreePath: string }): Promise { 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/` 的 detached 检出,供 cherry-pick 用: + * ① 已注册为本仓库 worktree → 上次冲突没处理完就拒绝,否则硬重置到远端 ← 复用,冲突现场靠它留住 + * ② 路径被别的东西占着 → 删掉重建 ← 否则 add 撞路径 + * ③ 不存在 → 新建 + */ +async function prepareReleaseWorktree(cwd: string, branch: string, worktreePath: string): Promise { + 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 { + 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 { + try { + await access(p) + return true + } + catch { + return false } } diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts index a52db70..e9e9f8f 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -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 { 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 }): 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 \`,再冲突就同样处理: +${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})`, }) } diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts index bed3b09..2ecd5fb 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { promises as fsp, rmSync, writeFileSync } from 'node:fs' +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' @@ -104,23 +104,55 @@ describe('集成', () => { expect(s).toMatchObject({ current: 'release/2026-08-31', unreleased: 2 }) const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' }) expect(list.map(c => c.subject)).toEqual(['feat c', 'fix b']) - await cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix] }) + // 目录被一个不是 worktree 的垃圾目录占着,应当清掉重建而不是撞路径 + const worktreePath = path.join(fx.root, 'wt', 'release-2026-08-31') + await fsp.mkdir(worktreePath, { recursive: true }) + writeFileSync(path.join(worktreePath, 'junk.txt'), 'junk\n') + await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix], worktreePath })).resolves.toBeUndefined() expect(git(fx.origin, ['log', '--format=%s', 'release/2026-08-31'])).toBe('fix b\ninit') expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(1) + expect(existsSync(worktreePath)).toBe(false) s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' }) expect(s.unreleased).toBe(1) }) - it('cherry-pick 冲突 → 抛错、远端不变、worktree 已清理', async () => { + it('cherry-pick 冲突 → 返回冲突现场、worktree 留在中间态、远端不变;没处理完拒绝再摘,处理完复用同一 worktree', async () => { fx = await setup() await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) commit(fx.peer, 'a.txt', 'a2', 'change a') const conflict = commit(fx.peer, 'a.txt', 'a3', 'change a again') + const after = commit(fx.peer, 'd.txt', 'd', 'feat d') git(fx.peer, ['push', 'origin', 'main']) const before = git(fx.origin, ['rev-parse', 'release/2026-08-31']) - await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [conflict] })).rejects.toThrow(/冲突/) + const worktreePath = path.join(fx.root, 'wt', 'release-2026-08-31') + const o = { cwd: fx.repo, branch: 'release/2026-08-31', worktreePath } + await expect(cherryPickToRelease({ ...o, shas: [conflict, after] })).resolves.toEqual({ + sha: conflict, + subject: 'change a again', + worktreePath, + remaining: [after], + }) expect(git(fx.origin, ['rev-parse', 'release/2026-08-31'])).toBe(before) + expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(2) + expect(git(worktreePath, ['rev-parse', '--verify', 'CHERRY_PICK_HEAD'])).toBe(conflict) + await expect(cherryPickToRelease({ ...o, shas: [after] })).rejects.toThrow(/还没处理完/) + // 模拟冲突解决会话:解冲突、continue、推远端 + writeFileSync(path.join(worktreePath, 'a.txt'), 'a3\n') + git(worktreePath, ['add', 'a.txt']) + git(worktreePath, ['-c', 'core.editor=true', 'cherry-pick', '--continue']) + git(worktreePath, ['push', 'origin', 'HEAD:refs/heads/release/2026-08-31']) + expect(git(fx.origin, ['log', '-1', '--format=%b', 'release/2026-08-31'])).toContain(`(cherry picked from commit ${conflict})`) + await expect(cherryPickToRelease({ ...o, shas: [after] })).resolves.toBeUndefined() + expect(git(fx.origin, ['log', '--format=%s', 'release/2026-08-31'])).toBe('feat d\nchange a again\ninit') expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(1) }) + it('发布 worktree 目录解析到工作区本身 → 拒绝', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) + const fix = commit(fx.peer, 'b.txt', 'b', 'fix b') + git(fx.peer, ['push', 'origin', 'main']) + await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix], worktreePath: fx.repo })).rejects.toThrow(/工作区本身/) + expect(git(fx.repo, ['status', '--porcelain'])).toBe('') + }) it('listReleaseBranches 新→旧', async () => { fx = await setup() await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-01' })