commit.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import { afterAll, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { spawnSync } from 'node:child_process'
  6. import { checkGitHealth } from '../src/commit/health'
  7. import { formatCommitMessage } from '../src/commit/message'
  8. import { checkCommitRelPath } from '../src/commit/paths'
  9. import { archiveChapter, archiveRetcon } from '../src/commit/archive'
  10. import { commitConfirmed } from '../src/commit/design'
  11. import { lastCommitOf } from '../src/commit/history'
  12. const roots: string[] = []
  13. function mkDir(prefix: string): string {
  14. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  15. roots.push(dir)
  16. return dir
  17. }
  18. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  19. function git(cwd: string, args: readonly string[]): ReturnType<typeof spawnSync> {
  20. return spawnSync('git', args, { cwd, encoding: 'utf-8', windowsHide: true })
  21. }
  22. function initRepo(): string {
  23. const root = mkDir('webnovel-git-')
  24. const init = git(root, ['init'])
  25. expect(init.status).toBe(0)
  26. git(root, ['config', 'user.email', 'test@example.com'])
  27. git(root, ['config', 'user.name', 'test'])
  28. git(root, ['config', 'commit.gpgsign', 'false'])
  29. return root
  30. }
  31. describe('git 健康检查(B6)', () => {
  32. it('干净仓库通过', () => {
  33. const root = initRepo()
  34. expect(checkGitHealth(root)).toEqual({ ok: true })
  35. })
  36. it('MERGE_HEAD→不通过', () => {
  37. const root = initRepo()
  38. fs.writeFileSync(path.join(root, '.git', 'MERGE_HEAD'), 'deadbeef\n')
  39. const h = checkGitHealth(root)
  40. expect(h.ok).toBe(false)
  41. if (!h.ok) expect(h.reason).toMatch(/合并|B6/)
  42. })
  43. it('worktree 里 .git 是文件,进行中状态仍能查到', () => {
  44. // 一本书仓内部开 worktree 做改稿对比是正当用法(见 single-workspace-rework.md §5)。
  45. // 那时 `<worktree>/.git` 是文件,状态存在 `<主仓>/.git/worktrees/<名>/` 内。
  46. const main = initRepo()
  47. fs.writeFileSync(path.join(main, '初章.md'), '正文\n')
  48. expect(git(main, ['add', '.']).status).toBe(0)
  49. expect(git(main, ['commit', '-m', 'ch: 首提交']).status).toBe(0)
  50. const wt = path.join(main, '..', path.basename(main) + '-改稿')
  51. roots.push(path.resolve(wt))
  52. expect(git(main, ['worktree', 'add', wt, '-b', '改稿']).status).toBe(0)
  53. expect(fs.statSync(path.join(wt, '.git')).isFile()).toBe(true)
  54. expect(checkGitHealth(wt)).toEqual({ ok: true })
  55. const gitDir = /gitdir:\s*(.+)/.exec(fs.readFileSync(path.join(wt, '.git'), 'utf-8'))?.[1]?.trim()
  56. expect(gitDir).toBeDefined()
  57. fs.writeFileSync(path.join(gitDir!, 'MERGE_HEAD'), 'deadbeef\n')
  58. const h = checkGitHealth(wt)
  59. expect(h.ok).toBe(false)
  60. if (!h.ok) expect(h.reason).toMatch(/合并|B6/)
  61. })
  62. it('.git 是文件但指针指向不存在的目录→不通过', () => {
  63. const root = mkDir('webnovel-git-bad-')
  64. fs.writeFileSync(path.join(root, '.git'), 'gitdir: ./不存在的目录\n')
  65. const h = checkGitHealth(root)
  66. expect(h.ok).toBe(false)
  67. if (!h.ok) expect(h.reason).toMatch(/版本库|B6/)
  68. })
  69. })
  70. describe('提交说明(D4)', () => {
  71. it('前缀格式', () => {
  72. expect(formatCommitMessage({ prefix: 'ch', summary: '初见入档' })).toBe('ch: 初见入档')
  73. expect(formatCommitMessage({ prefix: 'vol', summary: '卷一收束' })).toMatch(/^vol:/)
  74. expect(formatCommitMessage({ prefix: 'fix', summary: '补登' })).toMatch(/^fix:/)
  75. expect(formatCommitMessage({ prefix: 'retcon', summary: '死亡更正', kind: '吃书补偿' })).toMatch(/^retcon:/)
  76. })
  77. it('retcon 仅补偿通道', () => {
  78. expect(() => formatCommitMessage({ prefix: 'retcon', summary: '偷改' })).toThrow(/不变量 4/)
  79. })
  80. })
  81. describe('提交路径(不变量 8)', () => {
  82. it('拒绝知识库正文', () => {
  83. const r = checkCommitRelPath('知识库/写作方法库/节拍.md')
  84. expect(r.ok).toBe(false)
  85. if (!r.ok) expect(r.reason).toMatch(/不变量 8/)
  86. })
  87. it('允许定稿真源', () => {
  88. expect(checkCommitRelPath('定稿/卷01/0003-初见.md').ok).toBe(true)
  89. })
  90. })
  91. describe('清单入档', () => {
  92. it('快乐路径:清单一项写入定稿并 ch: 提交', () => {
  93. const root = initRepo()
  94. const pkg = path.join(root, '草稿区/定稿准备/卷01-初见')
  95. fs.mkdirSync(pkg, { recursive: true })
  96. fs.writeFileSync(path.join(pkg, '正文.md'), '第一章\n', 'utf-8')
  97. fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
  98. schemaVersion: 1,
  99. 文件: [{ 源: '正文.md', 目标: '定稿/卷01/0003-初见.md' }],
  100. }))
  101. const r = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '初见入档' })
  102. expect(r.ok).toBe(true)
  103. if (!r.ok) return
  104. expect(r.message.startsWith('ch:')).toBe(true)
  105. expect(fs.readFileSync(path.join(root, '定稿/卷01/0003-初见.md'), 'utf-8')).toBe('第一章\n')
  106. const log = git(root, ['log', '-1', '--pretty=%s'])
  107. expect(log.status).toBe(0)
  108. expect((log.stdout ?? '').trim().startsWith('ch:')).toBe(true)
  109. })
  110. it('章号跨卷重复被拒:章号全书连续(格式规格 §2.2)', () => {
  111. const root = initRepo()
  112. fs.mkdirSync(path.join(root, '定稿/卷01'), { recursive: true })
  113. fs.writeFileSync(path.join(root, '定稿/卷01/0005-已有.md'), '旧章\n', 'utf-8')
  114. const pkg = path.join(root, '草稿区/定稿准备/卷02-新章')
  115. fs.mkdirSync(pkg, { recursive: true })
  116. fs.writeFileSync(path.join(pkg, '正文.md'), '新章\n', 'utf-8')
  117. fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
  118. schemaVersion: 1,
  119. 文件: [{ 源: '正文.md', 目标: '定稿/卷02/0005-新章.md' }],
  120. }))
  121. const r = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '新章入档' })
  122. expect(r.ok).toBe(false)
  123. if (r.ok) return
  124. expect(r.reason).toMatch(/章号/)
  125. expect(r.reason).toContain('定稿/卷01/0005-已有.md')
  126. expect(fs.existsSync(path.join(root, '定稿/卷02/0005-新章.md'))).toBe(false)
  127. })
  128. it('同卷内递增章号正常入档', () => {
  129. const root = initRepo()
  130. fs.mkdirSync(path.join(root, '定稿/卷01'), { recursive: true })
  131. fs.writeFileSync(path.join(root, '定稿/卷01/0005-已有.md'), '旧章\n', 'utf-8')
  132. const pkg = path.join(root, '草稿区/定稿准备/卷02-续章')
  133. fs.mkdirSync(pkg, { recursive: true })
  134. fs.writeFileSync(path.join(pkg, '正文.md'), '续章\n', 'utf-8')
  135. fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
  136. schemaVersion: 1,
  137. 文件: [{ 源: '正文.md', 目标: '定稿/卷02/0006-续章.md' }],
  138. }))
  139. expect(archiveChapter({ bookRoot: root, packageDir: pkg, summary: '续章入档' }).ok).toBe(true)
  140. })
  141. it('补偿入口才产生 retcon: 前缀', () => {
  142. const root = initRepo()
  143. const r = archiveRetcon({
  144. bookRoot: root,
  145. files: [{ 目标: '世界书/人物/甲.md', 内容: '更正\n' }],
  146. summary: '死亡更正',
  147. })
  148. expect(r.ok).toBe(true)
  149. if (!r.ok) return
  150. expect(r.message.startsWith('retcon:')).toBe(true)
  151. const log = git(root, ['log', '-1', '--pretty=%s'])
  152. expect((log.stdout ?? '').trim().startsWith('retcon:')).toBe(true)
  153. })
  154. it('健康检查失败:不写文件、不提交', () => {
  155. const root = initRepo()
  156. fs.writeFileSync(path.join(root, '.git', 'MERGE_HEAD'), 'deadbeef\n')
  157. const dest = '定稿/卷01/0003-初见.md'
  158. const r = archiveChapter({
  159. bookRoot: root,
  160. files: [{ 目标: dest, 内容: '不该出现\n' }],
  161. summary: '初见入档',
  162. })
  163. expect(r.ok).toBe(false)
  164. expect(fs.existsSync(path.join(root, dest))).toBe(false)
  165. const log = git(root, ['log', '-1', '--pretty=%s'])
  166. expect(log.status).not.toBe(0)
  167. })
  168. })
  169. describe('提交说明:design 前缀与章号 Scope(拍板 1/7)', () => {
  170. it('design 携带活跃章 Scope,格式 design(chNNNN):', () => {
  171. expect(formatCommitMessage({ prefix: 'design', summary: '确认细纲', chapterScope: 12 })).toBe('design(ch0012): 确认细纲')
  172. expect(formatCommitMessage({ prefix: 'design', summary: '确认契约·核心设定' })).toBe('design: 确认契约·核心设定')
  173. })
  174. it('Scope 仅 design: 前缀携带', () => {
  175. expect(() => formatCommitMessage({ prefix: 'ch', summary: '初见入档', chapterScope: 3 })).toThrow(/Scope/)
  176. expect(() => formatCommitMessage({ prefix: 'fix', summary: '补登', chapterScope: 3 })).toThrow(/Scope/)
  177. })
  178. })
  179. describe('设计侧确认提交(commitConfirmed,拍板 1/2/7)', () => {
  180. it('隔离预先 staged 的无关文件,提交只包含本次目标且原暂存仍在', () => {
  181. const root = initRepo()
  182. fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
  183. fs.writeFileSync(path.join(root, '作品契约/无关.md'), '旧\n', 'utf-8')
  184. expect(git(root, ['add', '.']).status).toBe(0)
  185. expect(git(root, ['commit', '-m', 'vol: 基线']).status).toBe(0)
  186. fs.writeFileSync(path.join(root, '作品契约/无关.md'), '作者暂存修改\n', 'utf-8')
  187. expect(git(root, ['add', '--', '作品契约/无关.md']).status).toBe(0)
  188. fs.writeFileSync(path.join(root, '作品契约/本次.md'), '本次确认\n', 'utf-8')
  189. const result = commitConfirmed({ bookRoot: root, paths: ['作品契约/本次.md'], prefix: 'design', summary: '本次确认' })
  190. expect(result.ok).toBe(true)
  191. const show = git(root, ['-c', 'core.quotepath=false', 'show', '--name-only', '--pretty=format:', 'HEAD'])
  192. expect(show.stdout).toContain('作品契约/本次.md')
  193. expect(show.stdout).not.toContain('作品契约/无关.md')
  194. expect(git(root, ['-c', 'core.quotepath=false', 'diff', '--cached', '--name-only']).stdout?.trim()).toBe('作品契约/无关.md')
  195. const replay = commitConfirmed({ bookRoot: root, paths: ['作品契约/本次.md'], prefix: 'design', summary: '本次确认' })
  196. expect(replay.ok && replay.noChanges).toBe(true)
  197. })
  198. it('确认后产生 design: 提交,git log -1 -- 路径 命中', () => {
  199. const root = initRepo()
  200. fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
  201. fs.writeFileSync(path.join(root, '作品契约/契约.md'), '# 契约\n', 'utf-8')
  202. const r = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约·核心设定' })
  203. expect(r.ok).toBe(true)
  204. const log = git(root, ['log', '-1', '--pretty=%s', '--', '作品契约/契约.md'])
  205. expect((log.stdout ?? '').trim()).toBe('design: 确认契约·核心设定')
  206. })
  207. it('携带活跃章号:design(ch0012): 确认细纲', () => {
  208. const root = initRepo()
  209. fs.mkdirSync(path.join(root, '大纲/章细纲/卷01'), { recursive: true })
  210. fs.writeFileSync(path.join(root, '大纲/章细纲/卷01/0012-初见.md'), '# 细纲\n', 'utf-8')
  211. const r = commitConfirmed({
  212. bookRoot: root, paths: ['大纲/章细纲/卷01/0012-初见.md'], prefix: 'design',
  213. summary: '确认细纲·初见', chapterScope: 12,
  214. })
  215. expect(r.ok).toBe(true)
  216. const log = git(root, ['log', '-1', '--pretty=%s'])
  217. expect((log.stdout ?? '').trim()).toBe('design(ch0012): 确认细纲·初见')
  218. })
  219. it('整批多文件一次提交(拍板 7:一次确认一次提交)', () => {
  220. const root = initRepo()
  221. fs.mkdirSync(path.join(root, '大纲/卷规划/卷01'), { recursive: true })
  222. fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/卷纲.md'), '# 卷纲\n', 'utf-8')
  223. fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/近期窗口.md'), '# 近期窗口\n', 'utf-8')
  224. const r = commitConfirmed({
  225. bookRoot: root,
  226. paths: ['大纲/卷规划/卷01/卷纲.md', '大纲/卷规划/卷01/近期窗口.md'],
  227. prefix: 'design', summary: '卷纲·线索推进',
  228. })
  229. expect(r.ok).toBe(true)
  230. const show = git(root, ['-c', 'core.quotepath=false', 'show', '--name-only', '--pretty=format:'])
  231. const names = (show.stdout ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '')
  232. expect(names).toContain('大纲/卷规划/卷01/卷纲.md')
  233. expect(names).toContain('大纲/卷规划/卷01/近期窗口.md')
  234. expect(git(root, ['rev-list', '--count', 'HEAD']).stdout?.trim()).toBe('1')
  235. })
  236. it('幂等重放:上一次已提交→无改动不空提交', () => {
  237. const root = initRepo()
  238. fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
  239. fs.writeFileSync(path.join(root, '作品契约/契约.md'), '# 契约\n', 'utf-8')
  240. expect(commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' }).ok).toBe(true)
  241. const before = git(root, ['rev-list', '--count', 'HEAD']).stdout?.trim()
  242. const replay = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' })
  243. expect(replay.ok).toBe(true)
  244. if (!replay.ok) return
  245. expect(replay.noChanges).toBe(true)
  246. expect(git(root, ['rev-list', '--count', 'HEAD']).stdout?.trim()).toBe(before)
  247. })
  248. it('幂等重放:提交失败过(锁)→重跑补提交,不重写文件', () => {
  249. const root = initRepo()
  250. fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
  251. fs.writeFileSync(path.join(root, '作品契约/契约.md'), '# 契约\n', 'utf-8')
  252. fs.writeFileSync(path.join(root, '.git', 'index.lock'), '', 'utf-8')
  253. const blocked = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' })
  254. expect(blocked.ok).toBe(false)
  255. if (!blocked.ok) expect(blocked.reason).toMatch(/锁定|B6/)
  256. fs.rmSync(path.join(root, '.git', 'index.lock'))
  257. const replay = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' })
  258. expect(replay.ok).toBe(true)
  259. expect(fs.readFileSync(path.join(root, '作品契约/契约.md'), 'utf-8')).toBe('# 契约\n')
  260. const log = git(root, ['log', '-1', '--pretty=%s'])
  261. expect((log.stdout ?? '').trim()).toBe('design: 确认契约')
  262. })
  263. it('路径越界与草稿区路径拒绝', () => {
  264. const root = initRepo()
  265. expect(commitConfirmed({ bookRoot: root, paths: ['知识库/写作/节拍.md'], prefix: 'design', summary: 'x' }).ok).toBe(false)
  266. expect(commitConfirmed({ bookRoot: root, paths: ['草稿区/草稿/卷01-初见/稿1.md'], prefix: 'design', summary: 'x' }).ok).toBe(false)
  267. expect(commitConfirmed({ bookRoot: root, paths: ['../外部.md'], prefix: 'design', summary: 'x' }).ok).toBe(false)
  268. expect(commitConfirmed({ bookRoot: root, paths: [], prefix: 'design', summary: 'x' }).ok).toBe(false)
  269. const log = git(root, ['log', '--oneline'])
  270. expect((log.stdout ?? '').trim()).toBe('')
  271. })
  272. it('index.lock→拒绝,工作树保留改动可继续', () => {
  273. const root = initRepo()
  274. fs.mkdirSync(path.join(root, '世界书/人物'), { recursive: true })
  275. fs.writeFileSync(path.join(root, '世界书/人物/甲.md'), '# 甲\n', 'utf-8')
  276. fs.writeFileSync(path.join(root, '.git', 'index.lock'), '', 'utf-8')
  277. const r = commitConfirmed({ bookRoot: root, paths: ['世界书/人物/甲.md'], prefix: 'design', summary: '确认条目' })
  278. expect(r.ok).toBe(false)
  279. if (!r.ok) expect(r.reason).toMatch(/锁定|B6/)
  280. expect(fs.existsSync(path.join(root, '世界书/人物/甲.md'))).toBe(true)
  281. expect(git(root, ['log', '--oneline']).stdout?.trim()).toBe('')
  282. })
  283. })
  284. describe('归档附加提交路径(extraPaths,拍板 1:窗口标记随 ch:)', () => {
  285. it('归档失败重试保留无关 staged 项,文件名中的方括号按字面处理', () => {
  286. const root = initRepo()
  287. const target = '定稿/卷01/0001-[ab].md'
  288. const unrelated = '定稿/卷01/0001-a.md'
  289. fs.mkdirSync(path.join(root, '定稿/卷01'), { recursive: true })
  290. fs.writeFileSync(path.join(root, unrelated), 'author-staged\n')
  291. expect(git(root, ['add', '--', unrelated]).status).toBe(0)
  292. const hook = path.join(root, '.git/hooks/pre-commit')
  293. fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 })
  294. const input = { bookRoot: root, files: [{ 目标: target, 内容: 'chapter\n' }], summary: 'literal archive' }
  295. const failed = archiveChapter(input)
  296. expect(failed.ok).toBe(false)
  297. if (!failed.ok) expect(failed.written).toBe(true)
  298. expect(git(root, ['show', `:${unrelated}`]).stdout).toBe('author-staged\n')
  299. fs.unlinkSync(hook)
  300. expect(archiveChapter(input).ok).toBe(true)
  301. expect(git(root, ['-c', 'core.quotepath=false', 'show', '--format=', '--name-only', 'HEAD']).stdout?.trim()).toBe(target)
  302. expect(git(root, ['-c', 'core.quotepath=false', 'diff', '--cached', '--name-only']).stdout?.trim()).toBe(unrelated)
  303. })
  304. it('窗口标记随 ch: 提交,先前已提交的真源不在其中', () => {
  305. const root = initRepo()
  306. fs.mkdirSync(path.join(root, '大纲/卷规划/卷01'), { recursive: true })
  307. fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/近期窗口.md'), '# 近期窗口\n', 'utf-8')
  308. // 真源先经 design: 提交(提交点跟随确认),归档不再携带。
  309. const confirmed = commitConfirmed({ bookRoot: root, paths: ['大纲/卷规划/卷01/近期窗口.md'], prefix: 'design', summary: '滚动窗口' })
  310. expect(confirmed.ok).toBe(true)
  311. // 归档流程标记窗口已消费(此处直接改文件模拟),随 ch: 一并提交。
  312. fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/近期窗口.md'), '# 近期窗口\n- 初见 〔已消费〕\n', 'utf-8')
  313. const pkg = path.join(root, '草稿区/定稿准备/卷01-初见')
  314. fs.mkdirSync(pkg, { recursive: true })
  315. fs.writeFileSync(path.join(pkg, '正文.md'), '第一章\n', 'utf-8')
  316. fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
  317. schemaVersion: 1,
  318. 文件: [{ 源: '正文.md', 目标: '定稿/卷01/0003-初见.md' }],
  319. }))
  320. const r = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '初见入档', extraPaths: ['大纲/卷规划/卷01/近期窗口.md'] })
  321. expect(r.ok).toBe(true)
  322. const show = git(root, ['-c', 'core.quotepath=false', 'show', '--name-only', '--pretty=format:', 'HEAD'])
  323. const names = (show.stdout ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '')
  324. expect(names).toContain('定稿/卷01/0003-初见.md')
  325. expect(names).toContain('大纲/卷规划/卷01/近期窗口.md')
  326. const last = lastCommitOf(root, '大纲/卷规划/卷01/近期窗口.md')
  327. expect(last.ok).toBe(true)
  328. if (last.ok) expect(last.prefix).toBe('ch')
  329. })
  330. it('extraPaths 越界同样拒绝', () => {
  331. const root = initRepo()
  332. const r = archiveChapter({
  333. bookRoot: root,
  334. files: [{ 目标: '定稿/卷01/0003-初见.md', 内容: '第一章\n' }],
  335. summary: '初见入档',
  336. extraPaths: ['草稿区/草稿/x.md'],
  337. })
  338. expect(r.ok).toBe(false)
  339. })
  340. })
  341. describe('某工件最近一次提交(lastCommitOf,拍板 3)', () => {
  342. it('从 design(chNNNN) Scope 解析章号', () => {
  343. const root = initRepo()
  344. fs.mkdirSync(path.join(root, '大纲/章细纲/卷01'), { recursive: true })
  345. fs.writeFileSync(path.join(root, '大纲/章细纲/卷01/0012-初见.md'), '# 细纲\n', 'utf-8')
  346. expect(commitConfirmed({
  347. bookRoot: root, paths: ['大纲/章细纲/卷01/0012-初见.md'], prefix: 'design',
  348. summary: '确认细纲·初见', chapterScope: 12,
  349. }).ok).toBe(true)
  350. const r = lastCommitOf(root, '大纲/章细纲/卷01/0012-初见.md')
  351. expect(r.ok).toBe(true)
  352. if (!r.ok) return
  353. expect(r.prefix).toBe('design(ch0012)')
  354. expect(r.summary).toBe('确认细纲·初见')
  355. expect(r.chapter).toEqual({ 卷: null, 章: 12 })
  356. })
  357. it('从定稿章路径解析卷与章(ch: 不带 Scope)', () => {
  358. const root = initRepo()
  359. const pkg = path.join(root, '草稿区/定稿准备/卷01-初见')
  360. fs.mkdirSync(pkg, { recursive: true })
  361. fs.writeFileSync(path.join(pkg, '正文.md'), '第一章\n', 'utf-8')
  362. fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
  363. schemaVersion: 1,
  364. 文件: [{ 源: '正文.md', 目标: '定稿/卷01/0003-初见.md' }],
  365. }))
  366. expect(archiveChapter({ bookRoot: root, packageDir: pkg, summary: '初见入档' }).ok).toBe(true)
  367. const r = lastCommitOf(root, '定稿/卷01/0003-初见.md')
  368. expect(r.ok).toBe(true)
  369. if (!r.ok) return
  370. expect(r.prefix).toBe('ch')
  371. expect(r.chapter).toEqual({ 卷: 1, 章: 3 })
  372. })
  373. it('从未提交→kind never;非 git 仓→kind error', () => {
  374. const root = initRepo()
  375. fs.mkdirSync(path.join(root, '大纲'), { recursive: true })
  376. fs.writeFileSync(path.join(root, '大纲/卷纲.md'), '# 卷纲\n', 'utf-8')
  377. git(root, ['add', '大纲/卷纲.md'])
  378. git(root, ['commit', '-m', 'vol: 建书'])
  379. const never = lastCommitOf(root, '作品契约/契约.md')
  380. expect(never.ok).toBe(false)
  381. if (!never.ok) expect(never.kind).toBe('never')
  382. const bare = mkDir('webnovel-nogit-')
  383. const err = lastCommitOf(bare, '作品契约/契约.md')
  384. expect(err.ok).toBe(false)
  385. if (!err.ok) expect(err.kind).toBe('error')
  386. })
  387. })