Compare commits

...
5 Commits
Author SHA1 Message Date
cruldra df0660cff7 🐛 fix(vscode): 摘修复列表与角标认 cherry-pick -x 尾注,解过冲突的复制品不再重复显示
解过冲突的 cherry-pick 复制品补丁内容已变,按文件指纹对不上,列表和角标
都把它当没摘。现在额外扫发布分支独有提交的说明,`(cherry picked from
commit <sha>)` 尾注命中的来源提交直接判已摘,列表过滤与 rev-list 计数同步扣掉。

Claude-Session: https://claude.ai/code/session_018HDxMmNDyfLa3bMTUctBzt
2026-09-06 13:55:28 +08:00
cruldra e99aed15c2 📝 docs(vscode): 收尾规则改存 ecc instinct,撤回 CLAUDE.md 补行
Claude-Session: https://claude.ai/code/session_018HDxMmNDyfLa3bMTUctBzt
2026-09-06 13:36:24 +08:00
cruldra 706057075d ✨ feat(vscode): 摘修复冲突不再中止,保留 worktree 自动开冲突解决会话接手
摘修复改在固定的发布 worktree(worktreeDirectory 模板,目录名为分支名
/ 换 -)里按序 cherry-pick -x。全部成功推远端并删 worktree;中途冲突则
保留冲突现场、远端不变,开 `<发布分支>-冲突解决` claude 会话解冲突、
--continue、按原顺序摘完剩余提交再推远端。同名终端活着直接注入提示词。

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

Claude-Session: https://claude.ai/code/session_018HDxMmNDyfLa3bMTUctBzt
2026-09-06 13:32:47 +08:00
cruldra ee33ae2440 🔖 chore(vscode): bump 0.2.119 2026-09-04 12:21:58 +08:00
cruldra 0e144bc28f 🐛 fix(vscode): URI 开终端复用已有标签组,不再强制劈栏 2026-09-04 12:21:19 +08:00
8 changed files with 287 additions and 35 deletions

No files matched your search

+2 -1
View File
@@ -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 <branch> … origin/<branch>`)、把会话文件装到本机 `~/.claude/projects/` 对应目录(先清同 sid 副本)与 `~/.codex/sessions/`、写本机字段、清移交字段并删附件。之后 resume / 合并 / 冲突解决 / 测试与本机实施的工单无异。
8. **发布**:工具栏「发布」按钮下拉三项。「切新发布」从 `origin/<devBranch>` 切当天的 `<releaseBranchPrefix>YYYY-MM-DD` 并推远端(纯远端,不建本地分支),配了 `jenkinsJob` 会顺带问要不要立刻部署。「摘修复到当期发布」列出 `devBranch` 上还没进发布分支的提交,多选后在临时 worktree 里 `cherry-pick -x` 并推远端,冲突则中止、远端不变。「部署到正式服」选一个发布分支(默认当期,选旧的即回滚)执行 `opencli jks build <jenkinsJob> -p REF=<分支> -p REMOTE_HOST=<jenkinsProdHost>`。
8. **发布**:工具栏「发布」按钮下拉三项。「切新发布」从 `origin/<devBranch>` 切当天的 `<releaseBranchPrefix>YYYY-MM-DD` 并推远端(纯远端,不建本地分支),配了 `jenkinsJob` 会顺带问要不要立刻部署。「摘修复到当期发布」列出 `devBranch` 上还没进发布分支的提交(按文件改动指纹比对,另认 `cherry-pick -x` 留下的「cherry picked from commit」尾注,所以解过冲突、内容已变的复制品也算已摘;手工 pick 请带 `-x`),多选后在发布 worktree(`worktreeDirectory` 模板,目录名为分支名把 `/` 换成 `-`)里按序 `cherry-pick -x` 并推远端,成功后删 worktree。中途冲突则保留冲突现场、远端不变,开 `<发布分支>-冲突解决` 会话让 claude 解冲突、`cherry-pick --continue`、按序摘完剩余提交再推远端;上次冲突还没处理完时再摘会被拒绝。「部署到正式服」选一个发布分支(默认当期,选旧的即回滚)执行 `opencli jks build <jenkinsJob> -p REF=<分支> -p REMOTE_HOST=<jenkinsProdHost>`。
其他看板规则:
@@ -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 <profile.json> --dangerously-skip-permissions` 启动,后台无头调用会剥掉 `ANTHROPIC_*` 环境变量,让 profile 成为唯一鉴权来源。
+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra",
"name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra",
"version": "0.2.118",
"version": "0.2.121",
"packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra",
+102 -23
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)
@@ -154,12 +162,23 @@ export async function getReleaseStatus(o: { cwd: string, devBranch: string, pref
const current = pickCurrentRelease(o.prefix, await remoteBranchNames(o.cwd, o.prefix))
if (!current)
return base
const count = await mustGit(
const shas = await mustGit(
o.cwd,
['rev-list', '--count', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)],
['rev-list', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)],
'统计未发布提交失败',
)
return { devBranch: o.devBranch, current, unreleased: Number(count) }
const picked = await cherryPickedSources(o.cwd, `origin/${o.devBranch}..origin/${current}`)
const unreleased = shas.split('\n').filter(sha => sha && !picked.has(sha)).length
return { devBranch: o.devBranch, current, unreleased }
}
/**
* `cherry-pick -x` 在提交说明末尾留的来源提交号。解过冲突的复制品补丁内容已经变了,
* 指纹对不上,只剩这条线索能认出它摘过。
*/
async function cherryPickedSources(cwd: string, range: string): Promise<Set<string>> {
const bodies = await mustGit(cwd, ['log', '--no-merges', '--format=%b', range], '读取发布分支提交说明失败')
return new Set(Array.from(bodies.matchAll(/\(cherry picked from commit ([0-9a-f]{40})\)/g), m => m[1]))
}
/** 新→旧;不 fetch,调用方自己决定何时刷新远端 */
@@ -268,20 +287,24 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string,
)
if (!out)
return []
const releaseOnly = `origin/${o.devBranch}..origin/${o.current}`
const picked = await cherryPickedSources(o.cwd, releaseOnly)
const commits = out.split('\x1E').flatMap((chunk) => {
const trimmed = chunk.replace(/^\n+/, '').trimEnd()
if (!trimmed)
return []
const [sha, subject, author, date, body = ''] = trimmed.split('\0')
return [{ sha, subject, author, date, body: body.trim() }]
return picked.has(sha) ? [] : [{ sha, subject, author, date, body: body.trim() }]
})
if (commits.length === 0)
return []
const perCommit = await fileChangeKeysByCommit(o.cwd, ['--right-only', '--cherry-pick', unreleasedRange(o.current, o.devBranch)])
const files = new Set<string>()
for (const keys of perCommit.values()) {
for (const key of keys)
for (const c of commits) {
for (const key of perCommit.get(c.sha) ?? [])
files.add(key.slice(0, key.indexOf('\0')))
}
const onRelease = await fileChangeKeysByCommit(o.cwd, [`origin/${o.devBranch}..origin/${o.current}`], files)
const onRelease = await fileChangeKeysByCommit(o.cwd, [releaseOnly], files)
const released = new Set<string>()
for (const keys of onRelease.values()) {
for (const key of keys)
@@ -308,24 +331,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
}
}
+66 -2
View File
@@ -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})`,
})
}
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { terminalEditorColumn } from '../terminalColumn'
import { existingTerminalGroupColumn, terminalEditorColumn } from '../terminalColumn'
describe('terminalEditorColumn', () => {
it('分屏 → 右侧第 2 栏', () => {
@@ -9,3 +9,31 @@ describe('terminalEditorColumn', () => {
expect(terminalEditorColumn(false)).toBe(-1)
})
})
describe('existingTerminalGroupColumn', () => {
it('没有任何终端 tab → -1', () => {
expect(existingTerminalGroupColumn([
{ viewColumn: 1, isActive: true, tabs: [{ isTerminal: false }] },
])).toBe(-1)
})
it('已有一组含终端 → 该组 viewColumn', () => {
expect(existingTerminalGroupColumn([
{ viewColumn: 1, isActive: true, tabs: [{ isTerminal: true }] },
])).toBe(1)
})
it('多组时活动组有终端 → 活动组', () => {
expect(existingTerminalGroupColumn([
{ viewColumn: 1, isActive: false, tabs: [{ isTerminal: true }] },
{ viewColumn: 2, isActive: true, tabs: [{ isTerminal: true }] },
])).toBe(2)
})
it('多组时活动组没有终端、另一组有 → 有终端的那组', () => {
expect(existingTerminalGroupColumn([
{ viewColumn: 1, isActive: false, tabs: [{ isTerminal: true }] },
{ viewColumn: 2, isActive: true, tabs: [{ isTerminal: false }] },
])).toBe(1)
})
})
+15 -3
View File
@@ -4,13 +4,25 @@ import type {
} from 'vscode'
import type { Issue } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { window } from 'vscode'
import { TabInputTerminal, window } from 'vscode'
import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { terminalEditorColumn } from '../terminalColumn'
import { existingTerminalGroupColumn, terminalEditorColumn } from '../terminalColumn'
export function resolveTerminalLocation(panel: KanbanWebviewPanel | null, preserveFocus: boolean): TerminalEditorLocationOptions {
const split = panel ? getSettings(panel.context).splitNewTerminals : true
if (!panel) {
return {
viewColumn: existingTerminalGroupColumn(
window.tabGroups.all.map(g => ({
viewColumn: g.viewColumn,
isActive: g.isActive,
tabs: g.tabs.map(t => ({ isTerminal: t.input instanceof TabInputTerminal })),
})),
),
preserveFocus,
}
}
const split = getSettings(panel.context).splitNewTerminals
return { viewColumn: terminalEditorColumn(split), preserveFocus }
}
+18
View File
@@ -5,3 +5,21 @@
export function terminalEditorColumn(split: boolean): 2 | -1 {
return split ? 2 : -1
}
export interface EditorTabGroupLike {
readonly viewColumn: number
readonly isActive: boolean
readonly tabs: ReadonlyArray<{ readonly isTerminal: boolean }>
}
/** URI / 无看板开终端:复用已有终端所在组,避免无 panel 时强制劈出第 2 栏。 */
export function existingTerminalGroupColumn(groups: readonly EditorTabGroupLike[]): number {
const hasTerminal = (g: EditorTabGroupLike) => g.tabs.some(t => t.isTerminal)
const active = groups.find(g => g.isActive && hasTerminal(g))
if (active)
return active.viewColumn // ① 活动组已有终端 → 加进当前组,不劈栏
const first = groups.find(hasTerminal)
if (first)
return first.viewColumn // ② 终端在别的组 → 跟过去,不新开一栏
return -1 // ③ 一个终端组都没有 → Active(当前组只加标签),绝不默认 2
}
+54 -4
View File
@@ -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' })
@@ -169,6 +201,24 @@ describe('集成', () => {
const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' })
expect(list.map(c => c.subject)).toEqual(['feat: other'])
})
it('解过冲突的复制品内容不同,但带 -x 尾注就算已摘,角标计数同步扣掉', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
const fix = commit(fx.peer, 'a.txt', 'a2', 'fix a')
commit(fx.peer, 'c.txt', 'c', 'feat: other')
git(fx.peer, ['push', 'origin', 'main'])
git(fx.peer, ['fetch'])
git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31'])
writeFileSync(path.join(fx.peer, 'a.txt'), 'a2 resolved differently\n')
git(fx.peer, ['add', 'a.txt'])
git(fx.peer, ['commit', '-m', `fix a\n\n(cherry picked from commit ${fix})`])
git(fx.peer, ['push', 'origin', 'release/2026-08-31'])
git(fx.repo, ['fetch', 'origin'])
const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' })
expect(list.map(c => c.subject)).toEqual(['feat: other'])
const s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' })
expect(s.unreleased).toBe(1)
})
it('标题相同但改的文件不同,仍算未发布', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })