Files
superwork/vscode/test/git/release.test.ts
T
cruldra 706057075d ✨ feat(vscode): 摘修复冲突不再中止,保留 worktree 自动开冲突解决会话接手
摘修复改在固定的发布 worktree(worktreeDirectory 模板,目录名为分支名
/ 换 -)里按序 cherry-pick -x。全部成功推远端并删 worktree;中途冲突则
保留冲突现场、远端不变,开 `<发布分支>-冲突解决` claude 会话解冲突、
--continue、按原顺序摘完剩余提交再推远端。同名终端活着直接注入提示词。

- 上次冲突没处理完再摘会被拒绝;目录模板解析到工作区本身也拒绝
- 目录被非 worktree 的垃圾占着时清掉重建
- 测试覆盖成功清理、冲突接力(含 -x 尾注保留)、拒绝工作区本身
- CLAUDE.md 补一条:收尾三步默认直接做,不问不提醒

Claude-Session: https://claude.ai/code/session_018HDxMmNDyfLa3bMTUctBzt
2026-09-06 13:32:47 +08:00

223 lines
12 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, 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,
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'])
}
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'])
// 目录被一个不是 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('标题相同但改的文件不同,仍算未发布', 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): 一次性命令收口'])
})
})