/** * 发布分支管理的纯 git 逻辑:切发布点、统计未发布提交、cherry-pick 修复到发布分支。 * 不依赖 VS Code API;所有写操作只碰远端引用和发布 worktree,不改用户工作区。 */ import type { Buffer } from 'node:buffer' import { execFile } from 'node:child_process' import { access, mkdir, realpath } from 'node:fs/promises' import * as path from 'node:path' import { removeWorktreeDir } from './worktree' 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 /** 从合并记录认出的所属 PR;没有就不分组。 */ pr?: number prTitle?: string /** 带 alembic 迁移文件时才有;blocked 有值就不能摘。 */ migration?: MigrationVerdict } /** 提交带的 alembic 迁移文件,以及能否跟着摘到发布分支。 */ export interface MigrationVerdict { files: string[] /** 不能摘的原因;没有就可以摘 */ blocked?: string } export interface AlembicRevision { revision: string downRevisions: string[] } export interface ReleaseCommitGroup { pr?: number title: string authors: string[] 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) 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', maxBuffer: 512 * 1024 * 1024 }, (err, stdout, stderr) => { resolve({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' }) }) }) } function runGitStdin(args: string[], input: string, timeoutMs = 30_000): Promise { return new Promise((resolve) => { const child = execFile('git', args, { timeout: timeoutMs, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }, (err, stdout, stderr) => { resolve({ ok: !err, stdout: stdout ?? '', stderr: stderr ?? '' }) }) child.stdin?.end(input) }) } 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) } /** * 从合并进开发分支的提交说明里认出 PR 号和标题,认不出返回 `undefined`。 * * 认三种默认说明:Gitea 合并提交 `Merge pull request '标题' (#260) from …`;GitHub 合并提交 `Merge pull request #260 from owner/分支`, * 标题在说明正文第一行,正文为空就用来源分支;squash 合并的 `标题 (#260)`,两家都是这个格式。 * * @param subject 提交说明的标题行 * @param body 提交说明正文(标题行之后的部分),只有 GitHub 合并提交用得上 */ export function parseMergeSubject(subject: string, body = ''): { number: number, title: string } | undefined { const gitea = /^Merge pull request '(.+)' \(#(\d+)\)/.exec(subject) if (gitea) return { title: gitea[1], number: Number(gitea[2]) } const github = /^Merge pull request #(\d+) from (\S+)/.exec(subject) if (github) { const title = body.split('\n').map(line => line.trim()).find(Boolean) return { title: title ?? github[2], number: Number(github[1]) } } const squash = /^(.+) \(#(\d+)\)$/.exec(subject) if (squash) return { title: squash[1], number: Number(squash[2]) } return undefined } /** 同 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.unshift({ 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 shas = await mustGit( o.cwd, ['rev-list', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)], '统计未发布提交失败', ) 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> { 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,调用方自己决定何时刷新远端 */ 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() } /** 用本地的合并记录把未发布提交对上 PR:合并提交带进来的提交归那个 PR,squash 合并的提交看它自己的说明;不调代码托管接口。 */ export async function indexUnreleasedByMergePr(o: { cwd: string current: string devBranch: string commits: readonly { sha: string, subject: string }[] }): Promise> { const needed = new Set(o.commits.map(c => c.sha)) 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%x00%b%x1e', unreleasedRange(o.current, o.devBranch)], '读取合并记录失败', ) for (const chunk of raw ? raw.split('\x1E') : []) { const [sha, subject, body = ''] = chunk.replace(/^\n+/, '').split('\0') if (!sha || !subject) continue const parsed = parseMergeSubject(subject, body) 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 } // squash 合并没有合并提交,PR 号只在提交自己的说明里。 for (const c of o.commits) { if (out.has(c.sha)) continue const parsed = parseMergeSubject(c.subject) if (parsed) out.set(c.sha, parsed) } 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 }) } type FileChangeKeys = Map /** 一次 `git log -p` 切出每个提交每个文件的改动,再一次 `git patch-id` 算完指纹。 */ async function fileChangeKeysByCommit(cwd: string, logArgs: string[], onlyFiles?: Set): Promise { const out = new Map() const log = await mustGit(cwd, ['log', '-p', '--no-merges', '--format=commit %H', ...logArgs], '读取提交改动失败') if (!log) return out const blocks: Array<{ sha: string, file: string, text: string[] }> = [] let sha = '' for (const line of log.split('\n')) { const commit = /^commit ([0-9a-f]{40})$/.exec(line) if (commit) { sha = commit[1] out.set(sha, []) continue } const header = /^diff --git "?a\/.*? "?b\/(.+?)"?$/.exec(line) if (header) { blocks.push({ sha, file: header[1], text: [line] }) continue } blocks[blocks.length - 1]?.text.push(line) } const wanted = blocks.filter(b => !onlyFiles || onlyFiles.has(b.file)) if (wanted.length === 0) return out const stream = wanted .map((b, i) => `commit ${i.toString(16).padStart(40, '0')}\n${b.text.join('\n')}\n`) .join('') const ids = await runGitStdin(['patch-id', '--stable'], stream) for (const line of ids.stdout.split('\n')) { const [id, marker] = line.trim().split(/\s+/) if (!id || !marker) continue const block = wanted[Number.parseInt(marker, 16)] if (block) out.get(block.sha)?.push(`${block.file}\0${id}`) } return out } 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 [] 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 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() 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, [releaseOnly], files) const released = new Set() for (const keys of onRelease.values()) { for (const key of keys) released.add(key) } const kept = commits.filter((c) => { const keys = perCommit.get(c.sha) ?? [] return keys.length === 0 || keys.some(k => !released.has(k)) }) const filesOf = new Map(kept.map(c => [c.sha, (perCommit.get(c.sha) ?? []).map(k => k.slice(0, k.indexOf('\0')))])) return attachMigrationVerdicts(o.cwd, o.current, kept, filesOf) } export function parseAlembicRevision(text: string): AlembicRevision | undefined { const rev = /^revision[^=\n]*=\s*(['"])([^'"]+)\1/m.exec(text) if (!rev) return undefined const down = /^down_revision[^=\n]*=(.*)$/m.exec(text) const downRevisions = down ? Array.from(down[1].matchAll(/['"]([^'"]+)['"]/g), m => m[1]) : [] return { revision: rev[2], downRevisions } } function isMigrationPath(file: string): boolean { return /(?:^|\/)versions\/[^/]+\.py$/.test(file) && !file.endsWith('__init__.py') } /** 一次 cat-file --batch 读多个 `:`;对象不存在时值为 undefined。按字节切内容,避免多字节字符错位。 */ async function catFileBatch(cwd: string, specs: string[]): Promise> { const out = new Map() if (specs.length === 0) return out const buf = await new Promise((resolve, reject) => { const child = execFile('git', ['-C', cwd, 'cat-file', '--batch'], { encoding: 'buffer', maxBuffer: 512 * 1024 * 1024 }, (err, stdout) => { if (err) reject(new ReleaseError('读取迁移文件失败', String(err))) else resolve(stdout) }) child.stdin?.end(`${specs.join('\n')}\n`) }) let pos = 0 for (const spec of specs) { const eol = buf.indexOf(0x0A, pos) const header = buf.toString('utf8', pos, eol).split(' ') pos = eol + 1 if (header[1] === 'missing') { out.set(spec, undefined) continue } const size = Number(header[2]) out.set(spec, buf.toString('utf8', pos, pos + size)) pos += size + 1 } return out } interface ReleaseMigrations { byPath: Map revisions: Set children: Map> dirs: Set } /** 按 writing-alembic-migrations 的规矩:迁移只能接在发布分支已有的链尾上,前任缺失或已有别的后继都不能摘。 */ function judgeMigration(branch: string, own: Map, rel: ReleaseMigrations): string | undefined { const added = new Set(Array.from(own.values()).flatMap(r => r ? [r.revision] : [])) for (const [file, r] of own) { if (!r) { const gone = rel.byPath.get(file)! if ((rel.children.get(gone.revision)?.size ?? 0) > 0) return `删掉 ${gone.revision} 后 ${branch} 上断链` continue } if (r.downRevisions.length === 0 && !rel.revisions.has(r.revision) && rel.dirs.has(path.posix.dirname(file))) return `根迁移接不上 ${branch} 已有的迁移链` for (const d of r.downRevisions) { if (added.has(d)) continue if (!rel.revisions.has(d)) return `上游迁移 ${d} 不在 ${branch}` if (Array.from(rel.children.get(d) ?? []).some(x => x !== r.revision)) return `${branch} 上 ${d} 已有后继,会出现多个 head` } } return undefined } async function attachMigrationVerdicts(cwd: string, current: string, commits: UnreleasedCommit[], filesOf: Map): Promise { const candidates = new Map() for (const c of commits) { const files = (filesOf.get(c.sha) ?? []).filter(isMigrationPath) if (files.length > 0) candidates.set(c.sha, files) } if (candidates.size === 0) return commits const ref = `origin/${current}` const dirs = new Set(Array.from(candidates.values()).flat().map(f => path.posix.dirname(f))) const listed = await mustGit(cwd, ['ls-tree', '-r', '--name-only', ref, '--', ...dirs], '读取发布分支迁移目录失败') const releasePaths = listed.split('\n').filter(isMigrationPath) const specs = Array.from(candidates, ([sha, files]) => files.map(f => `${sha}:${f}`)).flat() const blobs = await catFileBatch(cwd, [...specs, ...releasePaths.map(p => `${ref}:${p}`)]) const rel: ReleaseMigrations = { byPath: new Map(), revisions: new Set(), children: new Map(), dirs: new Set() } for (const p of releasePaths) { const parsed = parseAlembicRevision(blobs.get(`${ref}:${p}`) ?? '') if (!parsed) continue rel.byPath.set(p, parsed) rel.revisions.add(parsed.revision) rel.dirs.add(path.posix.dirname(p)) for (const d of parsed.downRevisions) { if (!rel.children.has(d)) rel.children.set(d, new Set()) rel.children.get(d)!.add(parsed.revision) } } return commits.map((c) => { const files = candidates.get(c.sha) if (!files) return c const own = new Map() for (const f of files) { const text = blobs.get(`${c.sha}:${f}`) if (text === undefined) { if (rel.byPath.has(f)) own.set(f, undefined) continue } const parsed = parseAlembicRevision(text) if (parsed) own.set(f, parsed) } if (own.size === 0) return c const blocked = judgeMigration(current, own, rel) return { ...c, migration: blocked ? { files: [...own.keys()], blocked } : { files: [...own.keys()] } } }) } 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) 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 */ export async function cherryPickToRelease(o: { cwd: string, branch: string, shas: string[], worktreePath: string }): Promise { await mustGit(o.cwd, ['fetch', 'origin', '--prune'], '拉取远端失败') await prepareReleaseWorktree(o.cwd, o.branch, o.worktreePath) let conflict: CherryPickConflict | undefined try { 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(o.worktreePath, ['push', 'origin', `HEAD:refs/heads/${o.branch}`], `推送 ${o.branch} 失败`) return undefined } finally { 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 } }