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, groupCommitsByPr, parseGiteaMergeSubject, indexUnreleasedByMergePr, } 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() }) it('parseGiteaMergeSubject 认 Gitea 合并说明', () => { expect(parseGiteaMergeSubject("Merge pull request '连续包月会员订阅体系' (#260) from feature/monthly-membership into main")).toEqual({ number: 260, title: '连续包月会员订阅体系', }) expect(parseGiteaMergeSubject('fix: no merge')).toBeUndefined() }) it('groupCommitsByPr 按 Gitea 给的 pr 号分组,说明里没有 Closes 也能归组', () => { const groups = groupCommitsByPr([ { sha: '3e519ea', subject: 'fix(admin): 会员档位隐藏', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, { sha: '5572c4f', subject: 'fix(membership): 年付月度发卡', author: 'chehongweo', date: '2026-08-31', body: '', pr: 260, prTitle: '连续包月会员订阅体系' }, { sha: 'c', subject: 'chore: lone', author: 'you', date: '2026-08-30', body: '' }, ]) expect(groups.map(g => g.title)).toEqual(['未关联 PR', '连续包月会员订阅体系']) expect(groups[0].commits.map(c => c.sha)).toEqual(['c']) expect(groups[1].pr).toBe(260) expect(groups[1].commits.map(c => c.sha)).toEqual(['3e519ea', '5572c4f']) }) }) 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']) }) it('indexUnreleasedByMergePr 用本地合并记录把分支提交对上 PR', async () => { fx = await setup() await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) git(fx.peer, ['checkout', '-b', 'feature']) const a = commit(fx.peer, 'b.txt', 'b', 'fix(admin): 会员档位隐藏') const b = commit(fx.peer, 'c.txt', 'c', 'fix(membership): 年付月度发卡') git(fx.peer, ['checkout', 'main']) git(fx.peer, ['merge', '--no-ff', '-m', "Merge pull request '连续包月会员订阅体系' (#260) from feature into main", 'feature']) git(fx.peer, ['push', 'origin', 'main']) git(fx.repo, ['fetch', 'origin']) const map = await indexUnreleasedByMergePr({ cwd: fx.repo, current: 'release/2026-08-31', devBranch: 'main', shas: [a, b], }) expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) expect(map.get(b)).toEqual({ number: 260, title: '连续包月会员订阅体系' }) }) it('发布分支把同一改动拆成两条,即使标题不同也不再列出', async () => { fx = await setup() await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) writeFileSync(path.join(fx.peer, 'a.txt'), 'A\n') writeFileSync(path.join(fx.peer, 'b.txt'), 'B\n') git(fx.peer, ['add', 'a.txt', 'b.txt']) git(fx.peer, ['commit', '-m', 'fix(jobs): 一次性命令收口']) commit(fx.peer, 'c.txt', 'c', 'feat: other') git(fx.peer, ['push', 'origin', 'main']) git(fx.peer, ['fetch']) git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31']) writeFileSync(path.join(fx.peer, 'a.txt'), 'A\n') git(fx.peer, ['add', 'a.txt']) git(fx.peer, ['commit', '-m', 'part a']) writeFileSync(path.join(fx.peer, 'b.txt'), 'B\n') git(fx.peer, ['add', 'b.txt']) git(fx.peer, ['commit', '-m', 'part b']) git(fx.peer, ['push', 'origin', 'release/2026-08-31']) git(fx.repo, ['fetch', 'origin']) const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' }) expect(list.map(c => c.subject)).toEqual(['feat: other']) const s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' }) expect(s.unreleased).toBe(1) }) it('标题相同但改的文件不同,仍算未发布', async () => { fx = await setup() await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' }) git(fx.peer, ['fetch']) git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31']) writeFileSync(path.join(fx.peer, 'a.txt'), 'release-a\n') git(fx.peer, ['add', 'a.txt']) git(fx.peer, ['commit', '-m', 'fix(jobs): 一次性命令收口']) git(fx.peer, ['push', 'origin', 'release/2026-08-31']) git(fx.peer, ['checkout', 'main']) writeFileSync(path.join(fx.peer, 'b.txt'), 'main-b\n') git(fx.peer, ['add', 'b.txt']) git(fx.peer, ['commit', '-m', 'fix(jobs): 一次性命令收口']) git(fx.peer, ['push', 'origin', 'main']) git(fx.repo, ['fetch', 'origin']) const list = await listUnreleasedCommits({ cwd: fx.repo, devBranch: 'main', current: 'release/2026-08-31' }) expect(list.map(c => c.subject)).toEqual(['fix(jobs): 一次性命令收口']) }) })