Browse Source

feat(v7): M2 P3——定稿原子 commit + 断电安全(出口达成)

finalizeChapter 四步编排:校验 → 写工作树(定稿/大纲) → git add+commit → 最后清工作区。
- 原子单元 = 一次 git commit,message `ch(NNN): 标题` + 条目/设定行
- 断电安全:commit 前任何中断都回滚未提交写入(git restore + clean,仅
  定稿/大纲,绝不碰工作区),工作区草稿原样保留,不存在半章入档
- git.js 薄封装 node:child_process(add/commit/restore/clean/revCount/log)
- faultAfterWrite 故障注入点验证断电出口;6 Writer 端口由 finalize 编排
- 测试:正常定稿 / 断电注入(无新 commit+工作区原样+定稿净恢复) / 删缓存重建一致
- _helper 加 gitBookCtx(拷 fixture + git init + 首提交)
lingfengQAQ 1 ngày trước cách đây
mục cha
commit
4fa85a635e

+ 49 - 0
v7/src/finalize/git.js

@@ -0,0 +1,49 @@
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const exec = promisify(execFile)
+
+/**
+ * git 薄封装(node:child_process)。M2 假设仓库健康;健康检查/异常修复归 M3。
+ * 错误向上抛由 finalize 转中文。
+ */
+export function createGit(repoPath) {
+  const run = (args) =>
+    exec('git', args, { cwd: repoPath, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
+
+  return {
+    async add(files) {
+      if (!files.length) return
+      await run(['add', '--', ...files])
+    },
+    async commit(message) {
+      await run(['commit', '-m', message])
+      const { stdout } = await run(['rev-parse', 'HEAD'])
+      return stdout.trim()
+    },
+    /** 撤销 paths 下已跟踪文件的未提交修改(不碰其他路径如 工作区/) */
+    async restore(paths) {
+      try {
+        await run(['restore', '--staged', '--worktree', '--', ...paths])
+      } catch {
+        // 无可恢复改动时 git 可能报错,忽略
+      }
+    },
+    /** 删除 paths 下未跟踪的新文件(scoped,绝不触及 工作区/) */
+    async clean(paths) {
+      await run(['clean', '-fd', '--', ...paths])
+    },
+    async revCount() {
+      try {
+        const { stdout } = await run(['rev-list', '--count', 'HEAD'])
+        return parseInt(stdout.trim(), 10) || 0
+      } catch {
+        return 0
+      }
+    },
+    async log() {
+      const { stdout } = await run(['log', '--oneline', '--no-color'])
+      return stdout
+    },
+  }
+}

+ 131 - 3
v7/src/finalize/index.js

@@ -1,3 +1,131 @@
-// 定稿:原子 commit(正文入定稿、设定/时间线/名册更新、条目履历、章摘要、工作区清空)。
-// 占位——真实实现见 M2。
-export {}
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { ChapterWriter } from '../storage/adapters/ChapterWriter.js'
+import { ThreadLedgerWriter } from '../storage/adapters/ThreadLedgerWriter.js'
+import { EntityWriter } from '../storage/adapters/EntityWriter.js'
+import { TimelineWriter } from '../storage/adapters/TimelineWriter.js'
+import { SecretWriter } from '../storage/adapters/SecretWriter.js'
+import { SummaryWriter } from '../storage/adapters/SummaryWriter.js'
+import { createGit } from './git.js'
+
+/**
+ * 定稿:原子 commit(D3)。写工作树 → git add → commit → 最后清工作区。
+ * 断电安全:commit 前任何中断都回滚未提交写入(仅 定稿/大纲,不碰工作区),
+ * 工作区草稿原样保留,不存在半章入档。
+ *
+ * @param {{repoPath: string, cache?: object}} ctx
+ * @param {object} payload 定稿包(章档案/正文/摘要/条目/设定变更/commit 行/待清工作区文件)
+ * @param {{git?: object, faultAfterWrite?: boolean}} [opts] 注入 git(测试)/故障注入(断电模拟)
+ * @returns {Promise<{ok: boolean, commitHash?: string, error?: string}>}
+ */
+export async function finalizeChapter(ctx, payload, opts = {}) {
+  const { repoPath } = ctx
+  const git = opts.git || createGit(repoPath)
+  const {
+    chapterNum,
+    frontMatter,
+    body = '',
+    summary = null,
+    threadUpdates = [],
+    characterUpdates = [],
+    rosterUpserts = [],
+    timelineRows = [],
+    secretWrites = [],
+    commitLines = {},
+    workspaceFiles = [],
+  } = payload
+
+  // 1. 校验(不过则什么都没写,天然原样)
+  if (!Number.isInteger(chapterNum)) return { ok: false, error: '章号必须是整数' }
+  if (!frontMatter || !frontMatter.标题) return { ok: false, error: '缺少章档案或标题' }
+
+  const written = []
+  try {
+    // 2. 写工作树(全部落 定稿/大纲,非 工作区)
+    const cw = await new ChapterWriter(repoPath).writeChapter(chapterNum, frontMatter, body)
+    if (!cw.ok) throw new Error(cw.error)
+    written.push(cw.filePath)
+
+    if (summary != null) {
+      const sw = await new SummaryWriter(repoPath).writeChapterSummary(chapterNum, summary)
+      if (!sw.ok) throw new Error(sw.error)
+      written.push(sw.filePath)
+    }
+
+    const tlw = new ThreadLedgerWriter(repoPath)
+    for (const t of threadUpdates) {
+      if (t.updates) {
+        const r = await tlw.updateThread(t.id, t.updates)
+        if (!r.ok) throw new Error(r.error)
+      }
+      if (t.history) {
+        const r = await tlw.appendHistory(t.id, t.history)
+        if (!r.ok) throw new Error(r.error)
+      }
+      const f = await tlw._findThreadFile(t.id)
+      if (f) written.push(f)
+    }
+
+    const ew = new EntityWriter(repoPath)
+    for (const c of characterUpdates) {
+      const r = await ew.updateCharacter(c.name, c.updates)
+      if (!r.ok) throw new Error(r.error)
+      written.push(path.join(repoPath, '定稿', '设定', '角色', `${c.name}.md`))
+    }
+    for (const row of rosterUpserts) {
+      const r = await ew.upsertRosterRow(row)
+      if (!r.ok) throw new Error(r.error)
+      written.push(path.join(repoPath, '定稿', '设定', '名册.md'))
+    }
+
+    const tw = new TimelineWriter(repoPath)
+    for (const tr of timelineRows) {
+      const r = await tw.appendRow(tr.volumeNum, tr.row)
+      if (!r.ok) throw new Error(r.error)
+      written.push(r.filePath)
+    }
+
+    const secw = new SecretWriter(repoPath)
+    for (const s of secretWrites) {
+      const r = await secw.write(s.id, s.frontMatter, s.content)
+      if (!r.ok) throw new Error(r.error)
+      written.push(r.filePath)
+    }
+
+    // 故障注入点(断电模拟,仅测试用)
+    if (opts.faultAfterWrite) throw new Error('注入故障:写工作树后、commit 前中断')
+
+    // 3. git add + commit(原子点)
+    const relFiles = [...new Set(written)].map((f) => path.relative(repoPath, f))
+    await git.add(relFiles)
+    const commitHash = await git.commit(buildCommitMessage(chapterNum, frontMatter.标题, commitLines))
+
+    // 4. 清工作区(必须在 commit 成功之后)
+    for (const wf of workspaceFiles) {
+      await fs.rm(path.join(repoPath, '工作区', wf), { force: true })
+    }
+
+    return { ok: true, commitHash, error: '' }
+  } catch (err) {
+    // commit 前中断:回滚未提交写入(仅 定稿/大纲),工作区原样保留
+    try {
+      await git.restore(['定稿/', '大纲/'])
+      await git.clean(['定稿/', '大纲/'])
+    } catch {
+      // 回滚尽力而为;M3 git 健康检查兜底
+    }
+    return {
+      ok: false,
+      error: `定稿中断,已回滚未提交写入、工作区原样保留:${err.message}`,
+    }
+  }
+}
+
+function buildCommitMessage(chapterNum, title, lines) {
+  let msg = `ch(${chapterNum}): ${title}`
+  const extras = []
+  if (lines.条目) extras.push(`条目: ${lines.条目}`)
+  if (lines.设定) extras.push(`设定: ${lines.设定}`)
+  if (extras.length) msg += '\n\n' + extras.join('\n')
+  return msg
+}

+ 18 - 0
v7/test/commands/_helper.js

@@ -2,8 +2,12 @@ import path from 'node:path'
 import os from 'node:os'
 import { fileURLToPath } from 'node:url'
 import { mkdtemp, mkdir, writeFile, rm, cp } from 'node:fs/promises'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
 import { CacheManager } from '../../src/cache/index.js'
 
+const execFileAsync = promisify(execFile)
+
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 
 // 共享的示例书仓库(只读,命令测试默认喂它)
@@ -66,3 +70,17 @@ export async function tempBookCtx() {
     },
   }
 }
+
+/**
+ * 同 tempBookCtx,但额外 git init + 首提交(定稿/git 流程测试用)。
+ */
+export async function gitBookCtx() {
+  const { ctx, cleanup } = await tempBookCtx()
+  const git = (args) => execFileAsync('git', args, { cwd: ctx.repoPath })
+  await git(['init', '-q'])
+  await git(['config', 'user.email', 't@example.com'])
+  await git(['config', 'user.name', 'test'])
+  await git(['add', '-A'])
+  await git(['commit', '-q', '-m', 'init'])
+  return { ctx, cleanup }
+}

+ 103 - 0
v7/test/finalize/finalize.test.js

@@ -0,0 +1,103 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { promises as fs } from 'node:fs'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { finalizeChapter } from '../../src/finalize/index.js'
+import { createGit } from '../../src/finalize/git.js'
+import { gitBookCtx } from '../commands/_helper.js'
+
+const execFileAsync = promisify(execFile)
+
+function payload() {
+  return {
+    chapterNum: 3,
+    frontMatter: {
+      章号: 3,
+      标题: '初露',
+      卷: 1,
+      视角: '林晚',
+      字数: 100,
+      章定位: '推进',
+      钩子: '危机钩-强',
+      情绪定位: '铺垫',
+    },
+    body: '林晚查到玉佩的第一条线索,心头巨震。\n',
+    summary: '林晚查到玉佩的第一条线索。',
+    threadUpdates: [
+      { id: '伏笔-001', updates: { 最后推进章: 3 }, history: '第3章:推进——林晚查到线索' },
+    ],
+    characterUpdates: [{ name: '林晚', updates: { 最后变更章: 3 } }],
+    timelineRows: [
+      { volumeNum: 1, row: { 章: 3, 书内时间: '春月初三', 一句话事件: '查到玉佩线索', 在场: '林晚' } },
+    ],
+    commitLines: { 条目: '~伏笔-001', 设定: '林晚.最后变更章=3' },
+    workspaceFiles: ['细纲.md'],
+  }
+}
+
+test('finalizeChapter 正常定稿:落档 + git commit + 清工作区', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    const git = createGit(ctx.repoPath)
+    const before = await git.revCount()
+
+    const r = await finalizeChapter(ctx, payload())
+    assert.equal(r.ok, true, r.error)
+
+    const ch = await fs.readFile(path.join(ctx.repoPath, '定稿/正文/0003-初露.md'), 'utf8')
+    assert.match(ch, /标题: 初露/)
+    await fs.access(path.join(ctx.repoPath, '定稿/摘要/章摘要/0003.md'))
+
+    const log = await git.log()
+    assert.match(log, /ch\(3\):/)
+    assert.equal(await git.revCount(), before + 1)
+
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '工作区/细纲.md')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('finalizeChapter 断电注入:无新 commit + 工作区原样 + 定稿净恢复(出口)', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    const git = createGit(ctx.repoPath)
+    const before = await git.revCount()
+
+    const r = await finalizeChapter(ctx, payload(), { faultAfterWrite: true })
+    assert.equal(r.ok, false)
+
+    // 无新 commit
+    assert.equal(await git.revCount(), before)
+    // 工作区草稿原样(细纲还在)
+    await fs.access(path.join(ctx.repoPath, '工作区/细纲.md'))
+    // 定稿 未残留半成品章
+    await assert.rejects(() => fs.access(path.join(ctx.repoPath, '定稿/正文/0003-初露.md')))
+    // 定稿/大纲 工作树干净(回滚成功)
+    const { stdout } = await execFileAsync(
+      'git',
+      ['status', '--porcelain', '--', '定稿', '大纲'],
+      { cwd: ctx.repoPath, encoding: 'utf8' }
+    )
+    assert.equal(stdout.trim(), '')
+  } finally {
+    await cleanup()
+  }
+})
+
+test('finalizeChapter 定稿后删 .cache 全量重建一致(不变量 2)', async () => {
+  const { ctx, cleanup } = await gitBookCtx()
+  try {
+    const r = await finalizeChapter(ctx, payload())
+    assert.equal(r.ok, true, r.error)
+
+    await ctx.cache.rebuildFromSource(ctx.repoPath)
+    const rows = await ctx.cache.query('SELECT * FROM chapters WHERE chapter_num = 3')
+    assert.equal(rows.length, 1)
+    assert.equal(rows[0].title, '初露')
+  } finally {
+    await cleanup()
+  }
+})