Files
superwork/vscode/src/git/release.ts
T

265 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 发布分支管理的纯 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<Run> {
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<string> {
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<number, ReleaseCommitGroup>()
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<string[]> {
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<boolean> {
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<ReleaseStatus> {
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<string[]> {
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<Map<string, { number: number, title: string }>> {
const needed = new Set(o.shas)
const out = new Map<string, { number: number, title: string }>()
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<T extends { sha: string }>(
commits: T[],
index: Map<string, { number: number, title: string }>,
): Array<T & { pr?: number, prTitle?: string }> {
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<UnreleasedCommit[]> {
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<number> {
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<void> {
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'])
}
}