diff --git a/vscode/src/git/release.ts b/vscode/src/git/release.ts new file mode 100644 index 0000000..b7b9ff9 --- /dev/null +++ b/vscode/src/git/release.ts @@ -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 { + 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 { + 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 { + 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 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 { + 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 { + 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 { + 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 { + 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']) + } +} diff --git a/vscode/test/git/release.test.ts b/vscode/test/git/release.test.ts new file mode 100644 index 0000000..9a63350 --- /dev/null +++ b/vscode/test/git/release.test.ts @@ -0,0 +1,110 @@ +import { execFileSync } from 'node:child_process' +import { promises as fsp, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + cherryPickToRelease, + cutRelease, + getReleaseStatus, + listReleaseBranches, + listUnreleasedCommits, + normalizeReleasePrefix, + pickCurrentRelease, + todayReleaseBranch, +} from '../../src/git/release' + +const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } +const git = (cwd: string, args: string[]) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitEnv }).trim() + +interface Fx { root: string, origin: string, repo: string, peer: string } +async function setup(): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-release-')) + const origin = path.join(root, 'origin.git') + git(root, ['init', '--bare', '-b', 'main', origin]) + const peer = path.join(root, 'peer') + git(root, ['clone', origin, peer]) + commit(peer, 'a.txt', 'a', 'init') + git(peer, ['push', '-u', 'origin', 'main']) + const repo = path.join(root, 'repo') + git(root, ['clone', origin, repo]) + return { root, origin, repo, peer } +} +function commit(cwd: string, file: string, content: string, msg: string): string { + writeFileSync(path.join(cwd, file), `${content}\n`) + git(cwd, ['add', file]) + git(cwd, ['commit', '-m', msg]) + return git(cwd, ['rev-parse', 'HEAD']) +} +let fx: Fx | undefined +afterEach(() => { + if (fx) + rmSync(fx.root, { recursive: true, force: true }) + fx = undefined +}) + +describe('纯函数', () => { + it('normalizeReleasePrefix', () => { + expect(normalizeReleasePrefix('')).toBe('release/') + expect(normalizeReleasePrefix(' rel ')).toBe('rel/') + expect(normalizeReleasePrefix('release/')).toBe('release/') + }) + it('todayReleaseBranch', () => { + expect(todayReleaseBranch('release/', new Date(2026, 7, 31))).toBe('release/2026-08-31') + }) + it('pickCurrentRelease 取前缀内字典序最大', () => { + expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31') + expect(pickCurrentRelease('release/', ['main'])).toBeUndefined() + }) +}) + +describe('集成', () => { + it('无发布分支 → current undefined,unreleased 0', async () => { + fx = await setup() + const s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' }) + expect(s).toMatchObject({ devBranch: 'main', current: undefined, unreleased: 0 }) + expect(s.unavailable).toBeUndefined() + }) + it('cutRelease 纯远端切分支,不建本地分支', async () => { + fx = await setup() + const { repo, origin } = fx + await cutRelease({ cwd: repo, devBranch: 'main', branch: 'release/2026-08-31' }) + expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(git(origin, ['rev-parse', 'main'])) + expect(() => git(repo, ['rev-parse', '--verify', 'refs/heads/release/2026-08-31'])).toThrow() + await expect(cutRelease({ cwd: repo, devBranch: 'main', branch: 'release/2026-08-31' })).rejects.toThrow(/已存在/) + }) + it('unreleased 计数忽略已 cherry-pick 的提交;cherryPickToRelease 推远端并清理 worktree', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) + const fix = commit(fx.peer, 'b.txt', 'b', 'fix b') + commit(fx.peer, 'c.txt', 'c', 'feat c') + git(fx.peer, ['push', 'origin', 'main']) + let s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' }) + expect(s).toMatchObject({ current: 'release/2026-08-31', unreleased: 2 }) + const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' }) + expect(list.map(c => c.subject)).toEqual(['feat c', 'fix b']) + await cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix] }) + expect(git(fx.origin, ['log', '--format=%s', 'release/2026-08-31'])).toBe('fix b\ninit') + expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(1) + s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' }) + expect(s.unreleased).toBe(1) + }) + it('cherry-pick 冲突 → 抛错、远端不变、worktree 已清理', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) + commit(fx.peer, 'a.txt', 'a2', 'change a') + const conflict = commit(fx.peer, 'a.txt', 'a3', 'change a again') + git(fx.peer, ['push', 'origin', 'main']) + const before = git(fx.origin, ['rev-parse', 'release/2026-08-31']) + await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [conflict] })).rejects.toThrow(/冲突/) + expect(git(fx.origin, ['rev-parse', 'release/2026-08-31'])).toBe(before) + expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(1) + }) + it('listReleaseBranches 新→旧', async () => { + fx = await setup() + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-01' }) + await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) + git(fx.repo, ['fetch', 'origin']) + expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31', 'release/2026-08-01']) + }) +})