| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- import { afterAll, describe, expect, it } from 'vitest'
- import * as fs from 'node:fs'
- import * as path from 'node:path'
- import * as os from 'node:os'
- import { spawnSync } from 'node:child_process'
- import { checkGitHealth } from '../src/commit/health'
- import { formatCommitMessage } from '../src/commit/message'
- import { checkCommitRelPath } from '../src/commit/paths'
- import { archiveChapter, archiveRetcon } from '../src/commit/archive'
- import { commitConfirmed } from '../src/commit/design'
- import { lastCommitOf } from '../src/commit/history'
- const roots: string[] = []
- function mkDir(prefix: string): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
- roots.push(dir)
- return dir
- }
- afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
- function git(cwd: string, args: readonly string[]): ReturnType<typeof spawnSync> {
- return spawnSync('git', args, { cwd, encoding: 'utf-8', windowsHide: true })
- }
- function initRepo(): string {
- const root = mkDir('webnovel-git-')
- const init = git(root, ['init'])
- expect(init.status).toBe(0)
- git(root, ['config', 'user.email', 'test@example.com'])
- git(root, ['config', 'user.name', 'test'])
- git(root, ['config', 'commit.gpgsign', 'false'])
- return root
- }
- describe('git 健康检查(B6)', () => {
- it('干净仓库通过', () => {
- const root = initRepo()
- expect(checkGitHealth(root)).toEqual({ ok: true })
- })
- it('MERGE_HEAD→不通过', () => {
- const root = initRepo()
- fs.writeFileSync(path.join(root, '.git', 'MERGE_HEAD'), 'deadbeef\n')
- const h = checkGitHealth(root)
- expect(h.ok).toBe(false)
- if (!h.ok) expect(h.reason).toMatch(/合并|B6/)
- })
- it('worktree 里 .git 是文件,进行中状态仍能查到', () => {
- // 一本书仓内部开 worktree 做改稿对比是正当用法(见 single-workspace-rework.md §5)。
- // 那时 `<worktree>/.git` 是文件,状态存在 `<主仓>/.git/worktrees/<名>/` 内。
- const main = initRepo()
- fs.writeFileSync(path.join(main, '初章.md'), '正文\n')
- expect(git(main, ['add', '.']).status).toBe(0)
- expect(git(main, ['commit', '-m', 'ch: 首提交']).status).toBe(0)
- const wt = path.join(main, '..', path.basename(main) + '-改稿')
- roots.push(path.resolve(wt))
- expect(git(main, ['worktree', 'add', wt, '-b', '改稿']).status).toBe(0)
- expect(fs.statSync(path.join(wt, '.git')).isFile()).toBe(true)
- expect(checkGitHealth(wt)).toEqual({ ok: true })
- const gitDir = /gitdir:\s*(.+)/.exec(fs.readFileSync(path.join(wt, '.git'), 'utf-8'))?.[1]?.trim()
- expect(gitDir).toBeDefined()
- fs.writeFileSync(path.join(gitDir!, 'MERGE_HEAD'), 'deadbeef\n')
- const h = checkGitHealth(wt)
- expect(h.ok).toBe(false)
- if (!h.ok) expect(h.reason).toMatch(/合并|B6/)
- })
- it('.git 是文件但指针指向不存在的目录→不通过', () => {
- const root = mkDir('webnovel-git-bad-')
- fs.writeFileSync(path.join(root, '.git'), 'gitdir: ./不存在的目录\n')
- const h = checkGitHealth(root)
- expect(h.ok).toBe(false)
- if (!h.ok) expect(h.reason).toMatch(/版本库|B6/)
- })
- })
- describe('提交说明(D4)', () => {
- it('前缀格式', () => {
- expect(formatCommitMessage({ prefix: 'ch', summary: '初见入档' })).toBe('ch: 初见入档')
- expect(formatCommitMessage({ prefix: 'vol', summary: '卷一收束' })).toMatch(/^vol:/)
- expect(formatCommitMessage({ prefix: 'fix', summary: '补登' })).toMatch(/^fix:/)
- expect(formatCommitMessage({ prefix: 'retcon', summary: '死亡更正', kind: '吃书补偿' })).toMatch(/^retcon:/)
- })
- it('retcon 仅补偿通道', () => {
- expect(() => formatCommitMessage({ prefix: 'retcon', summary: '偷改' })).toThrow(/不变量 4/)
- })
- })
- describe('提交路径(不变量 8)', () => {
- it('拒绝知识库正文', () => {
- const r = checkCommitRelPath('知识库/写作方法库/节拍.md')
- expect(r.ok).toBe(false)
- if (!r.ok) expect(r.reason).toMatch(/不变量 8/)
- })
- it('允许定稿真源', () => {
- expect(checkCommitRelPath('定稿/卷01/0003-初见.md').ok).toBe(true)
- })
- })
- describe('清单入档', () => {
- it('快乐路径:清单一项写入定稿并 ch: 提交', () => {
- const root = initRepo()
- const pkg = path.join(root, '草稿区/定稿准备/卷01-初见')
- fs.mkdirSync(pkg, { recursive: true })
- fs.writeFileSync(path.join(pkg, '正文.md'), '第一章\n', 'utf-8')
- fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
- schemaVersion: 1,
- 文件: [{ 源: '正文.md', 目标: '定稿/卷01/0003-初见.md' }],
- }))
- const r = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '初见入档' })
- expect(r.ok).toBe(true)
- if (!r.ok) return
- expect(r.message.startsWith('ch:')).toBe(true)
- expect(fs.readFileSync(path.join(root, '定稿/卷01/0003-初见.md'), 'utf-8')).toBe('第一章\n')
- const log = git(root, ['log', '-1', '--pretty=%s'])
- expect(log.status).toBe(0)
- expect((log.stdout ?? '').trim().startsWith('ch:')).toBe(true)
- })
- it('章号跨卷重复被拒:章号全书连续(格式规格 §2.2)', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '定稿/卷01'), { recursive: true })
- fs.writeFileSync(path.join(root, '定稿/卷01/0005-已有.md'), '旧章\n', 'utf-8')
- const pkg = path.join(root, '草稿区/定稿准备/卷02-新章')
- fs.mkdirSync(pkg, { recursive: true })
- fs.writeFileSync(path.join(pkg, '正文.md'), '新章\n', 'utf-8')
- fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
- schemaVersion: 1,
- 文件: [{ 源: '正文.md', 目标: '定稿/卷02/0005-新章.md' }],
- }))
- const r = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '新章入档' })
- expect(r.ok).toBe(false)
- if (r.ok) return
- expect(r.reason).toMatch(/章号/)
- expect(r.reason).toContain('定稿/卷01/0005-已有.md')
- expect(fs.existsSync(path.join(root, '定稿/卷02/0005-新章.md'))).toBe(false)
- })
- it('同卷内递增章号正常入档', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '定稿/卷01'), { recursive: true })
- fs.writeFileSync(path.join(root, '定稿/卷01/0005-已有.md'), '旧章\n', 'utf-8')
- const pkg = path.join(root, '草稿区/定稿准备/卷02-续章')
- fs.mkdirSync(pkg, { recursive: true })
- fs.writeFileSync(path.join(pkg, '正文.md'), '续章\n', 'utf-8')
- fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
- schemaVersion: 1,
- 文件: [{ 源: '正文.md', 目标: '定稿/卷02/0006-续章.md' }],
- }))
- expect(archiveChapter({ bookRoot: root, packageDir: pkg, summary: '续章入档' }).ok).toBe(true)
- })
- it('补偿入口才产生 retcon: 前缀', () => {
- const root = initRepo()
- const r = archiveRetcon({
- bookRoot: root,
- files: [{ 目标: '世界书/人物/甲.md', 内容: '更正\n' }],
- summary: '死亡更正',
- })
- expect(r.ok).toBe(true)
- if (!r.ok) return
- expect(r.message.startsWith('retcon:')).toBe(true)
- const log = git(root, ['log', '-1', '--pretty=%s'])
- expect((log.stdout ?? '').trim().startsWith('retcon:')).toBe(true)
- })
- it('健康检查失败:不写文件、不提交', () => {
- const root = initRepo()
- fs.writeFileSync(path.join(root, '.git', 'MERGE_HEAD'), 'deadbeef\n')
- const dest = '定稿/卷01/0003-初见.md'
- const r = archiveChapter({
- bookRoot: root,
- files: [{ 目标: dest, 内容: '不该出现\n' }],
- summary: '初见入档',
- })
- expect(r.ok).toBe(false)
- expect(fs.existsSync(path.join(root, dest))).toBe(false)
- const log = git(root, ['log', '-1', '--pretty=%s'])
- expect(log.status).not.toBe(0)
- })
- })
- describe('提交说明:design 前缀与章号 Scope(拍板 1/7)', () => {
- it('design 携带活跃章 Scope,格式 design(chNNNN):', () => {
- expect(formatCommitMessage({ prefix: 'design', summary: '确认细纲', chapterScope: 12 })).toBe('design(ch0012): 确认细纲')
- expect(formatCommitMessage({ prefix: 'design', summary: '确认契约·核心设定' })).toBe('design: 确认契约·核心设定')
- })
- it('Scope 仅 design: 前缀携带', () => {
- expect(() => formatCommitMessage({ prefix: 'ch', summary: '初见入档', chapterScope: 3 })).toThrow(/Scope/)
- expect(() => formatCommitMessage({ prefix: 'fix', summary: '补登', chapterScope: 3 })).toThrow(/Scope/)
- })
- })
- describe('设计侧确认提交(commitConfirmed,拍板 1/2/7)', () => {
- it('隔离预先 staged 的无关文件,提交只包含本次目标且原暂存仍在', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
- fs.writeFileSync(path.join(root, '作品契约/无关.md'), '旧\n', 'utf-8')
- expect(git(root, ['add', '.']).status).toBe(0)
- expect(git(root, ['commit', '-m', 'vol: 基线']).status).toBe(0)
- fs.writeFileSync(path.join(root, '作品契约/无关.md'), '作者暂存修改\n', 'utf-8')
- expect(git(root, ['add', '--', '作品契约/无关.md']).status).toBe(0)
- fs.writeFileSync(path.join(root, '作品契约/本次.md'), '本次确认\n', 'utf-8')
- const result = commitConfirmed({ bookRoot: root, paths: ['作品契约/本次.md'], prefix: 'design', summary: '本次确认' })
- expect(result.ok).toBe(true)
- const show = git(root, ['-c', 'core.quotepath=false', 'show', '--name-only', '--pretty=format:', 'HEAD'])
- expect(show.stdout).toContain('作品契约/本次.md')
- expect(show.stdout).not.toContain('作品契约/无关.md')
- expect(git(root, ['-c', 'core.quotepath=false', 'diff', '--cached', '--name-only']).stdout?.trim()).toBe('作品契约/无关.md')
- const replay = commitConfirmed({ bookRoot: root, paths: ['作品契约/本次.md'], prefix: 'design', summary: '本次确认' })
- expect(replay.ok && replay.noChanges).toBe(true)
- })
- it('确认后产生 design: 提交,git log -1 -- 路径 命中', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
- fs.writeFileSync(path.join(root, '作品契约/契约.md'), '# 契约\n', 'utf-8')
- const r = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约·核心设定' })
- expect(r.ok).toBe(true)
- const log = git(root, ['log', '-1', '--pretty=%s', '--', '作品契约/契约.md'])
- expect((log.stdout ?? '').trim()).toBe('design: 确认契约·核心设定')
- })
- it('携带活跃章号:design(ch0012): 确认细纲', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '大纲/章细纲/卷01'), { recursive: true })
- fs.writeFileSync(path.join(root, '大纲/章细纲/卷01/0012-初见.md'), '# 细纲\n', 'utf-8')
- const r = commitConfirmed({
- bookRoot: root, paths: ['大纲/章细纲/卷01/0012-初见.md'], prefix: 'design',
- summary: '确认细纲·初见', chapterScope: 12,
- })
- expect(r.ok).toBe(true)
- const log = git(root, ['log', '-1', '--pretty=%s'])
- expect((log.stdout ?? '').trim()).toBe('design(ch0012): 确认细纲·初见')
- })
- it('整批多文件一次提交(拍板 7:一次确认一次提交)', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '大纲/卷规划/卷01'), { recursive: true })
- fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/卷纲.md'), '# 卷纲\n', 'utf-8')
- fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/近期窗口.md'), '# 近期窗口\n', 'utf-8')
- const r = commitConfirmed({
- bookRoot: root,
- paths: ['大纲/卷规划/卷01/卷纲.md', '大纲/卷规划/卷01/近期窗口.md'],
- prefix: 'design', summary: '卷纲·线索推进',
- })
- expect(r.ok).toBe(true)
- const show = git(root, ['-c', 'core.quotepath=false', 'show', '--name-only', '--pretty=format:'])
- const names = (show.stdout ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '')
- expect(names).toContain('大纲/卷规划/卷01/卷纲.md')
- expect(names).toContain('大纲/卷规划/卷01/近期窗口.md')
- expect(git(root, ['rev-list', '--count', 'HEAD']).stdout?.trim()).toBe('1')
- })
- it('幂等重放:上一次已提交→无改动不空提交', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
- fs.writeFileSync(path.join(root, '作品契约/契约.md'), '# 契约\n', 'utf-8')
- expect(commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' }).ok).toBe(true)
- const before = git(root, ['rev-list', '--count', 'HEAD']).stdout?.trim()
- const replay = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' })
- expect(replay.ok).toBe(true)
- if (!replay.ok) return
- expect(replay.noChanges).toBe(true)
- expect(git(root, ['rev-list', '--count', 'HEAD']).stdout?.trim()).toBe(before)
- })
- it('幂等重放:提交失败过(锁)→重跑补提交,不重写文件', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '作品契约'), { recursive: true })
- fs.writeFileSync(path.join(root, '作品契约/契约.md'), '# 契约\n', 'utf-8')
- fs.writeFileSync(path.join(root, '.git', 'index.lock'), '', 'utf-8')
- const blocked = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' })
- expect(blocked.ok).toBe(false)
- if (!blocked.ok) expect(blocked.reason).toMatch(/锁定|B6/)
- fs.rmSync(path.join(root, '.git', 'index.lock'))
- const replay = commitConfirmed({ bookRoot: root, paths: ['作品契约/契约.md'], prefix: 'design', summary: '确认契约' })
- expect(replay.ok).toBe(true)
- expect(fs.readFileSync(path.join(root, '作品契约/契约.md'), 'utf-8')).toBe('# 契约\n')
- const log = git(root, ['log', '-1', '--pretty=%s'])
- expect((log.stdout ?? '').trim()).toBe('design: 确认契约')
- })
- it('路径越界与草稿区路径拒绝', () => {
- const root = initRepo()
- expect(commitConfirmed({ bookRoot: root, paths: ['知识库/写作/节拍.md'], prefix: 'design', summary: 'x' }).ok).toBe(false)
- expect(commitConfirmed({ bookRoot: root, paths: ['草稿区/草稿/卷01-初见/稿1.md'], prefix: 'design', summary: 'x' }).ok).toBe(false)
- expect(commitConfirmed({ bookRoot: root, paths: ['../外部.md'], prefix: 'design', summary: 'x' }).ok).toBe(false)
- expect(commitConfirmed({ bookRoot: root, paths: [], prefix: 'design', summary: 'x' }).ok).toBe(false)
- const log = git(root, ['log', '--oneline'])
- expect((log.stdout ?? '').trim()).toBe('')
- })
- it('index.lock→拒绝,工作树保留改动可继续', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '世界书/人物'), { recursive: true })
- fs.writeFileSync(path.join(root, '世界书/人物/甲.md'), '# 甲\n', 'utf-8')
- fs.writeFileSync(path.join(root, '.git', 'index.lock'), '', 'utf-8')
- const r = commitConfirmed({ bookRoot: root, paths: ['世界书/人物/甲.md'], prefix: 'design', summary: '确认条目' })
- expect(r.ok).toBe(false)
- if (!r.ok) expect(r.reason).toMatch(/锁定|B6/)
- expect(fs.existsSync(path.join(root, '世界书/人物/甲.md'))).toBe(true)
- expect(git(root, ['log', '--oneline']).stdout?.trim()).toBe('')
- })
- })
- describe('归档附加提交路径(extraPaths,拍板 1:窗口标记随 ch:)', () => {
- it('归档失败重试保留无关 staged 项,文件名中的方括号按字面处理', () => {
- const root = initRepo()
- const target = '定稿/卷01/0001-[ab].md'
- const unrelated = '定稿/卷01/0001-a.md'
- fs.mkdirSync(path.join(root, '定稿/卷01'), { recursive: true })
- fs.writeFileSync(path.join(root, unrelated), 'author-staged\n')
- expect(git(root, ['add', '--', unrelated]).status).toBe(0)
- const hook = path.join(root, '.git/hooks/pre-commit')
- fs.writeFileSync(hook, '#!/bin/sh\nexit 1\n', { mode: 0o755 })
- const input = { bookRoot: root, files: [{ 目标: target, 内容: 'chapter\n' }], summary: 'literal archive' }
- const failed = archiveChapter(input)
- expect(failed.ok).toBe(false)
- if (!failed.ok) expect(failed.written).toBe(true)
- expect(git(root, ['show', `:${unrelated}`]).stdout).toBe('author-staged\n')
- fs.unlinkSync(hook)
- expect(archiveChapter(input).ok).toBe(true)
- expect(git(root, ['-c', 'core.quotepath=false', 'show', '--format=', '--name-only', 'HEAD']).stdout?.trim()).toBe(target)
- expect(git(root, ['-c', 'core.quotepath=false', 'diff', '--cached', '--name-only']).stdout?.trim()).toBe(unrelated)
- })
- it('窗口标记随 ch: 提交,先前已提交的真源不在其中', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '大纲/卷规划/卷01'), { recursive: true })
- fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/近期窗口.md'), '# 近期窗口\n', 'utf-8')
- // 真源先经 design: 提交(提交点跟随确认),归档不再携带。
- const confirmed = commitConfirmed({ bookRoot: root, paths: ['大纲/卷规划/卷01/近期窗口.md'], prefix: 'design', summary: '滚动窗口' })
- expect(confirmed.ok).toBe(true)
- // 归档流程标记窗口已消费(此处直接改文件模拟),随 ch: 一并提交。
- fs.writeFileSync(path.join(root, '大纲/卷规划/卷01/近期窗口.md'), '# 近期窗口\n- 初见 〔已消费〕\n', 'utf-8')
- const pkg = path.join(root, '草稿区/定稿准备/卷01-初见')
- fs.mkdirSync(pkg, { recursive: true })
- fs.writeFileSync(path.join(pkg, '正文.md'), '第一章\n', 'utf-8')
- fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
- schemaVersion: 1,
- 文件: [{ 源: '正文.md', 目标: '定稿/卷01/0003-初见.md' }],
- }))
- const r = archiveChapter({ bookRoot: root, packageDir: pkg, summary: '初见入档', extraPaths: ['大纲/卷规划/卷01/近期窗口.md'] })
- expect(r.ok).toBe(true)
- const show = git(root, ['-c', 'core.quotepath=false', 'show', '--name-only', '--pretty=format:', 'HEAD'])
- const names = (show.stdout ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '')
- expect(names).toContain('定稿/卷01/0003-初见.md')
- expect(names).toContain('大纲/卷规划/卷01/近期窗口.md')
- const last = lastCommitOf(root, '大纲/卷规划/卷01/近期窗口.md')
- expect(last.ok).toBe(true)
- if (last.ok) expect(last.prefix).toBe('ch')
- })
- it('extraPaths 越界同样拒绝', () => {
- const root = initRepo()
- const r = archiveChapter({
- bookRoot: root,
- files: [{ 目标: '定稿/卷01/0003-初见.md', 内容: '第一章\n' }],
- summary: '初见入档',
- extraPaths: ['草稿区/草稿/x.md'],
- })
- expect(r.ok).toBe(false)
- })
- })
- describe('某工件最近一次提交(lastCommitOf,拍板 3)', () => {
- it('从 design(chNNNN) Scope 解析章号', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '大纲/章细纲/卷01'), { recursive: true })
- fs.writeFileSync(path.join(root, '大纲/章细纲/卷01/0012-初见.md'), '# 细纲\n', 'utf-8')
- expect(commitConfirmed({
- bookRoot: root, paths: ['大纲/章细纲/卷01/0012-初见.md'], prefix: 'design',
- summary: '确认细纲·初见', chapterScope: 12,
- }).ok).toBe(true)
- const r = lastCommitOf(root, '大纲/章细纲/卷01/0012-初见.md')
- expect(r.ok).toBe(true)
- if (!r.ok) return
- expect(r.prefix).toBe('design(ch0012)')
- expect(r.summary).toBe('确认细纲·初见')
- expect(r.chapter).toEqual({ 卷: null, 章: 12 })
- })
- it('从定稿章路径解析卷与章(ch: 不带 Scope)', () => {
- const root = initRepo()
- const pkg = path.join(root, '草稿区/定稿准备/卷01-初见')
- fs.mkdirSync(pkg, { recursive: true })
- fs.writeFileSync(path.join(pkg, '正文.md'), '第一章\n', 'utf-8')
- fs.writeFileSync(path.join(pkg, '清单.json'), JSON.stringify({
- schemaVersion: 1,
- 文件: [{ 源: '正文.md', 目标: '定稿/卷01/0003-初见.md' }],
- }))
- expect(archiveChapter({ bookRoot: root, packageDir: pkg, summary: '初见入档' }).ok).toBe(true)
- const r = lastCommitOf(root, '定稿/卷01/0003-初见.md')
- expect(r.ok).toBe(true)
- if (!r.ok) return
- expect(r.prefix).toBe('ch')
- expect(r.chapter).toEqual({ 卷: 1, 章: 3 })
- })
- it('从未提交→kind never;非 git 仓→kind error', () => {
- const root = initRepo()
- fs.mkdirSync(path.join(root, '大纲'), { recursive: true })
- fs.writeFileSync(path.join(root, '大纲/卷纲.md'), '# 卷纲\n', 'utf-8')
- git(root, ['add', '大纲/卷纲.md'])
- git(root, ['commit', '-m', 'vol: 建书'])
- const never = lastCommitOf(root, '作品契约/契约.md')
- expect(never.ok).toBe(false)
- if (!never.ok) expect(never.kind).toBe('never')
- const bare = mkDir('webnovel-nogit-')
- const err = lastCommitOf(bare, '作品契约/契约.md')
- expect(err.ok).toBe(false)
- if (!err.ok) expect(err.kind).toBe('error')
- })
- })
|