/** * 发布分支管理的纯 git 逻辑:切发布点、统计未发布提交、cherry-pick 修复到发布分支。 * 不依赖 VS Code API;所有写操作只碰远端引用和临时 worktree,不改用户工作区。 */ import { execFile } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' const DEFAULT_PREFIX = 'release/' export interface ReleaseStatus { devBranch: string /** 当期发布分支全名(含前缀);远端没有任何发布分支时为 undefined */ current: string | undefined /** 未发布提交数;current 为 undefined 时为 0 */ unreleased: number /** 结构性不可用(fetch 失败 / devBranch 远端不存在),带原因 */ unavailable?: string } export interface UnreleasedCommit { sha: string subject: string author: string date: string body: string /** Gitea 查到的所属 PR;没有就不分组。 */ pr?: number prTitle?: string } export interface ReleaseCommitGroup { pr?: number title: string authors: string[] commits: UnreleasedCommit[] } export class ReleaseError extends Error { constructor(message: string, public readonly detail?: string) { super(message) this.name = 'ReleaseError' } } interface Run { ok: boolean, stdout: string, stderr: string } function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise { return new Promise((resolve) => { execFile('git', ['-C', cwd, ...args], { timeout: timeoutMs, encoding: 'utf8' }, (err, stdout, stderr) => { resolve({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' }) }) }) } const tail = (s: string, n = 400): string => s.trim().slice(-n) async function mustGit(cwd: string, args: string[], failLabel: string): Promise { const r = await runGit(cwd, args) if (!r.ok) throw new ReleaseError(`${failLabel}:${tail(r.stderr)}`, r.stderr) return r.stdout.trim() } export function normalizeReleasePrefix(raw: string): string { const t = raw.trim() if (!t) return DEFAULT_PREFIX return t.endsWith('/') ? t : `${t}/` } 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') return `${prefix}${y}-${m}-${d}` } /** 发布分支名是 ISO 日期,字典序即时间序,不解析日期 */ export function pickCurrentRelease(prefix: string, remoteBranches: string[]): string | undefined { return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1) } /** Gitea 默认合并说明:Merge pull request '标题' (#260) from … */ export function parseGiteaMergeSubject(subject: string): { number: number, title: string } | undefined { const m = /^Merge pull request '(.+)' \(#(\d+)\)/.exec(subject) if (!m) return undefined return { title: m[1], number: Number(m[2]) } } /** 同 PR 收一组(新→旧首次出现顺序)。pr 号来自合并记录,不是扫提交说明。 */ export function groupCommitsByPr(commits: UnreleasedCommit[]): ReleaseCommitGroup[] { const prGroups = new Map() const prOrder: number[] = [] const ungrouped: UnreleasedCommit[] = [] for (const c of commits) { const pr = c.pr if (pr === undefined) { ungrouped.push(c) continue } let g = prGroups.get(pr) if (!g) { g = { pr, title: c.prTitle?.trim() || c.subject, authors: [], commits: [] } prGroups.set(pr, g) prOrder.push(pr) } g.commits.push(c) if (!g.authors.includes(c.author)) g.authors.push(c.author) } const out = prOrder.map(n => prGroups.get(n)!) if (ungrouped.length > 0) { const authors: string[] = [] for (const c of ungrouped) { if (!authors.includes(c.author)) authors.push(c.author) } out.push({ title: '未关联 PR', authors, commits: ungrouped }) } return out } async function remoteBranchNames(cwd: string, prefix: string): Promise { const out = await mustGit(cwd, ['for-each-ref', '--format=%(refname:strip=3)', `refs/remotes/origin/${prefix}`], '读取远端发布分支失败') return out ? out.split('\n') : [] } async function remoteRefExists(cwd: string, branch: string): Promise { return (await runGit(cwd, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`])).ok } /** 左边是发布分支、右边是开发分支:只数右边独有、且补丁内容没被 cherry-pick 过去的提交 */ 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, 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 current = pickCurrentRelease(o.prefix, await remoteBranchNames(o.cwd, o.prefix)) if (!current) return base const count = await mustGit( o.cwd, ['rev-list', '--count', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)], '统计未发布提交失败', ) return { devBranch: o.devBranch, current, unreleased: Number(count) } } /** 新→旧;不 fetch,调用方自己决定何时刷新远端 */ export async function listReleaseBranches(o: { cwd: string, prefix: string }): Promise { const names = await remoteBranchNames(o.cwd, o.prefix) return names.filter(b => b.startsWith(o.prefix)).sort().reverse() } /** 用本地 merge 记录把未发布 sha 对上 PR。不打 Gitea。 */ export async function indexUnreleasedByMergePr(o: { cwd: string current: string devBranch: string shas: readonly string[] }): Promise> { const needed = new Set(o.shas) const out = new Map() if (needed.size === 0) return out const raw = await mustGit( o.cwd, ['log', '--right-only', '--cherry-pick', '--merges', '--format=%H%x00%s', unreleasedRange(o.current, o.devBranch)], '读取合并记录失败', ) if (!raw) return out for (const line of raw.split('\n')) { const [sha, subject] = line.split('\0') if (!sha || !subject) continue const parsed = parseGiteaMergeSubject(subject) if (!parsed) continue const members = await mustGit( o.cwd, ['rev-list', '--no-merges', `${sha}^1..${sha}^2`], `读取 PR #${parsed.number} 提交失败`, ) for (const member of members ? members.split('\n') : []) { if (needed.has(member)) out.set(member, parsed) } if (out.size === needed.size) return out } return out } export function attachPulls( commits: T[], index: Map, ): Array { return commits.map((c) => { const hit = index.get(c.sha) return hit ? { ...c, pr: hit.number, prTitle: hit.title } : c }) } export async function listUnreleasedCommits(o: { cwd: string, devBranch: string, current: string }): Promise { const out = await mustGit( o.cwd, ['log', '--right-only', '--cherry-pick', '--no-merges', '--format=%H%x00%s%x00%an%x00%as%x00%b%x1e', unreleasedRange(o.current, o.devBranch)], '读取未发布提交失败', ) if (!out) return [] return 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() }] }) } /** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */ export async function cutRelease(o: { cwd: string, devBranch: string, branch: string }): Promise { await mustGit(o.cwd, ['fetch', 'origin', '--prune'], '拉取远端失败') if (await remoteRefExists(o.cwd, o.branch)) throw new ReleaseError(`发布分支 ${o.branch} 已存在`) const prefix = o.branch.slice(0, o.branch.lastIndexOf('/') + 1) const prev = pickCurrentRelease(prefix, await remoteBranchNames(o.cwd, prefix)) await mustGit(o.cwd, ['push', 'origin', `origin/${o.devBranch}:refs/heads/${o.branch}`], `推送 ${o.branch} 失败`) if (!prev) return 0 const count = await mustGit(o.cwd, ['rev-list', '--count', `origin/${prev}..origin/${o.devBranch}`], '统计提交数失败') return Number(count) } /** 在临时 worktree 里按给定顺序(旧→新)cherry-pick,成功才推远端;无论成败都清理 worktree */ export async function cherryPickToRelease(o: { cwd: string, branch: string, shas: string[] }): Promise { await mustGit(o.cwd, ['fetch', 'origin', '--prune'], '拉取远端失败') const tmp = await mkdtemp(path.join(os.tmpdir(), 'sw-release-')) 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) } } await mustGit(tmp, ['push', 'origin', `HEAD:refs/heads/${o.branch}`], `推送 ${o.branch} 失败`) } finally { await runGit(o.cwd, ['worktree', 'remove', '--force', tmp]) await rm(tmp, { recursive: true, force: true }) await runGit(o.cwd, ['worktree', 'prune']) } }