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