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

337 lines
20 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.
import { execFileSync } from 'node:child_process'
import { existsSync, promises as fsp, mkdirSync, 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,
groupCommitsByPr,
indexUnreleasedByMergePr,
listReleaseBranches,
listUnreleasedCommits,
normalizeReleasePrefix,
parseAlembicRevision,
parseMergeSubject,
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<Fx> {
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'])
}
function migration(cwd: string, file: string, rev: string, down: string | null, msg: string): string {
mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true })
return commit(cwd, file, `revision = '${rev}'\ndown_revision = ${down === null ? 'None' : `'${down}'`}\n`, msg)
}
const AUG01 = new Date(2026, 7, 1)
const AUG31 = new Date(2026, 7, 31)
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/', AUG31)).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('parseMergeSubject 认 Gitea 合并说明', () => {
expect(parseMergeSubject('Merge pull request \'连续包月会员订阅体系\' (#260) from feature/monthly-membership into main')).toEqual({
number: 260,
title: '连续包月会员订阅体系',
})
expect(parseMergeSubject('fix: no merge')).toBeUndefined()
})
it('parseMergeSubject 认 GitHub 合并说明,标题取说明正文第一行,没有正文就用来源分支', () => {
expect(parseMergeSubject('Merge pull request #12 from cruldra/feature/x', '\n会员订阅\n\n细节')).toEqual({ number: 12, title: '会员订阅' })
expect(parseMergeSubject('Merge pull request #12 from cruldra/feature/x')).toEqual({ number: 12, title: 'cruldra/feature/x' })
})
it('parseMergeSubject 认 squash 合并的「标题 (#N)」', () => {
expect(parseMergeSubject('feat: 会员订阅 (#34)')).toEqual({ number: 34, title: 'feat: 会员订阅' })
expect(parseMergeSubject('fix (#34) 不在结尾')).toBeUndefined()
})
it('parseAlembicRevision 认裸赋值、带类型标注、元组和 None', () => {
expect(parseAlembicRevision('revision = \'a1\'\ndown_revision = \'a0\'\n')).toEqual({ revision: 'a1', downRevisions: ['a0'] })
expect(parseAlembicRevision('revision: str = "a1"\ndown_revision: Union[str, None] = "a0"\nbranch_labels = None\n')).toEqual({ revision: 'a1', downRevisions: ['a0'] })
expect(parseAlembicRevision('revision = \'m\'\ndown_revision = (\'a\', \'b\')\n')).toEqual({ revision: 'm', downRevisions: ['a', 'b'] })
expect(parseAlembicRevision('revision: str = \'root\'\ndown_revision: Union[str, None] = None\n')).toEqual({ revision: 'root', downRevisions: [] })
expect(parseAlembicRevision('print(1)\n')).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 纯远端切分支,不建本地分支;同一天再切 = 快进到 main,不另起分支', async () => {
fx = await setup()
const { repo, origin, peer } = fx
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'created', branch: 'release/2026-08-31', count: 0 })
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()
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'advanced', branch: 'release/2026-08-31', count: 0 })
commit(peer, 'b.txt', 'b', 'feat b')
git(peer, ['push', 'origin', 'main'])
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'advanced', branch: 'release/2026-08-31', count: 1 })
expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(git(origin, ['rev-parse', 'main']))
expect(git(origin, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/release/'])).toBe('release/2026-08-31')
})
it('同一天再切但发布分支上已有单独摘的提交 → diverged,远端不动', async () => {
fx = await setup()
const { repo, origin, peer } = fx
await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
const fix = commit(peer, 'b.txt', 'b', 'fix b')
commit(peer, 'c.txt', 'c', 'feat c')
git(peer, ['push', 'origin', 'main'])
git(peer, ['fetch', 'origin'])
git(peer, ['checkout', '-b', 'rel', 'origin/release/2026-08-31'])
git(peer, ['cherry-pick', '-x', fix])
git(peer, ['push', 'origin', 'HEAD:release/2026-08-31'])
const before = git(origin, ['rev-parse', 'release/2026-08-31'])
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'diverged', branch: 'release/2026-08-31' })
expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(before)
})
it('unreleased 计数忽略已 cherry-pick 的提交;cherryPickToRelease 推远端并清理 worktree', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
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'])
// 目录被一个不是 worktree 的垃圾目录占着,应当清掉重建而不是撞路径
const worktreePath = path.join(fx.root, 'wt', 'release-2026-08-31')
await fsp.mkdir(worktreePath, { recursive: true })
writeFileSync(path.join(worktreePath, 'junk.txt'), 'junk\n')
await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix], worktreePath })).resolves.toBeUndefined()
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)
expect(existsSync(worktreePath)).toBe(false)
s = await getReleaseStatus({ cwd: fx.repo, devBranch: 'main', prefix: 'release/' })
expect(s.unreleased).toBe(1)
})
it('cherry-pick 冲突 → 返回冲突现场、worktree 留在中间态、远端不变;没处理完拒绝再摘,处理完复用同一 worktree', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
commit(fx.peer, 'a.txt', 'a2', 'change a')
const conflict = commit(fx.peer, 'a.txt', 'a3', 'change a again')
const after = commit(fx.peer, 'd.txt', 'd', 'feat d')
git(fx.peer, ['push', 'origin', 'main'])
const before = git(fx.origin, ['rev-parse', 'release/2026-08-31'])
const worktreePath = path.join(fx.root, 'wt', 'release-2026-08-31')
const o = { cwd: fx.repo, branch: 'release/2026-08-31', worktreePath }
await expect(cherryPickToRelease({ ...o, shas: [conflict, after] })).resolves.toEqual({
sha: conflict,
subject: 'change a again',
worktreePath,
remaining: [after],
})
expect(git(fx.origin, ['rev-parse', 'release/2026-08-31'])).toBe(before)
expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(2)
expect(git(worktreePath, ['rev-parse', '--verify', 'CHERRY_PICK_HEAD'])).toBe(conflict)
await expect(cherryPickToRelease({ ...o, shas: [after] })).rejects.toThrow(/还没处理完/)
// 模拟冲突解决会话:解冲突、continue、推远端
writeFileSync(path.join(worktreePath, 'a.txt'), 'a3\n')
git(worktreePath, ['add', 'a.txt'])
git(worktreePath, ['-c', 'core.editor=true', 'cherry-pick', '--continue'])
git(worktreePath, ['push', 'origin', 'HEAD:refs/heads/release/2026-08-31'])
expect(git(fx.origin, ['log', '-1', '--format=%b', 'release/2026-08-31'])).toContain(`(cherry picked from commit ${conflict})`)
await expect(cherryPickToRelease({ ...o, shas: [after] })).resolves.toBeUndefined()
expect(git(fx.origin, ['log', '--format=%s', 'release/2026-08-31'])).toBe('feat d\nchange a again\ninit')
expect(git(fx.repo, ['worktree', 'list']).split('\n')).toHaveLength(1)
})
it('发布 worktree 目录解析到工作区本身 → 拒绝', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
const fix = commit(fx.peer, 'b.txt', 'b', 'fix b')
git(fx.peer, ['push', 'origin', 'main'])
await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix], worktreePath: fx.repo })).rejects.toThrow(/工作区本身/)
expect(git(fx.repo, ['status', '--porcelain'])).toBe('')
})
it('listReleaseBranches 新→旧', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG01 })
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
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', prefix: 'release/', now: AUG31 })
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',
commits: [{ sha: a, subject: 'fix(admin): 会员档位隐藏' }, { sha: b, subject: 'fix(membership): 年付月度发卡' }],
})
expect(map.get(a)).toEqual({ number: 260, title: '连续包月会员订阅体系' })
expect(map.get(b)).toEqual({ number: 260, title: '连续包月会员订阅体系' })
})
it('indexUnreleasedByMergePr 认 GitHub 的合并提交和 squash 提交', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
git(fx.peer, ['checkout', '-b', 'feature'])
const a = commit(fx.peer, 'b.txt', 'b', 'fix: 档位')
git(fx.peer, ['checkout', 'main'])
git(fx.peer, ['merge', '--no-ff', '-m', 'Merge pull request #12 from cruldra/feature', '-m', '会员订阅', 'feature'])
const squashed = commit(fx.peer, 'c.txt', 'c', 'feat: 发卡 (#13)')
const lone = commit(fx.peer, 'd.txt', 'd', 'chore: lone')
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',
commits: [{ sha: lone, subject: 'chore: lone' }, { sha: squashed, subject: 'feat: 发卡 (#13)' }, { sha: a, subject: 'fix: 档位' }],
})
expect(map.get(a)).toEqual({ number: 12, title: '会员订阅' })
expect(map.get(squashed)).toEqual({ number: 13, title: 'feat: 发卡' })
expect(map.has(lone)).toBe(false)
})
it('发布分支把同一改动拆成两条,即使标题不同也不再列出', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
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'])
})
it('解过冲突的复制品内容不同,但带 -x 尾注就算已摘,角标计数同步扣掉', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
const fix = commit(fx.peer, 'a.txt', 'a2', 'fix a')
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'), 'a2 resolved differently\n')
git(fx.peer, ['add', 'a.txt'])
git(fx.peer, ['commit', '-m', `fix a\n\n(cherry picked from commit ${fix})`])
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', prefix: 'release/', now: AUG31 })
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): 一次性命令收口'])
})
it('带迁移的提交:前任在发布分支上才能摘,前任缺失的标不可摘,不带迁移的不打标', async () => {
fx = await setup()
migration(fx.peer, 'alembic/versions/0001_init.py', '0001', null, 'feat: init schema')
git(fx.peer, ['push', 'origin', 'main'])
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
migration(fx.peer, 'alembic/versions/0002_x.py', '0002', '0001', 'feat: m2')
migration(fx.peer, 'alembic/versions/0003_y.py', '0003', '0002', 'feat: m3')
commit(fx.peer, 'b.txt', 'b', 'fix: plain')
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: plain', 'feat: m3', 'feat: m2'])
expect(list[0].migration).toBeUndefined()
expect(list[1].migration?.files).toEqual(['alembic/versions/0003_y.py'])
expect(list[1].migration?.blocked).toContain('0002')
expect(list[2].migration).toEqual({ files: ['alembic/versions/0002_x.py'] })
})
it('发布分支上同一前任已有后继 → 多 head,不可摘', async () => {
fx = await setup()
migration(fx.peer, 'alembic/versions/0001_init.py', '0001', null, 'feat: init schema')
git(fx.peer, ['push', 'origin', 'main'])
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
migration(fx.peer, 'alembic/versions/0002_x.py', '0002', '0001', 'feat: m2')
git(fx.peer, ['push', 'origin', 'main'])
git(fx.peer, ['fetch'])
git(fx.peer, ['checkout', '-b', 'release/2026-08-31', 'origin/release/2026-08-31'])
migration(fx.peer, 'alembic/versions/0002_hotfix.py', '0002b', '0001', 'fix: hotfix schema')
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: m2'])
expect(list[0].migration?.blocked).toContain('多个 head')
})
})