diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts index 52e4780..4c0533f 100644 --- a/vscode/src/git/release.ts +++ b/vscode/src/git/release.ts @@ -12,8 +12,6 @@ const DEFAULT_PREFIX = 'release/' export interface ReleaseStatus { devBranch: string - /** 下一次切发布会得到的分支全名;同一天已切过则带 .2 / .3 序号 */ - next: string /** 当期发布分支全名(含前缀);远端没有任何发布分支时为 undefined */ current: string | undefined /** 未发布提交数;current 为 undefined 时为 0 */ @@ -104,30 +102,16 @@ export function normalizeReleasePrefix(raw: string): string { return t.endsWith('/') ? t : `${t}/` } -/** 当天首次切不带序号;同一天再切从 .2 起,取已有最大序号加一,删过中间序号也不会倒退 */ -export function nextReleaseBranch(prefix: string, remoteBranches: string[], now = new Date()): string { +export function todayReleaseBranch(prefix: string, now = new Date()): string { const y = now.getFullYear() const m = String(now.getMonth() + 1).padStart(2, '0') const d = String(now.getDate()).padStart(2, '0') - const base = `${prefix}${y}-${m}-${d}` - const seqs = remoteBranches.flatMap((b) => { - if (b === base) - return [1] - if (!b.startsWith(`${base}.`)) - return [] - const n = Number(b.slice(base.length + 1)) - return Number.isInteger(n) && n > 1 ? [n] : [] - }) - return seqs.length === 0 ? base : `${base}.${Math.max(...seqs) + 1}` -} - -/** 先按日期再按同日序号;纯字典序会把 .10 排到 .2 前面 */ -export function compareReleaseBranches(a: string, b: string): number { - return a.localeCompare(b, 'en', { numeric: true }) + return `${prefix}${y}-${m}-${d}` } +/** 发布分支名是 ISO 日期,字典序即时间序,不解析日期 */ export function pickCurrentRelease(prefix: string, remoteBranches: string[]): string | undefined { - return remoteBranches.filter(b => b.startsWith(prefix)).sort(compareReleaseBranches).at(-1) + return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1) } /** Gitea 默认合并说明:Merge pull request '标题' (#260) from … */ @@ -184,17 +168,15 @@ async function remoteRefExists(cwd: string, branch: string): Promise { const unreleasedRange = (current: string, devBranch: string): string => `origin/${current}...origin/${devBranch}` export async function getReleaseStatus(o: { cwd: string, devBranch: string, prefix: string }): Promise { - const base = { devBranch: o.devBranch, next: nextReleaseBranch(o.prefix, []), current: undefined, unreleased: 0 } + const base = { devBranch: o.devBranch, current: undefined, unreleased: 0 } const fetched = await runGit(o.cwd, ['fetch', 'origin', '--prune']) if (!fetched.ok) return { ...base, unavailable: `拉取远端失败:${tail(fetched.stderr)}` } if (!await remoteRefExists(o.cwd, o.devBranch)) return { ...base, unavailable: `远端没有 ${o.devBranch}` } - const names = await remoteBranchNames(o.cwd, o.prefix) - const next = nextReleaseBranch(o.prefix, names) - const current = pickCurrentRelease(o.prefix, names) + const current = pickCurrentRelease(o.prefix, await remoteBranchNames(o.cwd, o.prefix)) if (!current) - return { ...base, next } + return base const shas = await mustGit( o.cwd, ['rev-list', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)], @@ -202,7 +184,7 @@ export async function getReleaseStatus(o: { cwd: string, devBranch: string, pref ) 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, next, current, unreleased } + return { devBranch: o.devBranch, current, unreleased } } /** @@ -217,7 +199,7 @@ async function cherryPickedSources(cwd: string, range: string): Promise { const names = await remoteBranchNames(o.cwd, o.prefix) - return names.filter(b => b.startsWith(o.prefix)).sort(compareReleaseBranches).reverse() + return names.filter(b => b.startsWith(o.prefix)).sort().reverse() } /** 用本地 merge 记录把未发布 sha 对上 PR。不打 Gitea。 */ @@ -478,20 +460,38 @@ async function attachMigrationVerdicts(cwd: string, current: string, commits: Un } /** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */ -export async function cutRelease(o: { cwd: string, devBranch: string, prefix: string, now?: Date }): Promise<{ branch: string, count: number }> { +export type CutOutcome + = | { kind: 'created', branch: string, count: number } + | { kind: 'advanced', branch: string, count: number } + | { kind: 'diverged', branch: string } + +/** + * 切发布 = 让今天的发布分支等于 origin/devBranch 此刻的内容: + * ① 今天还没切过 → 从 origin/devBranch 新建 + * ② 切过,分支上没别的提交 → 快进到 origin/devBranch,SHA 原样保留 + * ③ 切过,分支上已有单独摘的提交 → 快进不了,交给调用方逐个摘 + */ +export async function cutRelease(o: { cwd: string, devBranch: string, prefix: string, now?: Date }): Promise { await mustGit(o.cwd, ['fetch', 'origin', '--prune'], '拉取远端失败') + const branch = todayReleaseBranch(o.prefix, o.now) const names = await remoteBranchNames(o.cwd, o.prefix) - const branch = nextReleaseBranch(o.prefix, names, o.now) - const prev = pickCurrentRelease(o.prefix, names) - await mustGit( - o.cwd, - ['push', 'origin', `--force-with-lease=refs/heads/${branch}:`, `origin/${o.devBranch}:refs/heads/${branch}`], // ← 空 lease = 远端此刻必须不存在,fetch 之后别人抢先切了也推不上去 - `推送 ${branch} 失败`, - ) - if (!prev) - return { branch, count: 0 } - const count = await mustGit(o.cwd, ['rev-list', '--count', `origin/${prev}..origin/${o.devBranch}`], '统计提交数失败') - return { branch, count: Number(count) } + if (!names.includes(branch)) { // ① + const prev = pickCurrentRelease(o.prefix, names) + await mustGit( + o.cwd, + ['push', 'origin', `--force-with-lease=refs/heads/${branch}:`, `origin/${o.devBranch}:refs/heads/${branch}`], // ← 空 lease = 远端此刻必须不存在,fetch 之后别人抢先切了也推不上去 + `推送 ${branch} 失败`, + ) + const count = prev ? Number(await mustGit(o.cwd, ['rev-list', '--count', `origin/${prev}..origin/${o.devBranch}`], '统计提交数失败')) : 0 + return { kind: 'created', branch, count } + } + const linear = await runGit(o.cwd, ['merge-base', '--is-ancestor', `origin/${branch}`, `origin/${o.devBranch}`]) + if (!linear.ok) // ③ + return { kind: 'diverged', branch } + const count = Number(await mustGit(o.cwd, ['rev-list', '--count', `origin/${branch}..origin/${o.devBranch}`], '统计提交数失败')) + if (count > 0) // ② 不带 force:fetch 之后远端被人推过,git 自己会拒掉非快进 + await mustGit(o.cwd, ['push', 'origin', `origin/${o.devBranch}:refs/heads/${branch}`], `推送 ${branch} 失败`) + return { kind: 'advanced', branch, count } } /** 在临时 worktree 里按给定顺序(旧→新)cherry-pick,成功才推远端;无论成败都清理 worktree */ diff --git a/vscode/src/panel/handlers/release.ts b/vscode/src/panel/handlers/release.ts index 42cbfd0..d96c137 100644 --- a/vscode/src/panel/handlers/release.ts +++ b/vscode/src/panel/handlers/release.ts @@ -20,7 +20,7 @@ import { indexUnreleasedByMergePr, listReleaseBranches, listUnreleasedCommits, - nextReleaseBranch, + todayReleaseBranch, } from '../../git/release' import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir } from '../../git/worktreePath' import { triggerJenkinsBuild } from '../../jenkins/trigger' @@ -35,7 +35,7 @@ export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise 0, } @@ -45,7 +45,7 @@ export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise { return runReleaseAction(panel, async () => { const root = requireRoot() const s = getSettings(panel.context) - const { branch, count } = await cutRelease({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) - toast(panel, 'success', `已切发布分支 ${branch}(${count} 个提交)`) + const r = await cutRelease({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) + if (r.kind === 'diverged') { + const picked = await pickUnreleased(panel, { root, devBranch: s.devBranch, current: r.branch }) + if (picked !== 'done') + return + } + else if (r.kind === 'advanced' && r.count === 0) { + toast(panel, 'info', `${r.branch} 已经和 ${s.devBranch} 一致,没有新提交`) + return + } + else { + toast(panel, 'success', r.kind === 'created' + ? `已切发布分支 ${r.branch}(${r.count} 个提交)` + : `已把 ${s.devBranch} 上 ${r.count} 个新提交推进到 ${r.branch}`) + } if (!s.jenkinsJob) return - const pick = await window.showInformationMessage(`现在部署 ${branch} 到${s.jenkinsProdHost}?`, '部署') + const pick = await window.showInformationMessage(`现在部署 ${r.branch} 到${s.jenkinsProdHost}?`, '部署') if (pick === '部署') - await deploy(panel, branch) + await deploy(panel, r.branch) }) } @@ -140,35 +153,48 @@ export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas: const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix }) if (status.unavailable) throw new Error(status.unavailable) - const current = status.current - if (!current) + if (!status.current) throw new Error('尚无发布分支,先切新发布') - const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current }) - const wanted = new Set(shas) - const blocked = commits.find(c => wanted.has(c.sha) && c.migration?.blocked) - if (blocked) - throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`) - 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 { - conflict = await cherryPickToRelease({ cwd: root, branch: current, shas: ordered, worktreePath }) - } - finally { - dismiss(panel, spinner) - } - 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 }) + await pickUnreleased(panel, { root, devBranch: s.devBranch, current: status.current, shas }) }) } +/** + * 把 devBranch 上未发布的提交摘到 current,shas 不传就全摘。 + * 冲突时开会话接手并返回 'conflict',调用方据此决定后续(比如要不要问部署)。 + */ +async function pickUnreleased(panel: KanbanWebviewPanel, o: { root: string, devBranch: string, current: string, shas?: string[] }): Promise<'done' | 'conflict' | 'nothing'> { + const s = getSettings(panel.context) + const commits = await listUnreleasedCommits({ cwd: o.root, devBranch: o.devBranch, current: o.current }) + const wanted = o.shas && new Set(o.shas) + const chosen = wanted ? commits.filter(c => wanted.has(c.sha)) : commits + if (chosen.length === 0) { + toast(panel, 'info', '没有待摘的提交') + return 'nothing' + } + const blocked = chosen.find(c => c.migration?.blocked) + if (blocked) + throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`) + const ordered = chosen.map(c => c.sha).reverse() // ← 列表是新→旧,摘要旧→新 + // 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。 + const worktreePath = resolveWorktreeDir(s.worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE, o.root, o.current.replace(/\//g, '-')) + const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${o.current}…`, { spinner: true, dismissOnTimer: 0 }) + let conflict: CherryPickConflict | undefined + try { + conflict = await cherryPickToRelease({ cwd: o.root, branch: o.current, shas: ordered, worktreePath }) + } + finally { + dismiss(panel, spinner) + } + if (!conflict) { + toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${o.current}`) + return 'done' + } + openReleaseConflictSession(panel, { branch: o.current, conflict, subjectOf: new Map(commits.map(c => [c.sha, c.subject])) }) + toast(panel, 'info', `${conflict.sha.slice(0, 7)} ${conflict.subject} 冲突,已开 ${o.current} 冲突解决会话接手`, { dismissOnTimer: 10_000 }) + return 'conflict' +} + /** * 摘修复冲突后的接力:worktree 里 cherry-pick 停在中间态,开一个 cc 会话进去解决、 * 按原顺序摘完剩余提交并推远端。同名终端还活着就把提示词直接打进去,不再新开。 diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts index 02279ca..c696d49 100644 --- a/vscode/test/git/release.test.ts +++ b/vscode/test/git/release.test.ts @@ -11,11 +11,11 @@ import { indexUnreleasedByMergePr, listReleaseBranches, listUnreleasedCommits, - nextReleaseBranch, normalizeReleasePrefix, parseAlembicRevision, parseGiteaMergeSubject, pickCurrentRelease, + todayReleaseBranch, } from '../../src/git/release' const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } @@ -59,19 +59,11 @@ describe('纯函数', () => { expect(normalizeReleasePrefix(' rel ')).toBe('rel/') expect(normalizeReleasePrefix('release/')).toBe('release/') }) - it('nextReleaseBranch 当天首次不带序号,再切从 .2 起,取最大序号加一', () => { - expect(nextReleaseBranch('release/', ['main'], AUG31)).toBe('release/2026-08-31') - expect(nextReleaseBranch('release/', ['release/2026-08-30', 'release/2026-08-30.2'], AUG31)).toBe('release/2026-08-31') - expect(nextReleaseBranch('release/', ['release/2026-08-31'], AUG31)).toBe('release/2026-08-31.2') - expect(nextReleaseBranch('release/', ['release/2026-08-31', 'release/2026-08-31.2'], AUG31)).toBe('release/2026-08-31.3') - expect(nextReleaseBranch('release/', ['release/2026-08-31.3'], AUG31)).toBe('release/2026-08-31.4') - expect(nextReleaseBranch('release/', ['release/2026-08-31.x'], AUG31)).toBe('release/2026-08-31') + it('todayReleaseBranch', () => { + expect(todayReleaseBranch('release/', AUG31)).toBe('release/2026-08-31') }) - it('pickCurrentRelease 先按日期再按同日序号,.10 排在 .9 后', () => { + it('pickCurrentRelease 取前缀内字典序最大', () => { expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31') - expect(pickCurrentRelease('release/', ['release/2026-08-31.2', 'release/2026-08-31'])).toBe('release/2026-08-31.2') - expect(pickCurrentRelease('release/', ['release/2026-08-31.10', 'release/2026-08-31.9'])).toBe('release/2026-08-31.10') - expect(pickCurrentRelease('release/', ['release/2026-08-31.3', 'release/2026-09-01'])).toBe('release/2026-09-01') expect(pickCurrentRelease('release/', ['main'])).toBeUndefined() }) it('parseGiteaMergeSubject 认 Gitea 合并说明', () => { @@ -108,16 +100,33 @@ describe('集成', () => { expect(s).toMatchObject({ devBranch: 'main', current: undefined, unreleased: 0 }) expect(s.unavailable).toBeUndefined() }) - it('cutRelease 纯远端切分支,不建本地分支;同一天再切得到 .2', async () => { + it('cutRelease 纯远端切分支,不建本地分支;同一天再切 = 快进到 main,不另起分支', async () => { fx = await setup() - const { repo, origin } = fx - expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ branch: 'release/2026-08-31', count: 0 }) + const { repo, origin, peer } = fx + expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'created', branch: 'release/2026-08-31', count: 0 }) expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(git(origin, ['rev-parse', 'main'])) expect(() => git(repo, ['rev-parse', '--verify', 'refs/heads/release/2026-08-31'])).toThrow() - expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ branch: 'release/2026-08-31.2', count: 0 }) - expect(git(origin, ['rev-parse', 'release/2026-08-31.2'])).toBe(git(origin, ['rev-parse', 'main'])) - const s = await getReleaseStatus({ cwd: repo, devBranch: 'main', prefix: 'release/' }) - expect(s.current).toBe('release/2026-08-31.2') + expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'advanced', branch: 'release/2026-08-31', count: 0 }) + commit(peer, 'b.txt', 'b', 'feat b') + git(peer, ['push', 'origin', 'main']) + expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'advanced', branch: 'release/2026-08-31', count: 1 }) + expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(git(origin, ['rev-parse', 'main'])) + expect(git(origin, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/release/'])).toBe('release/2026-08-31') + }) + it('同一天再切但发布分支上已有单独摘的提交 → diverged,远端不动', async () => { + fx = await setup() + const { repo, origin, peer } = fx + await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 }) + const fix = commit(peer, 'b.txt', 'b', 'fix b') + commit(peer, 'c.txt', 'c', 'feat c') + git(peer, ['push', 'origin', 'main']) + git(peer, ['fetch', 'origin']) + git(peer, ['checkout', '-b', 'rel', 'origin/release/2026-08-31']) + git(peer, ['cherry-pick', '-x', fix]) + git(peer, ['push', 'origin', 'HEAD:release/2026-08-31']) + const before = git(origin, ['rev-parse', 'release/2026-08-31']) + expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'diverged', branch: 'release/2026-08-31' }) + expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(before) }) it('unreleased 计数忽略已 cherry-pick 的提交;cherryPickToRelease 推远端并清理 worktree', async () => { fx = await setup() @@ -178,13 +187,12 @@ describe('集成', () => { 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 () => { + it('listReleaseBranches 新→旧', async () => { fx = await setup() await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG01 }) await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 }) - await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 }) git(fx.repo, ['fetch', 'origin']) - expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31.2', 'release/2026-08-31', 'release/2026-08-01']) + expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31', 'release/2026-08-01']) }) it('indexUnreleasedByMergePr 用本地合并记录把分支提交对上 PR', async () => { fx = await setup() diff --git a/vscode/webview-ui/src/components/PanelHeader.tsx b/vscode/webview-ui/src/components/PanelHeader.tsx index c408ab8..7ba593c 100644 --- a/vscode/webview-ui/src/components/PanelHeader.tsx +++ b/vscode/webview-ui/src/components/PanelHeader.tsx @@ -140,7 +140,7 @@ export function PanelHeader({ className="absolute right-0 top-7 z-20 min-w-44 rounded border border-[var(--vscode-menu-border)] bg-[var(--vscode-menu-background)] py-1 text-xs text-[var(--vscode-menu-foreground)] shadow" >