✨ feat(vscode): 发布分支 git 逻辑(切分支 / 未发布计数 / cherry-pick)
Claude-Session: https://claude.ai/code/session_01HTMgwaE3woqM5yvsHiuSAa
This commit is contained in:
1 parent
e10fe93bc6
commit
fa32d4a88b
2 files changed
+269
No files matched your search
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 发布分支管理的纯 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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', unreleasedRange(o.current, o.devBranch)],
|
||||
'读取未发布提交失败',
|
||||
)
|
||||
if (!out)
|
||||
return []
|
||||
return out.split('\n').map((line) => {
|
||||
const [sha, subject, author, date] = line.split('\0')
|
||||
return { sha, subject, author, date }
|
||||
})
|
||||
}
|
||||
|
||||
/** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 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'])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user