574 lines
23 KiB
TypeScript
574 lines
23 KiB
TypeScript
/**
|
||
* 发布分支管理的纯 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
|
||
}
|
||
}
|