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

574 lines
23 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 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
/** Gitea 查到的所属 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<Run> {
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<Run> {
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<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.unshift({ 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 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<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,调用方自己决定何时刷新远端 */
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
})
}
type FileChangeKeys = Map<string, string[]>
/** 一次 `git log -p` 切出每个提交每个文件的改动,再一次 `git patch-id` 算完指纹。 */
async function fileChangeKeysByCommit(cwd: string, logArgs: string[], onlyFiles?: Set<string>): Promise<FileChangeKeys> {
const out = new Map<string, string[]>()
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<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 []
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<string>()
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<string>()
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 读多个 `<ref>:<path>`;对象不存在时值为 undefined。按字节切内容,避免多字节字符错位。 */
async function catFileBatch(cwd: string, specs: string[]): Promise<Map<string, string | undefined>> {
const out = new Map<string, string | undefined>()
if (specs.length === 0)
return out
const buf = await new Promise<Buffer>((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<string, AlembicRevision>
revisions: Set<string>
children: Map<string, Set<string>>
dirs: Set<string>
}
/** 按 writing-alembic-migrations 的规矩:迁移只能接在发布分支已有的链尾上,前任缺失或已有别的后继都不能摘。 */
function judgeMigration(branch: string, own: Map<string, AlembicRevision | undefined>, 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<string, string[]>): Promise<UnreleasedCommit[]> {
const candidates = new Map<string, string[]>()
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<string, AlembicRevision | undefined>()
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<CutOutcome> {
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<CherryPickConflict | undefined> {
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/<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
}
}