按 writing-alembic-migrations 的规矩判断:迁移的前任必须已在发布分支上, 且发布分支上该前任不能已有别的后继;根迁移接不上已有链、删迁移会断链也不可摘。 列表里带迁移的提交打「迁移」角标,不可摘的高亮警告色、禁用勾选并写明原因; 确认接口再校验一遍,绕过前端也摘不进去。 Claude-Session: https://claude.ai/code/session_01QWxLb6X8Vd2G5vHnp6R7qm
286 lines
16 KiB
TypeScript
286 lines
16 KiB
TypeScript
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,
|
||
parseGiteaMergeSubject,
|
||
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)
|
||
}
|
||
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('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 纯远端切分支,不建本地分支', 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'])
|
||
// 目录被一个不是 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', branch: 'release/2026-08-31' })
|
||
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', branch: 'release/2026-08-31' })
|
||
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', 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'])
|
||
})
|
||
it('解过冲突的复制品内容不同,但带 -x 尾注就算已摘,角标计数同步扣掉', async () => {
|
||
fx = await setup()
|
||
await cutRelease({ cwd: fx.repo, devBranch: 'main', branch: 'release/2026-08-31' })
|
||
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', 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): 一次性命令收口'])
|
||
})
|
||
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', branch: 'release/2026-08-31' })
|
||
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', branch: 'release/2026-08-31' })
|
||
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')
|
||
})
|
||
})
|