返回 oh-my-ppt
git-history-service.ts
根目录 / src / main / history / git-history-service.ts
1 import fs from 'fs'
2 import path from 'path'
3 import crypto from 'crypto'
4 import log from 'electron-log/main.js'
5 import * as git from 'isomorphic-git'
6 import { nanoid } from 'nanoid'
7 import type {
8 PPTDatabase,
9 SessionOperationRecord,
10 SessionPageRecord,
11 SessionStyleSnapshotRow
12 } from '../db/database'
13 import {
14 HISTORY_VERSION_LIMIT,
15 type ChangedHistoryFile,
16 type HistoryOperationKind,
17 type HistoryOperationScope,
18 type HistoryVersion,
19 type RollbackHistoryResult
20 } from '@shared/history'
21
22 const GITIGNORE_ENTRIES = ['.DS_Store', 'Thumbs.db', '*.log', 'tmp/', 'cache/', 'speech/']
23 const GITIGNORE_CONTENT = [...GITIGNORE_ENTRIES, ''].join('\n')
24
25 type RecordOperationArgs = {
26 sessionId: string
27 projectDir: string
28 type: HistoryOperationKind
29 scope: HistoryOperationScope
30 prompt?: string | null
31 metadata?: Record<string, unknown>
32 targetOperationId?: string | null
33 targetCommit?: string | null
34 allowEmptySnapshot?: boolean
35 allowedPaths?: string[]
36 }
37
38 type GitStatusMatrixRow = [string, number, number, number]
39
40 const parseJson = <T>(value: string | null | undefined, fallback: T): T => {
41 if (!value || value.trim().length === 0) return fallback
42 try {
43 return JSON.parse(value) as T
44 } catch {
45 return fallback
46 }
47 }
48
49 type HistorySessionStyleState = {
50 styleId: string | null
51 snapshot: SessionStyleSnapshotRow | null
52 designContract: unknown
53 }
54
55 const parseHistorySessionStyleState = (
56 metadata: Record<string, unknown>
57 ): HistorySessionStyleState | undefined => {
58 const raw = metadata.sessionStyleState
59 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
60 const record = raw as Record<string, unknown>
61 const styleId = record.styleId === null ? null : record.styleId
62 if (styleId !== null && typeof styleId !== 'string') return undefined
63 const designContract = record.designContract ?? null
64 if (record.snapshot === null) return { styleId, snapshot: null, designContract }
65 if (!record.snapshot || typeof record.snapshot !== 'object' || Array.isArray(record.snapshot)) {
66 return undefined
67 }
68 const snapshot = record.snapshot as Record<string, unknown>
69 const requiredStrings = [
70 'id',
71 'sessionId',
72 'styleId',
73 'styleKey',
74 'styleName',
75 'styleNameZh',
76 'styleNameEn',
77 'description',
78 'category',
79 'aliases',
80 'source',
81 'version',
82 'styleCase',
83 'packageDir',
84 'styleSkill'
85 ]
86 if (requiredStrings.some((key) => typeof snapshot[key] !== 'string')) return undefined
87 if (typeof snapshot.createdAt !== 'number' || !Number.isFinite(snapshot.createdAt)) {
88 return undefined
89 }
90 if (!['builtin', 'custom', 'override'].includes(snapshot.source as string)) return undefined
91 return {
92 styleId,
93 snapshot: snapshot as unknown as SessionStyleSnapshotRow,
94 designContract
95 }
96 }
97
98 const normalizeRelativePath = (value: string): string => value.split(path.sep).join('/')
99
100 const isControlledFile = (relativePath: string): boolean => {
101 const rel = normalizeRelativePath(relativePath).replace(/^\/+/, '')
102 if (!rel || rel.includes('..') || rel.startsWith('.git/')) return false
103 if (rel.startsWith('speech/')) return false
104 if (rel === '.gitignore') return true
105 if (
106 rel === 'index.html' ||
107 rel === 'master/master.css' ||
108 rel === 'master/master.html' ||
109 rel === 'master/layouts.json'
110 ) {
111 return true
112 }
113 if (/^[^/]+\.html?$/i.test(rel) && rel.toLowerCase() !== 'index.html') return true
114 if (rel.startsWith('assets/') && !rel.endsWith('/')) return true
115 if (rel.startsWith('images/') && !rel.endsWith('/')) return true
116 if (rel.startsWith('docs/merged-pages/') && !rel.endsWith('/')) return true
117 return false
118 }
119
120 const pageIdFromPath = (relativePath: string): string | undefined => {
121 const rel = normalizeRelativePath(relativePath)
122 if (!/^[^/]+\.html?$/i.test(rel) || rel.toLowerCase() === 'index.html') return undefined
123 return rel.replace(/\.html?$/i, '')
124 }
125
126 const hasRestorableDeckFiles = (files: string[]): boolean =>
127 files.some((file) => file === 'index.html') &&
128 files.some((file) => /^[^/]+\.html?$/i.test(file) && file.toLowerCase() !== 'index.html')
129
130 const ensureDir = async (dir: string): Promise<void> => {
131 await fs.promises.mkdir(dir, { recursive: true })
132 }
133
134 async function walkFiles(root: string, prefix = ''): Promise<string[]> {
135 const dir = path.join(root, prefix)
136 if (!fs.existsSync(dir)) return []
137 const entries = await fs.promises.readdir(dir, { withFileTypes: true })
138 const results: string[] = []
139 for (const entry of entries) {
140 if (entry.name === '.git') continue
141 const rel = normalizeRelativePath(path.join(prefix, entry.name))
142 if (entry.isDirectory()) {
143 results.push(...(await walkFiles(root, rel)))
144 } else if (entry.isFile() && isControlledFile(rel)) {
145 results.push(rel)
146 }
147 }
148 return results.sort()
149 }
150
151 export class GitHistoryService {
152 constructor(private readonly db: PPTDatabase) {}
153
154 async captureCurrentVersionStyleState(sessionId: string): Promise<void> {
155 const session = await this.db.getSession(sessionId)
156 const operationId = session?.currentOperationId
157 if (!operationId) return
158 const operation = await this.db.getSessionOperation(operationId)
159 if (!operation || operation.session_id !== sessionId) return
160 const metadata = parseJson<Record<string, unknown>>(operation.metadata_json, {})
161 const snapshot = await this.db.getSessionStyleSnapshot(sessionId)
162 await this.db.updateSessionOperationMetadata(operationId, {
163 ...metadata,
164 sessionStyleState: {
165 styleId: session.styleId ?? null,
166 snapshot: snapshot || null,
167 designContract: parseJson<unknown>(session.designContract, null)
168 }
169 })
170 log.info('[history] captured current version style snapshot', {
171 sessionId,
172 operationId,
173 styleId: session.styleId ?? null,
174 snapshotStyleId: snapshot?.styleId || null
175 })
176 }
177
178 async ensureBaseline(sessionId: string, projectDir: string): Promise<void> {
179 const resolvedProjectDir = path.resolve(projectDir)
180 if (!(await this.db.hasAnyOperationPageSnapshots(sessionId))) {
181 await fs.promises.rm(path.join(resolvedProjectDir, '.git'), { recursive: true, force: true })
182 await this.db.cleanupSessionOperations(sessionId)
183 await this.ensureRepository(resolvedProjectDir)
184 await this.createLegacyImport(sessionId, resolvedProjectDir)
185 return
186 }
187 await this.ensureRepository(resolvedProjectDir)
188 }
189
190 async recordOperation(args: RecordOperationArgs): Promise<SessionOperationRecord | null> {
191 const projectDir = path.resolve(args.projectDir)
192 await this.ensureRepository(projectDir)
193
194 let beforeCommit = await this.resolveHead(projectDir)
195 const beforeFiles = beforeCommit
196 ? await this.listTrackedFiles(projectDir, beforeCommit).catch(() => walkFiles(projectDir))
197 : []
198 let session = await this.db.getSession(args.sessionId)
199 let parentOperationId =
200 typeof session?.currentOperationId === 'string' ? session.currentOperationId : null
201
202 const canStartHistoryFromCurrentOperation =
203 args.type === 'generate' || args.type === 'import' || args.type === 'retry'
204 if (!beforeCommit && !canStartHistoryFromCurrentOperation) {
205 await this.createLegacyImport(args.sessionId, projectDir)
206 beforeCommit = await this.resolveHead(projectDir)
207 session = await this.db.getSession(args.sessionId)
208 parentOperationId =
209 typeof session?.currentOperationId === 'string' ? session.currentOperationId : null
210 }
211
212 const metadata = await this.buildOperationMetadata(args)
213 const { changedFiles } = await this.stageControlledChanges(projectDir, args.allowedPaths)
214 const changedPages = Array.from(
215 new Set(changedFiles.map((file) => file.pageId).filter(Boolean) as string[])
216 ).sort()
217 if (changedFiles.length === 0 && args.allowEmptySnapshot && beforeCommit) {
218 const trackedFiles = await this.listTrackedFiles(projectDir, beforeCommit).catch(() =>
219 walkFiles(projectDir)
220 )
221 if (!hasRestorableDeckFiles(trackedFiles)) {
222 throw new Error('历史记录写入失败:未记录到可恢复的页面文件。')
223 }
224 const operationId = crypto.randomUUID()
225 await this.db.createSessionOperation({
226 id: operationId,
227 sessionId: args.sessionId,
228 type: args.type,
229 scope: args.scope,
230 prompt: args.prompt || null,
231 parentOperationId,
232 beforeCommit,
233 targetOperationId: args.targetOperationId || null,
234 targetCommit: args.targetCommit || null,
235 metadata
236 })
237 await this.captureOperationPageSnapshot(args.sessionId, operationId, projectDir)
238 await this.db.completeSessionOperation({
239 id: operationId,
240 status: 'completed',
241 afterCommit: beforeCommit,
242 changedFiles: [],
243 changedPages: [],
244 trackedFiles,
245 metadata: {
246 ...metadata,
247 emptySnapshot: true
248 }
249 })
250 await this.db.updateSessionHistoryPointer({
251 sessionId: args.sessionId,
252 operationId,
253 commit: beforeCommit
254 })
255 return this.db.getSessionOperation(operationId) as Promise<SessionOperationRecord | null>
256 }
257
258 if (changedFiles.length === 0) {
259 log.debug('[history] skip operation without controlled file changes', {
260 sessionId: args.sessionId,
261 type: args.type,
262 scope: args.scope
263 })
264 return null
265 }
266 const operationId = crypto.randomUUID()
267 await this.db.createSessionOperation({
268 id: operationId,
269 sessionId: args.sessionId,
270 type: args.type,
271 scope: args.scope,
272 prompt: args.prompt || null,
273 parentOperationId,
274 beforeCommit,
275 targetOperationId: args.targetOperationId || null,
276 targetCommit: args.targetCommit || null,
277 metadata
278 })
279
280 let committedAfter: string | null = null
281 try {
282 await this.captureOperationPageSnapshot(args.sessionId, operationId, projectDir)
283 const afterCommit = await git.commit({
284 fs,
285 dir: projectDir,
286 message: this.buildCommitMessage(args, changedPages),
287 author: {
288 name: 'Oh My PPT',
289 email: 'history@oh-my-ppt.local'
290 }
291 })
292 committedAfter = afterCommit
293 const trackedAfterCommit = await this.listTrackedFiles(projectDir, afterCommit)
294 if (!hasRestorableDeckFiles(trackedAfterCommit)) {
295 throw new Error('历史记录写入失败:提交后未记录到可恢复的页面文件。')
296 }
297 await this.db.completeSessionOperation({
298 id: operationId,
299 status: 'completed',
300 afterCommit,
301 changedFiles,
302 changedPages,
303 trackedFiles: trackedAfterCommit,
304 metadata
305 })
306 await this.db.updateSessionHistoryPointer({
307 sessionId: args.sessionId,
308 operationId,
309 commit: afterCommit
310 })
311 return this.db.getSessionOperation(operationId) as Promise<SessionOperationRecord | null>
312 } catch (error) {
313 if (committedAfter) {
314 await this.rollbackFailedCommit(
315 projectDir,
316 beforeCommit,
317 beforeFiles,
318 args.allowedPaths
319 ).catch((rollbackError) => {
320 log.error('[history] rollback failed after operation commit', {
321 sessionId: args.sessionId,
322 operationId,
323 beforeCommit,
324 committedAfter,
325 message: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
326 })
327 })
328 }
329 await this.db.completeSessionOperation({
330 id: operationId,
331 status: 'failed',
332 afterCommit: beforeCommit,
333 metadata: {
334 ...metadata,
335 error: error instanceof Error ? error.message : String(error)
336 }
337 })
338 throw error
339 }
340 }
341
342 /**
343 * Compensates a just-recorded path-scoped operation when its caller cannot finish its own
344 * database state transition. This deliberately restores only the operation allowlist so an
345 * independently generated page can remain uncommitted in the working tree.
346 */
347 async rollbackCommittedOperation(args: {
348 sessionId: string
349 projectDir: string
350 operation: SessionOperationRecord
351 allowedPaths: string[]
352 reason: string
353 }): Promise<void> {
354 const beforeCommit = args.operation.before_commit
355 if (!beforeCommit) throw new Error('历史补偿失败:缺少提交前版本。')
356 const projectDir = path.resolve(args.projectDir)
357 const metadata = parseJson<Record<string, unknown>>(args.operation.metadata_json, {})
358 await this.moveHeadToCommit(projectDir, beforeCommit)
359 await this.restoreCommitPaths(projectDir, beforeCommit, args.allowedPaths)
360 await this.db.completeSessionOperation({
361 id: args.operation.id,
362 status: 'failed',
363 afterCommit: beforeCommit,
364 metadata: {
365 ...metadata,
366 compensation: 'rolled_back_after_page_finalization_failure',
367 error: args.reason
368 }
369 })
370 await this.db.updateSessionHistoryPointer({
371 sessionId: args.sessionId,
372 operationId: args.operation.parent_operation_id || null,
373 commit: beforeCommit
374 })
375 }
376
377 async listVersions(sessionId: string, limit = HISTORY_VERSION_LIMIT): Promise<HistoryVersion[]> {
378 const session = await this.db.getSession(sessionId)
379 const currentCommit = typeof session?.currentCommit === 'string' ? session.currentCommit : null
380 const currentOperationId =
381 typeof session?.currentOperationId === 'string' ? session.currentOperationId : null
382 const startOperationId =
383 currentOperationId || (await this.findOperationIdByCommit(sessionId, currentCommit)) || null
384 const maxCount = Math.max(1, Math.min(HISTORY_VERSION_LIMIT, Math.floor(limit)))
385 const operations = await this.collectVisibleChainOperations(startOperationId, maxCount)
386
387 return operations
388 .filter((operation) => operation.status === 'completed' && Boolean(operation.after_commit))
389 .slice(0, maxCount)
390 .map((operation) =>
391 this.toHistoryVersion(operation, {
392 currentCommit,
393 currentOperationId
394 })
395 )
396 }
397
398 async rollbackToVersion(args: {
399 sessionId: string
400 projectDir: string
401 versionId: string
402 }): Promise<RollbackHistoryResult> {
403 const session = await this.db.getSession(args.sessionId)
404 if (session?.status === 'active') {
405 throw new Error('当前会话正在生成或编辑,暂时不能回退。')
406 }
407 const targetOperation = await this.db.getSessionOperation(args.versionId)
408 if (!targetOperation || targetOperation.session_id !== args.sessionId) {
409 throw new Error('历史版本不存在。')
410 }
411 if (targetOperation.status !== 'completed' || !targetOperation.after_commit) {
412 throw new Error('该历史版本不可回退。')
413 }
414
415 const projectDir = path.resolve(args.projectDir)
416 await this.ensureRepository(projectDir)
417 const beforeCommit = await this.resolveHead(projectDir)
418 if (!beforeCommit) {
419 throw new Error('当前会话尚未建立历史记录,不能回退。')
420 }
421 await this.assertCommitExists(projectDir, targetOperation.after_commit)
422 const beforePages = await this.db.listSessionPages(args.sessionId, { includeDeleted: true })
423 const beforeMetadata = parseJson<Record<string, unknown>>(session?.metadata, {})
424 const beforeStyleSnapshot = await this.db.getSessionStyleSnapshot(args.sessionId)
425 const beforeStyleId = session?.styleId ?? null
426 const beforeDesignContract = parseJson<unknown>(session?.designContract, null)
427 const beforeOperationId =
428 typeof session?.currentOperationId === 'string' ? session.currentOperationId : null
429 const beforeFiles = await this.listTrackedFiles(projectDir, beforeCommit)
430
431 const operationTrackedFiles = parseJson<string[]>(
432 targetOperation.tracked_files_json,
433 []
434 ).filter(isControlledFile)
435 if (!hasRestorableDeckFiles(operationTrackedFiles)) {
436 throw new Error('目标历史版本记录不完整(tracked_files_json 缺少页面文件),无法回退。')
437 }
438 const filesToRestore = operationTrackedFiles
439 try {
440 await this.restoreCommitFiles(projectDir, targetOperation.after_commit, filesToRestore)
441 const targetMetadata = parseJson<Record<string, unknown>>(targetOperation.metadata_json, {})
442 const targetStyleState = parseHistorySessionStyleState(targetMetadata)
443 await this.syncSessionPagesForRestoredVersion(args.sessionId, projectDir, targetOperation.id)
444 const sessionMetadata = targetMetadata.sessionMetadata
445 await this.moveHeadToCommit(projectDir, targetOperation.after_commit)
446 await this.db.updateSessionHistoryPointer({
447 sessionId: args.sessionId,
448 operationId: targetOperation.id,
449 commit: targetOperation.after_commit
450 })
451
452 if (
453 sessionMetadata &&
454 typeof sessionMetadata === 'object' &&
455 !Array.isArray(sessionMetadata)
456 ) {
457 await this.db.updateSessionMetadata(
458 args.sessionId,
459 sessionMetadata as Record<string, unknown>
460 )
461 }
462 if (targetStyleState) {
463 await this.db.restoreSessionStyleState(
464 args.sessionId,
465 targetStyleState.styleId,
466 targetStyleState.snapshot || undefined
467 )
468 await this.db.updateSessionDesignContract(args.sessionId, targetStyleState.designContract)
469 log.info('[history] restored session style snapshot', {
470 sessionId: args.sessionId,
471 versionId: targetOperation.id,
472 styleId: targetStyleState.styleId,
473 snapshotStyleId: targetStyleState.snapshot?.styleId || null
474 })
475 } else {
476 log.info('[history] target version has no session style snapshot; keeping current style', {
477 sessionId: args.sessionId,
478 versionId: targetOperation.id
479 })
480 }
481 } catch (error) {
482 // Best-effort rollback to pre-rollback state for non-crash failures.
483 await this.restoreCommitFiles(projectDir, beforeCommit, beforeFiles).catch(() => {})
484 await this.restoreSessionPagesFromSnapshot(args.sessionId, beforePages).catch(() => {})
485 await this.moveHeadToCommit(projectDir, beforeCommit).catch(() => {})
486 await this.db
487 .updateSessionHistoryPointer({
488 sessionId: args.sessionId,
489 operationId: beforeOperationId,
490 commit: beforeCommit
491 })
492 .catch(() => {})
493 await this.db.updateSessionMetadata(args.sessionId, beforeMetadata).catch(() => {})
494 await this.db
495 .restoreSessionStyleState(args.sessionId, beforeStyleId, beforeStyleSnapshot)
496 .catch((styleRollbackError) => {
497 log.error('[history] failed to restore style snapshot after rollback error', {
498 sessionId: args.sessionId,
499 versionId: targetOperation.id,
500 message:
501 styleRollbackError instanceof Error
502 ? styleRollbackError.message
503 : String(styleRollbackError)
504 })
505 })
506 await this.db
507 .updateSessionDesignContract(args.sessionId, beforeDesignContract)
508 .catch(() => {})
509 throw error
510 }
511
512 return {
513 versionId: targetOperation.id,
514 operationId: targetOperation.id,
515 beforeCommit,
516 targetCommit: targetOperation.after_commit,
517 afterCommit: targetOperation.after_commit,
518 changedFiles: [],
519 changedPages: []
520 }
521 }
522
523 private async syncSessionPagesForRestoredVersion(
524 sessionId: string,
525 projectDir: string,
526 operationId: string
527 ): Promise<void> {
528 const order = await this.resolveRestoredPageOrder(operationId)
529 if (order.length === 0) {
530 throw new Error('目标历史版本缺少页面快照,无法恢复页面顺序。')
531 }
532 const existingPages = await this.db.listSessionPages(sessionId, { includeDeleted: true })
533 const existingById = new Map(existingPages.map((p) => [p.id, p]))
534 const existingByFileSlug = new Map(existingPages.map((p) => [p.file_slug, p]))
535 const activeIds = new Set<string>()
536
537 for (let index = 0; index < order.length; index += 1) {
538 const item = order[index] as Record<string, unknown>
539 if (!(typeof item.pageId === 'string' && item.pageId.trim().length > 0)) {
540 throw new Error('目标历史版本页面快照缺少 pageId,无法恢复页面顺序。')
541 }
542 const fileSlug = item.pageId.trim()
543 const providedId =
544 typeof item.id === 'string' && item.id.trim().length > 0 ? item.id.trim() : ''
545 const existing =
546 (providedId ? existingById.get(providedId) : undefined) || existingByFileSlug.get(fileSlug)
547 const pageId = providedId || existing?.id || nanoid()
548 activeIds.add(pageId)
549 const pageNumberRaw = Number(item.pageNumber)
550 const pageNumber =
551 Number.isFinite(pageNumberRaw) && pageNumberRaw > 0 ? Math.floor(pageNumberRaw) : index + 1
552 const title =
553 typeof item.title === 'string' && item.title.trim().length > 0
554 ? item.title.trim()
555 : `Page ${pageNumber}`
556 const snapshotHtmlPath =
557 typeof item.htmlPath === 'string' && item.htmlPath.trim().length > 0
558 ? item.htmlPath.trim()
559 : ''
560 const htmlPath = this.resolveRestoredHtmlPath({
561 fileSlug,
562 projectDir,
563 snapshotHtmlPath,
564 existingHtmlPath: existing?.html_path || ''
565 })
566 const restoredStatus = fs.existsSync(htmlPath)
567 ? 'completed'
568 : ((typeof item.status === 'string' ? item.status : existing?.status) as
569 | 'completed'
570 | 'failed'
571 | 'pending'
572 | undefined) || 'failed'
573 await this.db.upsertSessionPage({
574 id: pageId,
575 sessionId,
576 legacyPageId: existing?.legacy_page_id || null,
577 fileSlug,
578 pageNumber,
579 title,
580 htmlPath,
581 status: restoredStatus,
582 error: restoredStatus === 'failed' ? existing?.error || '页面文件不存在' : null
583 })
584 }
585
586 const idsToSoftDelete = existingPages.filter((p) => !activeIds.has(p.id)).map((p) => p.id)
587 if (idsToSoftDelete.length > 0) {
588 await this.db.softDeleteSessionPages(sessionId, idsToSoftDelete)
589 }
590 }
591
592 private async resolveRestoredPageOrder(
593 operationId: string
594 ): Promise<Array<Record<string, unknown>>> {
595 const snapshotPages = await this.db.listSessionOperationPages(operationId)
596 return snapshotPages.map((page) => ({
597 id: page.page_id,
598 pageNumber: page.page_number,
599 pageId: page.file_slug,
600 title: page.title,
601 htmlPath: page.html_path,
602 status: page.status,
603 error: page.error
604 }))
605 }
606
607 private async captureOperationPageSnapshot(
608 sessionId: string,
609 operationId: string,
610 projectDir: string
611 ): Promise<void> {
612 const pages = await this.db.listSessionPages(sessionId)
613 await this.db.replaceSessionOperationPages(
614 operationId,
615 sessionId,
616 pages.map((page) => ({
617 pageId: page.id,
618 legacyPageId: page.legacy_page_id,
619 fileSlug: page.file_slug,
620 pageNumber: page.page_number,
621 title: page.title,
622 htmlPath: this.resolveRestoredHtmlPath({
623 fileSlug: page.file_slug,
624 projectDir,
625 snapshotHtmlPath: '',
626 existingHtmlPath: page.html_path
627 }),
628 status: page.status,
629 error: page.error
630 }))
631 )
632 }
633
634 private resolveRestoredHtmlPath(args: {
635 fileSlug: string
636 projectDir: string
637 snapshotHtmlPath?: string
638 existingHtmlPath?: string
639 }): string {
640 const candidates = [
641 args.snapshotHtmlPath,
642 args.existingHtmlPath,
643 path.resolve(args.projectDir, `${args.fileSlug}.html`)
644 ]
645 .map((item) => (typeof item === 'string' ? item.trim() : ''))
646 .filter((item) => item.length > 0)
647
648 for (const candidate of candidates) {
649 const resolved = path.isAbsolute(candidate)
650 ? path.resolve(candidate)
651 : path.resolve(args.projectDir, candidate)
652 const relativeToProject = path.relative(args.projectDir, resolved)
653 if (relativeToProject.startsWith('..') || path.isAbsolute(relativeToProject)) continue
654 if (fs.existsSync(resolved)) return resolved
655 }
656
657 return path.resolve(args.projectDir, `${args.fileSlug}.html`)
658 }
659
660 private async ensureRepository(projectDir: string): Promise<void> {
661 await ensureDir(projectDir)
662 const gitDir = path.join(projectDir, '.git')
663 if (!fs.existsSync(gitDir)) {
664 await git.init({ fs, dir: projectDir, defaultBranch: 'main' })
665 await git.setConfig({ fs, dir: projectDir, path: 'user.name', value: 'Oh My PPT' })
666 await git.setConfig({
667 fs,
668 dir: projectDir,
669 path: 'user.email',
670 value: 'history@oh-my-ppt.local'
671 })
672 }
673 const gitignorePath = path.join(projectDir, '.gitignore')
674 if (!fs.existsSync(gitignorePath)) {
675 await fs.promises.writeFile(gitignorePath, GITIGNORE_CONTENT, 'utf-8')
676 } else {
677 const existing = await fs.promises.readFile(gitignorePath, 'utf-8').catch(() => '')
678 const lines = new Set(existing.split(/\r?\n/).map((line) => line.trim()))
679 const missing = GITIGNORE_ENTRIES.filter((entry) => !lines.has(entry))
680 if (missing.length > 0) {
681 const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''
682 await fs.promises.writeFile(
683 gitignorePath,
684 `${existing}${separator}${missing.join('\n')}\n`,
685 'utf-8'
686 )
687 }
688 }
689 }
690
691 private async createLegacyImport(sessionId: string, projectDir: string): Promise<void> {
692 const files = await walkFiles(projectDir)
693 if (
694 !files.some((file) => file === 'index.html') ||
695 !files.some((file) => /^[^/]+\.html?$/i.test(file) && file.toLowerCase() !== 'index.html')
696 ) {
697 throw new Error('旧会话文件不完整,无法建立历史起点。')
698 }
699 await this.recordOperation({
700 sessionId,
701 projectDir,
702 type: 'import',
703 scope: 'session',
704 prompt: '历史起点:导入现有会话状态',
705 metadata: {
706 legacy: true,
707 reason: 'legacy_import'
708 },
709 allowEmptySnapshot: true
710 })
711 }
712
713 private async resolveHead(projectDir: string): Promise<string | null> {
714 try {
715 return await git.resolveRef({ fs, dir: projectDir, ref: 'HEAD' })
716 } catch {
717 return null
718 }
719 }
720
721 private async moveHeadToCommit(projectDir: string, commit: string): Promise<void> {
722 const currentBranchRef = await git.currentBranch({ fs, dir: projectDir, fullname: true })
723 if (currentBranchRef) {
724 await git.writeRef({
725 fs,
726 dir: projectDir,
727 ref: currentBranchRef,
728 value: commit,
729 force: true
730 })
731 return
732 }
733 await git.writeRef({
734 fs,
735 dir: projectDir,
736 ref: 'HEAD',
737 value: commit,
738 force: true
739 })
740 }
741
742 private async assertCommitExists(projectDir: string, commit: string): Promise<void> {
743 try {
744 await git.readCommit({
745 fs,
746 dir: projectDir,
747 oid: commit
748 })
749 } catch {
750 throw new Error('目标历史版本对应的提交对象不存在,无法回退。')
751 }
752 }
753
754 private async collectVisibleChainOperations(
755 startOperationId: string | null,
756 limit: number
757 ): Promise<SessionOperationRecord[]> {
758 if (!startOperationId) return []
759 const operations: SessionOperationRecord[] = []
760 const visited = new Set<string>()
761 let cursor: string | null = startOperationId
762 while (cursor && !visited.has(cursor) && operations.length < Math.max(20, limit * 5)) {
763 visited.add(cursor)
764 const operation = await this.db.getSessionOperation(cursor)
765 if (!operation) break
766 operations.push(operation)
767 cursor = operation.parent_operation_id
768 }
769 return operations
770 }
771
772 private async findOperationIdByCommit(
773 sessionId: string,
774 commit: string | null
775 ): Promise<string | null> {
776 if (!commit) return null
777 const operations = await this.db.listSessionOperations(sessionId, {
778 limit: 500,
779 includeNoop: true
780 })
781 const matched = operations.find((operation) => operation.after_commit === commit)
782 return matched?.id || null
783 }
784
785 private async restoreSessionPagesFromSnapshot(
786 sessionId: string,
787 pages: SessionPageRecord[]
788 ): Promise<void> {
789 const activeIds: string[] = []
790 const deletedIds: string[] = []
791 for (const page of pages) {
792 await this.db.upsertSessionPage({
793 id: page.id,
794 sessionId,
795 legacyPageId: page.legacy_page_id,
796 fileSlug: page.file_slug,
797 pageNumber: page.page_number,
798 title: page.title,
799 htmlPath: page.html_path,
800 status: page.status,
801 error: page.error
802 })
803 if (page.deleted_at === null) {
804 activeIds.push(page.id)
805 } else {
806 deletedIds.push(page.id)
807 }
808 }
809 if (deletedIds.length > 0) {
810 await this.db.softDeleteSessionPages(sessionId, deletedIds)
811 }
812 const targetActive = new Set(activeIds)
813 const currentPages = await this.db.listSessionPages(sessionId, { includeDeleted: true })
814 const unknownIds = currentPages
815 .filter((page) => !pages.some((item) => item.id === page.id))
816 .map((page) => page.id)
817 if (unknownIds.length > 0) {
818 await this.db.softDeleteSessionPages(sessionId, unknownIds)
819 }
820 const currentActive = currentPages
821 .filter((page) => page.deleted_at === null)
822 .map((page) => page.id)
823 const shouldDelete = currentActive.filter((id) => !targetActive.has(id))
824 if (shouldDelete.length > 0) {
825 await this.db.softDeleteSessionPages(sessionId, shouldDelete)
826 }
827 }
828
829 private async stageControlledChanges(
830 projectDir: string,
831 allowedPaths?: string[]
832 ): Promise<{
833 changedFiles: ChangedHistoryFile[]
834 }> {
835 const allowedPathSet = allowedPaths
836 ? new Set(
837 allowedPaths
838 .map((item) => normalizeRelativePath(item).replace(/^\/+/, ''))
839 .filter(isControlledFile)
840 )
841 : null
842 const matrix = (await git.statusMatrix({ fs, dir: projectDir })) as GitStatusMatrixRow[]
843 const changedFiles: ChangedHistoryFile[] = []
844 for (const [filepath, head, workdir, stage] of matrix) {
845 if (!isControlledFile(filepath)) continue
846 if (allowedPathSet && !allowedPathSet.has(normalizeRelativePath(filepath))) {
847 // A concurrent page worker must never be pulled into this operation merely because a
848 // previous attempt left it in the Git index. Keep its worktree change, but unstage it.
849 if (head !== stage) await git.resetIndex({ fs, dir: projectDir, filepath })
850 continue
851 }
852 const hasWorkdirDiff = head !== workdir
853 const hasStagedDiff = head !== stage
854 if (!hasWorkdirDiff && !hasStagedDiff) continue
855 const pageId = pageIdFromPath(filepath)
856 if (workdir === 2) {
857 await git.add({ fs, dir: projectDir, filepath })
858 } else if (head === 1 && workdir === 0) {
859 await git.remove({ fs, dir: projectDir, filepath })
860 }
861 const changeType: ChangedHistoryFile['changeType'] =
862 head === 0 && (workdir === 2 || stage === 2)
863 ? 'added'
864 : head === 1 && (workdir === 0 || stage === 0)
865 ? 'deleted'
866 : 'modified'
867 changedFiles.push({ path: filepath, changeType, pageId })
868 }
869 return { changedFiles }
870 }
871
872 private async listTrackedFiles(projectDir: string, commit: string): Promise<string[]> {
873 const files = await git.listFiles({
874 fs,
875 dir: projectDir,
876 ref: commit
877 })
878 return files.filter(isControlledFile).sort()
879 }
880
881 private async rollbackFailedCommit(
882 projectDir: string,
883 beforeCommit: string | null,
884 beforeFiles: string[],
885 allowedPaths?: string[]
886 ): Promise<void> {
887 if (!beforeCommit) {
888 await fs.promises.rm(path.join(projectDir, '.git'), { recursive: true, force: true })
889 await this.ensureRepository(projectDir)
890 return
891 }
892
893 await this.moveHeadToCommit(projectDir, beforeCommit)
894 if (allowedPaths && allowedPaths.length > 0) {
895 await this.restoreCommitPaths(projectDir, beforeCommit, allowedPaths)
896 return
897 }
898 if (hasRestorableDeckFiles(beforeFiles)) {
899 await this.restoreCommitFiles(projectDir, beforeCommit, beforeFiles)
900 }
901 }
902
903 private async restoreCommitPaths(
904 projectDir: string,
905 commit: string,
906 allowedPaths: string[]
907 ): Promise<void> {
908 const beforeFiles = new Set(await this.listTrackedFiles(projectDir, commit))
909 const normalizedPaths = Array.from(
910 new Set(
911 allowedPaths
912 .map((item) => normalizeRelativePath(item).replace(/^\/+/, ''))
913 .filter(isControlledFile)
914 )
915 )
916 for (const relativePath of normalizedPaths) {
917 const targetPath = path.resolve(projectDir, relativePath)
918 if (!targetPath.startsWith(`${path.resolve(projectDir)}${path.sep}`)) continue
919 if (!beforeFiles.has(relativePath)) {
920 await fs.promises.rm(targetPath, { force: true })
921 continue
922 }
923 const { blob } = await git.readBlob({
924 fs,
925 dir: projectDir,
926 oid: commit,
927 filepath: relativePath
928 })
929 await ensureDir(path.dirname(targetPath))
930 await fs.promises.writeFile(targetPath, blob)
931 }
932 }
933
934 private async restoreCommitFiles(
935 projectDir: string,
936 commit: string,
937 targetFiles: string[]
938 ): Promise<void> {
939 const normalizedTargetFiles = targetFiles.filter(isControlledFile)
940 if (!hasRestorableDeckFiles(normalizedTargetFiles)) {
941 throw new Error('目标历史版本缺少可恢复的页面文件,无法回退。')
942 }
943 const targetSet = new Set(normalizedTargetFiles)
944 for (const relativePath of targetSet) {
945 const { blob } = await git.readBlob({
946 fs,
947 dir: projectDir,
948 oid: commit,
949 filepath: relativePath
950 })
951 const targetPath = path.resolve(projectDir, relativePath)
952 if (!targetPath.startsWith(`${path.resolve(projectDir)}${path.sep}`)) {
953 log.warn('[history] skip restore outside project dir', { projectDir, relativePath })
954 continue
955 }
956 await ensureDir(path.dirname(targetPath))
957 await fs.promises.writeFile(targetPath, blob)
958 }
959
960 const currentFiles = await walkFiles(projectDir)
961 await Promise.all(
962 currentFiles
963 .filter((file) => isControlledFile(file) && !targetSet.has(file))
964 .map(async (file) => {
965 const targetPath = path.resolve(projectDir, file)
966 if (!targetPath.startsWith(`${path.resolve(projectDir)}${path.sep}`)) return
967 await fs.promises.rm(targetPath, { force: true })
968 })
969 )
970 }
971
972 private async buildOperationMetadata(
973 args: RecordOperationArgs
974 ): Promise<Record<string, unknown>> {
975 const session = await this.db.getSession(args.sessionId)
976 const sessionMetadata = parseJson<Record<string, unknown>>(session?.metadata, {})
977 const sessionStyleSnapshot = await this.db.getSessionStyleSnapshot(args.sessionId)
978 const providedSessionMetadata = args.metadata?.sessionMetadata
979 return {
980 ...(args.metadata || {}),
981 sessionStyleState: {
982 styleId: session?.styleId ?? null,
983 snapshot: sessionStyleSnapshot || null,
984 designContract: parseJson<unknown>(session?.designContract, null)
985 },
986 sessionMetadata:
987 providedSessionMetadata &&
988 typeof providedSessionMetadata === 'object' &&
989 !Array.isArray(providedSessionMetadata)
990 ? providedSessionMetadata
991 : sessionMetadata
992 }
993 }
994
995 private buildCommitMessage(args: RecordOperationArgs, changedPages: string[]): string {
996 const suffix = changedPages.length > 0 ? ` ${changedPages.join(',')}` : ''
997 return `[${args.type}:${args.scope}]${suffix}${args.prompt ? ` - ${args.prompt.slice(0, 80)}` : ''}`
998 }
999
1000 private toHistoryVersion(
1001 operation: SessionOperationRecord,
1002 current: { currentCommit: string | null; currentOperationId: string | null }
1003 ): HistoryVersion {
1004 const metadata = parseJson<Record<string, unknown>>(operation.metadata_json, {})
1005 const changedFiles = parseJson<ChangedHistoryFile[]>(operation.changed_files_json, [])
1006 const rawChangedPages = parseJson<string[]>(operation.changed_pages_json, [])
1007 // For edit operations, only show the page that was actually edited (not anchor-only changes)
1008 const editedPageId =
1009 operation.type === 'edit' && typeof metadata.pageId === 'string' ? metadata.pageId : ''
1010 const changedPages = editedPageId
1011 ? rawChangedPages.filter((p) => p === editedPageId)
1012 : rawChangedPages
1013 const trackedFiles = parseJson<string[]>(operation.tracked_files_json, []).filter(
1014 isControlledFile
1015 )
1016 const commit = operation.after_commit || ''
1017 return {
1018 id: operation.id,
1019 sessionId: operation.session_id,
1020 operationId: operation.id,
1021 commit,
1022 title: this.titleForOperation(operation, metadata),
1023 description: operation.prompt || this.descriptionForOperation(operation, changedPages),
1024 kind: operation.type,
1025 scope: operation.scope || 'session',
1026 createdAt: operation.completed_at || operation.created_at,
1027 changedFiles,
1028 changedPages,
1029 isCurrent: Boolean(
1030 (current.currentCommit && commit === current.currentCommit) ||
1031 (current.currentOperationId && operation.id === current.currentOperationId)
1032 ),
1033 isRestorable: Boolean(commit) && hasRestorableDeckFiles(trackedFiles)
1034 }
1035 }
1036
1037 private titleForOperation(
1038 operation: SessionOperationRecord,
1039 metadata: Record<string, unknown>
1040 ): string {
1041 const type = String(operation.type || '').trim()
1042 const scope = typeof operation.scope === 'string' ? operation.scope : ''
1043 const effectiveMode =
1044 typeof metadata.effectiveMode === 'string' ? metadata.effectiveMode.trim() : ''
1045 const styleSwitchPageNumber = Number(metadata.pageNumber)
1046
1047 if (metadata.jobType === 'style-switch') {
1048 return Number.isInteger(styleSwitchPageNumber) && styleSwitchPageNumber > 0
1049 ? `切换风格 · 第 ${styleSwitchPageNumber} 页`
1050 : '切换风格'
1051 }
1052
1053 if (effectiveMode === 'addPage' || metadata.addPage === true) return '新增页面'
1054 if (effectiveMode === 'retrySinglePage') return '重试页面'
1055 if (effectiveMode === 'retry') return '重试失败页面'
1056
1057 if (operation.type === 'import' && metadata.legacy) return '历史起点'
1058 if (type === 'import') return '导入 PPTX'
1059 if (type === 'generate') return '首次生成'
1060 if (type === 'addPage' || type === 'add_page') return '新增页面'
1061 if (type === 'reorder') return '调整页面顺序'
1062 if (type === 'delete') return '删除页面'
1063 if (type === 'retry') return scope === 'page' ? '重试页面' : '重试失败页面'
1064 if (type === 'rollback') return '回退到历史版本'
1065 if (type === 'edit') {
1066 if (scope === 'deck') return '全局修改页面'
1067 if (scope === 'selector') return '局部修改页面元素'
1068 if (scope === 'page') return '编辑页面'
1069 if (scope === 'session') return '调整页面'
1070 if (scope === 'shell') return '调整页面容器'
1071 }
1072 return '历史版本'
1073 }
1074
1075 private descriptionForOperation(
1076 operation: SessionOperationRecord,
1077 changedPages: string[]
1078 ): string {
1079 if (changedPages.length > 0) return `修改了 ${changedPages.join('、')}`
1080 if (operation.type === 'rollback') return '已恢复到选定版本'
1081 return '已记录此时间点'
1082 }
1083 }
1084
1085 export async function recordHistoryOperationSafe(
1086 db: PPTDatabase,
1087 args: RecordOperationArgs
1088 ): Promise<void> {
1089 try {
1090 await new GitHistoryService(db).recordOperation(args)
1091 } catch (error) {
1092 log.warn('[history] record operation failed', {
1093 sessionId: args.sessionId,
1094 type: args.type,
1095 message: error instanceof Error ? error.message : String(error)
1096 })
1097 }
1098 }
1099
1100 export async function recordHistoryOperationStrict(
1101 db: PPTDatabase,
1102 args: RecordOperationArgs
1103 ): Promise<void> {
1104 await new GitHistoryService(db).recordOperation(args)
1105 }
1106
1107 export async function ensureHistoryBaselineSafe(
1108 db: PPTDatabase,
1109 sessionId: string,
1110 projectDir: string
1111 ): Promise<void> {
1112 try {
1113 await new GitHistoryService(db).ensureBaseline(sessionId, projectDir)
1114 } catch (error) {
1115 log.warn('[history] ensure baseline failed', {
1116 sessionId,
1117 message: error instanceof Error ? error.message : String(error)
1118 })
1119 }
1120 }
1121
1121 lines TYPESCRIPT