source dump of claude code
at main 1115 lines 35 kB view raw
1import { createHash, type UUID } from 'crypto' 2import { diffLines } from 'diff' 3import type { Stats } from 'fs' 4import { 5 chmod, 6 copyFile, 7 link, 8 mkdir, 9 readFile, 10 stat, 11 unlink, 12} from 'fs/promises' 13import { dirname, isAbsolute, join, relative } from 'path' 14import { 15 getIsNonInteractiveSession, 16 getOriginalCwd, 17 getSessionId, 18} from 'src/bootstrap/state.js' 19import { logEvent } from 'src/services/analytics/index.js' 20import { notifyVscodeFileUpdated } from 'src/services/mcp/vscodeSdkMcp.js' 21import type { LogOption } from 'src/types/logs.js' 22import { inspect } from 'util' 23import { getGlobalConfig } from './config.js' 24import { logForDebugging } from './debug.js' 25import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 26import { getErrnoCode, isENOENT } from './errors.js' 27import { pathExists } from './file.js' 28import { logError } from './log.js' 29import { recordFileHistorySnapshot } from './sessionStorage.js' 30 31type BackupFileName = string | null // The null value means the file does not exist in this version 32 33export type FileHistoryBackup = { 34 backupFileName: BackupFileName 35 version: number 36 backupTime: Date 37} 38 39export type FileHistorySnapshot = { 40 messageId: UUID // The associated message ID for this snapshot 41 trackedFileBackups: Record<string, FileHistoryBackup> // Map of file paths to backup versions 42 timestamp: Date 43} 44 45export type FileHistoryState = { 46 snapshots: FileHistorySnapshot[] 47 trackedFiles: Set<string> 48 // Monotonically-increasing counter incremented on every snapshot, even when 49 // old snapshots are evicted. Used by useGitDiffStats as an activity signal 50 // (snapshots.length plateaus once the cap is reached). 51 snapshotSequence: number 52} 53 54const MAX_SNAPSHOTS = 100 55export type DiffStats = 56 | { 57 filesChanged?: string[] 58 insertions: number 59 deletions: number 60 } 61 | undefined 62 63export function fileHistoryEnabled(): boolean { 64 if (getIsNonInteractiveSession()) { 65 return fileHistoryEnabledSdk() 66 } 67 return ( 68 getGlobalConfig().fileCheckpointingEnabled !== false && 69 !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING) 70 ) 71} 72 73function fileHistoryEnabledSdk(): boolean { 74 return ( 75 isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING) && 76 !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING) 77 ) 78} 79 80/** 81 * Tracks a file edit (and add) by creating a backup of its current contents (if necessary). 82 * 83 * This must be called before the file is actually added or edited, so we can save 84 * its contents before the edit. 85 */ 86export async function fileHistoryTrackEdit( 87 updateFileHistoryState: ( 88 updater: (prev: FileHistoryState) => FileHistoryState, 89 ) => void, 90 filePath: string, 91 messageId: UUID, 92): Promise<void> { 93 if (!fileHistoryEnabled()) { 94 return 95 } 96 97 const trackingPath = maybeShortenFilePath(filePath) 98 99 // Phase 1: check if backup is needed. Speculative writes would overwrite 100 // the deterministic {hash}@v1 backup on every repeat call — a second 101 // trackEdit after an edit would corrupt v1 with post-edit content. 102 let captured: FileHistoryState | undefined 103 updateFileHistoryState(state => { 104 captured = state 105 return state 106 }) 107 if (!captured) return 108 const mostRecent = captured.snapshots.at(-1) 109 if (!mostRecent) { 110 logError(new Error('FileHistory: Missing most recent snapshot')) 111 logEvent('tengu_file_history_track_edit_failed', {}) 112 return 113 } 114 if (mostRecent.trackedFileBackups[trackingPath]) { 115 // Already tracked in the most recent snapshot; next makeSnapshot will 116 // re-check mtime and re-backup if changed. Do not touch v1 backup. 117 return 118 } 119 120 // Phase 2: async backup. 121 let backup: FileHistoryBackup 122 try { 123 backup = await createBackup(filePath, 1) 124 } catch (error) { 125 logError(error) 126 logEvent('tengu_file_history_track_edit_failed', {}) 127 return 128 } 129 const isAddingFile = backup.backupFileName === null 130 131 // Phase 3: commit. Re-check tracked (another trackEdit may have raced). 132 updateFileHistoryState((state: FileHistoryState) => { 133 try { 134 const mostRecentSnapshot = state.snapshots.at(-1) 135 if ( 136 !mostRecentSnapshot || 137 mostRecentSnapshot.trackedFileBackups[trackingPath] 138 ) { 139 return state 140 } 141 142 // This file has not already been tracked in the most recent snapshot, so we 143 // need to retroactively track a backup there. 144 const updatedTrackedFiles = state.trackedFiles.has(trackingPath) 145 ? state.trackedFiles 146 : new Set(state.trackedFiles).add(trackingPath) 147 148 // Shallow-spread is sufficient: backup values are never mutated after 149 // insertion, so we only need fresh top-level + trackedFileBackups refs 150 // for React change detection. A deep clone would copy every existing 151 // backup's Date/string fields — O(n) cost to add one entry. 152 const updatedMostRecentSnapshot = { 153 ...mostRecentSnapshot, 154 trackedFileBackups: { 155 ...mostRecentSnapshot.trackedFileBackups, 156 [trackingPath]: backup, 157 }, 158 } 159 160 const updatedState = { 161 ...state, 162 snapshots: (() => { 163 const copy = state.snapshots.slice() 164 copy[copy.length - 1] = updatedMostRecentSnapshot 165 return copy 166 })(), 167 trackedFiles: updatedTrackedFiles, 168 } 169 maybeDumpStateForDebug(updatedState) 170 171 // Record a snapshot update since it has changed. 172 void recordFileHistorySnapshot( 173 messageId, 174 updatedMostRecentSnapshot, 175 true, // isSnapshotUpdate 176 ).catch(error => { 177 logError(new Error(`FileHistory: Failed to record snapshot: ${error}`)) 178 }) 179 180 logEvent('tengu_file_history_track_edit_success', { 181 isNewFile: isAddingFile, 182 version: backup.version, 183 }) 184 logForDebugging(`FileHistory: Tracked file modification for ${filePath}`) 185 186 return updatedState 187 } catch (error) { 188 logError(error) 189 logEvent('tengu_file_history_track_edit_failed', {}) 190 return state 191 } 192 }) 193} 194 195/** 196 * Adds a snapshot in the file history and backs up any modified tracked files. 197 */ 198export async function fileHistoryMakeSnapshot( 199 updateFileHistoryState: ( 200 updater: (prev: FileHistoryState) => FileHistoryState, 201 ) => void, 202 messageId: UUID, 203): Promise<void> { 204 if (!fileHistoryEnabled()) { 205 return undefined 206 } 207 208 // Phase 1: capture current state with a no-op updater so we know which 209 // files to back up. Returning the same reference keeps this a true no-op 210 // for any wrapper that honors same-ref returns (src/CLAUDE.md wrapper 211 // rule). Wrappers that unconditionally spread will trigger one extra 212 // re-render; acceptable for a once-per-turn call. 213 let captured: FileHistoryState | undefined 214 updateFileHistoryState(state => { 215 captured = state 216 return state 217 }) 218 if (!captured) return // updateFileHistoryState was a no-op stub (e.g. mcp.ts) 219 220 // Phase 2: do all IO async, outside the updater. 221 const trackedFileBackups: Record<string, FileHistoryBackup> = {} 222 const mostRecentSnapshot = captured.snapshots.at(-1) 223 if (mostRecentSnapshot) { 224 logForDebugging(`FileHistory: Making snapshot for message ${messageId}`) 225 await Promise.all( 226 Array.from(captured.trackedFiles, async trackingPath => { 227 try { 228 const filePath = maybeExpandFilePath(trackingPath) 229 const latestBackup = 230 mostRecentSnapshot.trackedFileBackups[trackingPath] 231 const nextVersion = latestBackup ? latestBackup.version + 1 : 1 232 233 // Stat the file once; ENOENT means the tracked file was deleted. 234 let fileStats: Stats | undefined 235 try { 236 fileStats = await stat(filePath) 237 } catch (e: unknown) { 238 if (!isENOENT(e)) throw e 239 } 240 241 if (!fileStats) { 242 trackedFileBackups[trackingPath] = { 243 backupFileName: null, // Use null to denote missing tracked file 244 version: nextVersion, 245 backupTime: new Date(), 246 } 247 logEvent('tengu_file_history_backup_deleted_file', { 248 version: nextVersion, 249 }) 250 logForDebugging( 251 `FileHistory: Missing tracked file: ${trackingPath}`, 252 ) 253 return 254 } 255 256 // File exists - check if it needs to be backed up 257 if ( 258 latestBackup && 259 latestBackup.backupFileName !== null && 260 !(await checkOriginFileChanged( 261 filePath, 262 latestBackup.backupFileName, 263 fileStats, 264 )) 265 ) { 266 // File hasn't been modified since the latest version, reuse it 267 trackedFileBackups[trackingPath] = latestBackup 268 return 269 } 270 271 // File is newer than the latest backup, create a new backup 272 trackedFileBackups[trackingPath] = await createBackup( 273 filePath, 274 nextVersion, 275 ) 276 } catch (error) { 277 logError(error) 278 logEvent('tengu_file_history_backup_file_failed', {}) 279 } 280 }), 281 ) 282 } 283 284 // Phase 3: commit the new snapshot to state. Read state.trackedFiles FRESH 285 // — if fileHistoryTrackEdit added a file during phase 2's async window, it 286 // wrote the backup to state.snapshots[-1].trackedFileBackups. Inherit those 287 // so the new snapshot covers every currently-tracked file. 288 updateFileHistoryState((state: FileHistoryState) => { 289 try { 290 const lastSnapshot = state.snapshots.at(-1) 291 if (lastSnapshot) { 292 for (const trackingPath of state.trackedFiles) { 293 if (trackingPath in trackedFileBackups) continue 294 const inherited = lastSnapshot.trackedFileBackups[trackingPath] 295 if (inherited) trackedFileBackups[trackingPath] = inherited 296 } 297 } 298 const now = new Date() 299 const newSnapshot: FileHistorySnapshot = { 300 messageId, 301 trackedFileBackups, 302 timestamp: now, 303 } 304 305 const allSnapshots = [...state.snapshots, newSnapshot] 306 const updatedState: FileHistoryState = { 307 ...state, 308 snapshots: 309 allSnapshots.length > MAX_SNAPSHOTS 310 ? allSnapshots.slice(-MAX_SNAPSHOTS) 311 : allSnapshots, 312 snapshotSequence: (state.snapshotSequence ?? 0) + 1, 313 } 314 maybeDumpStateForDebug(updatedState) 315 316 void notifyVscodeSnapshotFilesUpdated(state, updatedState).catch(logError) 317 318 // Record the file history snapshot to session storage for resume support 319 void recordFileHistorySnapshot( 320 messageId, 321 newSnapshot, 322 false, // isSnapshotUpdate 323 ).catch(error => { 324 logError(new Error(`FileHistory: Failed to record snapshot: ${error}`)) 325 }) 326 327 logForDebugging( 328 `FileHistory: Added snapshot for ${messageId}, tracking ${state.trackedFiles.size} files`, 329 ) 330 logEvent('tengu_file_history_snapshot_success', { 331 trackedFilesCount: state.trackedFiles.size, 332 snapshotCount: updatedState.snapshots.length, 333 }) 334 335 return updatedState 336 } catch (error) { 337 logError(error) 338 logEvent('tengu_file_history_snapshot_failed', {}) 339 return state 340 } 341 }) 342} 343 344/** 345 * Rewinds the file system to a previous snapshot. 346 */ 347export async function fileHistoryRewind( 348 updateFileHistoryState: ( 349 updater: (prev: FileHistoryState) => FileHistoryState, 350 ) => void, 351 messageId: UUID, 352): Promise<void> { 353 if (!fileHistoryEnabled()) { 354 return 355 } 356 357 // Rewind is a pure filesystem side-effect and does not mutate 358 // FileHistoryState. Capture state with a no-op updater, then do IO async. 359 let captured: FileHistoryState | undefined 360 updateFileHistoryState(state => { 361 captured = state 362 return state 363 }) 364 if (!captured) return 365 366 const targetSnapshot = captured.snapshots.findLast( 367 snapshot => snapshot.messageId === messageId, 368 ) 369 if (!targetSnapshot) { 370 logError(new Error(`FileHistory: Snapshot for ${messageId} not found`)) 371 logEvent('tengu_file_history_rewind_failed', { 372 trackedFilesCount: captured.trackedFiles.size, 373 snapshotFound: false, 374 }) 375 throw new Error('The selected snapshot was not found') 376 } 377 378 try { 379 logForDebugging( 380 `FileHistory: [Rewind] Rewinding to snapshot for ${messageId}`, 381 ) 382 const filesChanged = await applySnapshot(captured, targetSnapshot) 383 384 logForDebugging(`FileHistory: [Rewind] Finished rewinding to ${messageId}`) 385 logEvent('tengu_file_history_rewind_success', { 386 trackedFilesCount: captured.trackedFiles.size, 387 filesChangedCount: filesChanged.length, 388 }) 389 } catch (error) { 390 logError(error) 391 logEvent('tengu_file_history_rewind_failed', { 392 trackedFilesCount: captured.trackedFiles.size, 393 snapshotFound: true, 394 }) 395 throw error 396 } 397} 398 399export function fileHistoryCanRestore( 400 state: FileHistoryState, 401 messageId: UUID, 402): boolean { 403 if (!fileHistoryEnabled()) { 404 return false 405 } 406 407 return state.snapshots.some(snapshot => snapshot.messageId === messageId) 408} 409 410/** 411 * Computes diff stats for a file snapshot by counting the number of files that would be changed 412 * if reverting to that snapshot. 413 */ 414export async function fileHistoryGetDiffStats( 415 state: FileHistoryState, 416 messageId: UUID, 417): Promise<DiffStats> { 418 if (!fileHistoryEnabled()) { 419 return undefined 420 } 421 422 const targetSnapshot = state.snapshots.findLast( 423 snapshot => snapshot.messageId === messageId, 424 ) 425 426 if (!targetSnapshot) { 427 return undefined 428 } 429 430 const results = await Promise.all( 431 Array.from(state.trackedFiles, async trackingPath => { 432 try { 433 const filePath = maybeExpandFilePath(trackingPath) 434 const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] 435 436 const backupFileName: BackupFileName | undefined = targetBackup 437 ? targetBackup.backupFileName 438 : getBackupFileNameFirstVersion(trackingPath, state) 439 440 if (backupFileName === undefined) { 441 // Error resolving the backup, so don't touch the file 442 logError( 443 new Error('FileHistory: Error finding the backup file to apply'), 444 ) 445 logEvent('tengu_file_history_rewind_restore_file_failed', { 446 dryRun: true, 447 }) 448 return null 449 } 450 451 const stats = await computeDiffStatsForFile( 452 filePath, 453 backupFileName === null ? undefined : backupFileName, 454 ) 455 if (stats?.insertions || stats?.deletions) { 456 return { filePath, stats } 457 } 458 if (backupFileName === null && (await pathExists(filePath))) { 459 // Zero-byte file created after snapshot: counts as changed even 460 // though diffLines reports 0/0. 461 return { filePath, stats } 462 } 463 return null 464 } catch (error) { 465 logError(error) 466 logEvent('tengu_file_history_rewind_restore_file_failed', { 467 dryRun: true, 468 }) 469 return null 470 } 471 }), 472 ) 473 474 const filesChanged: string[] = [] 475 let insertions = 0 476 let deletions = 0 477 for (const r of results) { 478 if (!r) continue 479 filesChanged.push(r.filePath) 480 insertions += r.stats?.insertions || 0 481 deletions += r.stats?.deletions || 0 482 } 483 return { filesChanged, insertions, deletions } 484} 485 486/** 487 * Lightweight boolean-only check: would rewinding to this message change any 488 * file on disk? Uses the same stat/content comparison as the non-dry-run path 489 * of applySnapshot (checkOriginFileChanged) instead of computeDiffStatsForFile, 490 * so it never calls diffLines. Early-exits on the first changed file. Use when 491 * the caller only needs a yes/no answer; fileHistoryGetDiffStats remains for 492 * callers that display insertions/deletions. 493 */ 494export async function fileHistoryHasAnyChanges( 495 state: FileHistoryState, 496 messageId: UUID, 497): Promise<boolean> { 498 if (!fileHistoryEnabled()) { 499 return false 500 } 501 502 const targetSnapshot = state.snapshots.findLast( 503 snapshot => snapshot.messageId === messageId, 504 ) 505 if (!targetSnapshot) { 506 return false 507 } 508 509 for (const trackingPath of state.trackedFiles) { 510 try { 511 const filePath = maybeExpandFilePath(trackingPath) 512 const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] 513 const backupFileName: BackupFileName | undefined = targetBackup 514 ? targetBackup.backupFileName 515 : getBackupFileNameFirstVersion(trackingPath, state) 516 517 if (backupFileName === undefined) { 518 continue 519 } 520 if (backupFileName === null) { 521 // Backup says file did not exist; probe via stat (operate-then-catch). 522 if (await pathExists(filePath)) return true 523 continue 524 } 525 if (await checkOriginFileChanged(filePath, backupFileName)) return true 526 } catch (error) { 527 logError(error) 528 } 529 } 530 return false 531} 532 533/** 534 * Applies the given file snapshot state to the tracked files (writes/deletes 535 * on disk), returning the list of changed file paths. Async IO only. 536 */ 537async function applySnapshot( 538 state: FileHistoryState, 539 targetSnapshot: FileHistorySnapshot, 540): Promise<string[]> { 541 const filesChanged: string[] = [] 542 for (const trackingPath of state.trackedFiles) { 543 try { 544 const filePath = maybeExpandFilePath(trackingPath) 545 const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] 546 547 const backupFileName: BackupFileName | undefined = targetBackup 548 ? targetBackup.backupFileName 549 : getBackupFileNameFirstVersion(trackingPath, state) 550 551 if (backupFileName === undefined) { 552 // Error resolving the backup, so don't touch the file 553 logError( 554 new Error('FileHistory: Error finding the backup file to apply'), 555 ) 556 logEvent('tengu_file_history_rewind_restore_file_failed', { 557 dryRun: false, 558 }) 559 continue 560 } 561 562 if (backupFileName === null) { 563 // File did not exist at the target version; delete it if present. 564 try { 565 await unlink(filePath) 566 logForDebugging(`FileHistory: [Rewind] Deleted ${filePath}`) 567 filesChanged.push(filePath) 568 } catch (e: unknown) { 569 if (!isENOENT(e)) throw e 570 // Already absent; nothing to do. 571 } 572 continue 573 } 574 575 // File should exist at a specific version. Restore only if it differs. 576 if (await checkOriginFileChanged(filePath, backupFileName)) { 577 await restoreBackup(filePath, backupFileName) 578 logForDebugging( 579 `FileHistory: [Rewind] Restored ${filePath} from ${backupFileName}`, 580 ) 581 filesChanged.push(filePath) 582 } 583 } catch (error) { 584 logError(error) 585 logEvent('tengu_file_history_rewind_restore_file_failed', { 586 dryRun: false, 587 }) 588 } 589 } 590 return filesChanged 591} 592 593/** 594 * Checks if the original file has been changed compared to the backup file. 595 * Optionally reuses a pre-fetched stat for the original file (when the caller 596 * already stat'd it to check existence, we avoid a second syscall). 597 * 598 * Exported for testing. 599 */ 600export async function checkOriginFileChanged( 601 originalFile: string, 602 backupFileName: string, 603 originalStatsHint?: Stats, 604): Promise<boolean> { 605 const backupPath = resolveBackupPath(backupFileName) 606 607 let originalStats: Stats | null = originalStatsHint ?? null 608 if (!originalStats) { 609 try { 610 originalStats = await stat(originalFile) 611 } catch (e: unknown) { 612 if (!isENOENT(e)) return true 613 } 614 } 615 let backupStats: Stats | null = null 616 try { 617 backupStats = await stat(backupPath) 618 } catch (e: unknown) { 619 if (!isENOENT(e)) return true 620 } 621 622 return compareStatsAndContent(originalStats, backupStats, async () => { 623 try { 624 const [originalContent, backupContent] = await Promise.all([ 625 readFile(originalFile, 'utf-8'), 626 readFile(backupPath, 'utf-8'), 627 ]) 628 return originalContent !== backupContent 629 } catch { 630 // File deleted between stat and read -> treat as changed. 631 return true 632 } 633 }) 634} 635 636/** 637 * Shared stat/content comparison logic for sync and async change checks. 638 * Returns true if the file has changed relative to the backup. 639 */ 640function compareStatsAndContent<T extends boolean | Promise<boolean>>( 641 originalStats: Stats | null, 642 backupStats: Stats | null, 643 compareContent: () => T, 644): T | boolean { 645 // One exists, one missing -> changed 646 if ((originalStats === null) !== (backupStats === null)) { 647 return true 648 } 649 // Both missing -> no change 650 if (originalStats === null || backupStats === null) { 651 return false 652 } 653 654 // Check file stats like permission and file size 655 if ( 656 originalStats.mode !== backupStats.mode || 657 originalStats.size !== backupStats.size 658 ) { 659 return true 660 } 661 662 // This is an optimization that depends on the correct setting of the modified 663 // time. If the original file's modified time was before the backup time, then 664 // we can skip the file content comparison. 665 if (originalStats.mtimeMs < backupStats.mtimeMs) { 666 return false 667 } 668 669 // Use the more expensive file content comparison. The callback handles its 670 // own read errors — a try/catch here is dead for async callbacks anyway. 671 return compareContent() 672} 673 674/** 675 * Computes the number of lines changed in the diff. 676 */ 677async function computeDiffStatsForFile( 678 originalFile: string, 679 backupFileName?: string, 680): Promise<DiffStats> { 681 const filesChanged: string[] = [] 682 let insertions = 0 683 let deletions = 0 684 try { 685 const backupPath = backupFileName 686 ? resolveBackupPath(backupFileName) 687 : undefined 688 689 const [originalContent, backupContent] = await Promise.all([ 690 readFileAsyncOrNull(originalFile), 691 backupPath ? readFileAsyncOrNull(backupPath) : null, 692 ]) 693 694 if (originalContent === null && backupContent === null) { 695 return { 696 filesChanged, 697 insertions, 698 deletions, 699 } 700 } 701 702 filesChanged.push(originalFile) 703 704 // Compute the diff 705 const changes = diffLines(originalContent ?? '', backupContent ?? '') 706 changes.forEach(c => { 707 if (c.added) { 708 insertions += c.count || 0 709 } 710 if (c.removed) { 711 deletions += c.count || 0 712 } 713 }) 714 } catch (error) { 715 logError(new Error(`FileHistory: Error generating diffStats: ${error}`)) 716 } 717 718 return { 719 filesChanged, 720 insertions, 721 deletions, 722 } 723} 724 725function getBackupFileName(filePath: string, version: number): string { 726 const fileNameHash = createHash('sha256') 727 .update(filePath) 728 .digest('hex') 729 .slice(0, 16) 730 return `${fileNameHash}@v${version}` 731} 732 733function resolveBackupPath(backupFileName: string, sessionId?: string): string { 734 const configDir = getClaudeConfigHomeDir() 735 return join( 736 configDir, 737 'file-history', 738 sessionId || getSessionId(), 739 backupFileName, 740 ) 741} 742 743/** 744 * Creates a backup of the file at filePath. If the file does not exist 745 * (ENOENT), records a null backup (file-did-not-exist marker). All IO is 746 * async. Lazy mkdir: tries copyFile first, creates the directory on ENOENT. 747 */ 748async function createBackup( 749 filePath: string | null, 750 version: number, 751): Promise<FileHistoryBackup> { 752 if (filePath === null) { 753 return { backupFileName: null, version, backupTime: new Date() } 754 } 755 756 const backupFileName = getBackupFileName(filePath, version) 757 const backupPath = resolveBackupPath(backupFileName) 758 759 // Stat first: if the source is missing, record a null backup and skip the 760 // copy. Separates "source missing" from "backup dir missing" cleanly — 761 // sharing a catch for both meant a file deleted between copyFile-success 762 // and stat would leave an orphaned backup with a null state record. 763 let srcStats: Stats 764 try { 765 srcStats = await stat(filePath) 766 } catch (e: unknown) { 767 if (isENOENT(e)) { 768 return { backupFileName: null, version, backupTime: new Date() } 769 } 770 throw e 771 } 772 773 // copyFile preserves content and avoids reading the whole file into the JS 774 // heap (which the previous readFileSync+writeFileSync pipeline did, OOMing 775 // on large tracked files). Lazy mkdir: 99% of calls hit the fast path 776 // (directory already exists); on ENOENT, mkdir then retry. 777 try { 778 await copyFile(filePath, backupPath) 779 } catch (e: unknown) { 780 if (!isENOENT(e)) throw e 781 await mkdir(dirname(backupPath), { recursive: true }) 782 await copyFile(filePath, backupPath) 783 } 784 785 // Preserve file permissions on the backup. 786 await chmod(backupPath, srcStats.mode) 787 788 logEvent('tengu_file_history_backup_file_created', { 789 version: version, 790 fileSize: srcStats.size, 791 }) 792 793 return { 794 backupFileName, 795 version, 796 backupTime: new Date(), 797 } 798} 799 800/** 801 * Restores a file from its backup path with proper directory creation and permissions. 802 * Lazy mkdir: tries copyFile first, creates the directory on ENOENT. 803 */ 804async function restoreBackup( 805 filePath: string, 806 backupFileName: string, 807): Promise<void> { 808 const backupPath = resolveBackupPath(backupFileName) 809 810 // Stat first: if the backup is missing, log and bail before attempting 811 // the copy. Separates "backup missing" from "destination dir missing". 812 let backupStats: Stats 813 try { 814 backupStats = await stat(backupPath) 815 } catch (e: unknown) { 816 if (isENOENT(e)) { 817 logEvent('tengu_file_history_rewind_restore_file_failed', {}) 818 logError( 819 new Error(`FileHistory: [Rewind] Backup file not found: ${backupPath}`), 820 ) 821 return 822 } 823 throw e 824 } 825 826 // Lazy mkdir: 99% of calls hit the fast path (destination dir exists). 827 try { 828 await copyFile(backupPath, filePath) 829 } catch (e: unknown) { 830 if (!isENOENT(e)) throw e 831 await mkdir(dirname(filePath), { recursive: true }) 832 await copyFile(backupPath, filePath) 833 } 834 835 // Restore the file permissions 836 await chmod(filePath, backupStats.mode) 837} 838 839/** 840 * Gets the first (earliest) backup version for a file, used when rewinding 841 * to a target backup point where the file has not been tracked yet. 842 * 843 * @returns The backup file name for the first version, or null if the file 844 * did not exist in the first version, or undefined if we cannot find a 845 * first version at all 846 */ 847function getBackupFileNameFirstVersion( 848 trackingPath: string, 849 state: FileHistoryState, 850): BackupFileName | undefined { 851 for (const snapshot of state.snapshots) { 852 const backup = snapshot.trackedFileBackups[trackingPath] 853 if (backup !== undefined && backup.version === 1) { 854 // This can be either a file name or null, with null meaning the file 855 // did not exist in the first version. 856 return backup.backupFileName 857 } 858 } 859 860 // The undefined means there was an error resolving the first version. 861 return undefined 862} 863 864/** 865 * Use the relative path as the key to reduce session storage space for tracking. 866 */ 867function maybeShortenFilePath(filePath: string): string { 868 if (!isAbsolute(filePath)) { 869 return filePath 870 } 871 const cwd = getOriginalCwd() 872 if (filePath.startsWith(cwd)) { 873 return relative(cwd, filePath) 874 } 875 return filePath 876} 877 878function maybeExpandFilePath(filePath: string): string { 879 if (isAbsolute(filePath)) { 880 return filePath 881 } 882 return join(getOriginalCwd(), filePath) 883} 884 885/** 886 * Restores file history snapshot state for a given log option. 887 */ 888export function fileHistoryRestoreStateFromLog( 889 fileHistorySnapshots: FileHistorySnapshot[], 890 onUpdateState: (newState: FileHistoryState) => void, 891): void { 892 if (!fileHistoryEnabled()) { 893 return 894 } 895 // Make a copy of the snapshots as we migrate from absolute path to 896 // shortened relative tracking path. 897 const snapshots: FileHistorySnapshot[] = [] 898 // Rebuild the tracked files from the snapshots 899 const trackedFiles = new Set<string>() 900 for (const snapshot of fileHistorySnapshots) { 901 const trackedFileBackups: Record<string, FileHistoryBackup> = {} 902 for (const [path, backup] of Object.entries(snapshot.trackedFileBackups)) { 903 const trackingPath = maybeShortenFilePath(path) 904 trackedFiles.add(trackingPath) 905 trackedFileBackups[trackingPath] = backup 906 } 907 snapshots.push({ 908 ...snapshot, 909 trackedFileBackups: trackedFileBackups, 910 }) 911 } 912 onUpdateState({ 913 snapshots: snapshots, 914 trackedFiles: trackedFiles, 915 snapshotSequence: snapshots.length, 916 }) 917} 918 919/** 920 * Copy file history snapshots for a given log option. 921 */ 922export async function copyFileHistoryForResume(log: LogOption): Promise<void> { 923 if (!fileHistoryEnabled()) { 924 return 925 } 926 927 const fileHistorySnapshots = log.fileHistorySnapshots 928 if (!fileHistorySnapshots || log.messages.length === 0) { 929 return 930 } 931 const lastMessage = log.messages[log.messages.length - 1] 932 const previousSessionId = lastMessage?.sessionId 933 if (!previousSessionId) { 934 logError( 935 new Error( 936 `FileHistory: Failed to copy backups on restore (no previous session id)`, 937 ), 938 ) 939 return 940 } 941 942 const sessionId = getSessionId() 943 if (previousSessionId === sessionId) { 944 logForDebugging( 945 `FileHistory: No need to copy file history for resuming with same session id: ${sessionId}`, 946 ) 947 return 948 } 949 950 try { 951 // All backups share the same directory: {configDir}/file-history/{sessionId}/ 952 // Create it once upfront instead of once per backup file 953 const newBackupDir = join( 954 getClaudeConfigHomeDir(), 955 'file-history', 956 sessionId, 957 ) 958 await mkdir(newBackupDir, { recursive: true }) 959 960 // Migrate all backup files from the previous session to current session. 961 // Process all snapshots in parallel; within each snapshot, links also run in parallel. 962 let failedSnapshots = 0 963 await Promise.allSettled( 964 fileHistorySnapshots.map(async snapshot => { 965 const backupEntries = Object.values(snapshot.trackedFileBackups).filter( 966 (backup): backup is typeof backup & { backupFileName: string } => 967 backup.backupFileName !== null, 968 ) 969 970 const results = await Promise.allSettled( 971 backupEntries.map(async ({ backupFileName }) => { 972 const oldBackupPath = resolveBackupPath( 973 backupFileName, 974 previousSessionId, 975 ) 976 const newBackupPath = join(newBackupDir, backupFileName) 977 978 try { 979 await link(oldBackupPath, newBackupPath) 980 } catch (e: unknown) { 981 const code = getErrnoCode(e) 982 if (code === 'EEXIST') { 983 // Already migrated, skip 984 return 985 } 986 if (code === 'ENOENT') { 987 logError( 988 new Error( 989 `FileHistory: Failed to copy backup ${backupFileName} on restore (backup file does not exist in ${previousSessionId})`, 990 ), 991 ) 992 throw e 993 } 994 logError( 995 new Error( 996 `FileHistory: Error hard linking backup file from previous session`, 997 ), 998 ) 999 // Fallback to copy if hard link fails 1000 try { 1001 await copyFile(oldBackupPath, newBackupPath) 1002 } catch (copyErr) { 1003 logError( 1004 new Error( 1005 `FileHistory: Error copying over backup from previous session`, 1006 ), 1007 ) 1008 throw copyErr 1009 } 1010 } 1011 1012 logForDebugging( 1013 `FileHistory: Copied backup ${backupFileName} from session ${previousSessionId} to ${sessionId}`, 1014 ) 1015 }), 1016 ) 1017 1018 const copyFailed = results.some(r => r.status === 'rejected') 1019 1020 // Record the snapshot only if we have successfully migrated the backup files 1021 if (!copyFailed) { 1022 void recordFileHistorySnapshot( 1023 snapshot.messageId, 1024 snapshot, 1025 false, // isSnapshotUpdate 1026 ).catch(_ => { 1027 logError( 1028 new Error(`FileHistory: Failed to record copy backup snapshot`), 1029 ) 1030 }) 1031 } else { 1032 failedSnapshots++ 1033 } 1034 }), 1035 ) 1036 1037 if (failedSnapshots > 0) { 1038 logEvent('tengu_file_history_resume_copy_failed', { 1039 numSnapshots: fileHistorySnapshots.length, 1040 failedSnapshots, 1041 }) 1042 } 1043 } catch (error) { 1044 logError(error) 1045 } 1046} 1047 1048/** 1049 * Notifies VSCode about files that have changed between snapshots. 1050 * Compares the previous snapshot with the new snapshot and sends file_updated 1051 * notifications for any files whose content has changed. 1052 * Fire-and-forget (void-dispatched from fileHistoryMakeSnapshot). 1053 */ 1054async function notifyVscodeSnapshotFilesUpdated( 1055 oldState: FileHistoryState, 1056 newState: FileHistoryState, 1057): Promise<void> { 1058 const oldSnapshot = oldState.snapshots.at(-1) 1059 const newSnapshot = newState.snapshots.at(-1) 1060 1061 if (!newSnapshot) { 1062 return 1063 } 1064 1065 for (const trackingPath of newState.trackedFiles) { 1066 const filePath = maybeExpandFilePath(trackingPath) 1067 const oldBackup = oldSnapshot?.trackedFileBackups[trackingPath] 1068 const newBackup = newSnapshot.trackedFileBackups[trackingPath] 1069 1070 // Skip if both backups reference the same version (no change) 1071 if ( 1072 oldBackup?.backupFileName === newBackup?.backupFileName && 1073 oldBackup?.version === newBackup?.version 1074 ) { 1075 continue 1076 } 1077 1078 // Get old content from the previous backup 1079 let oldContent: string | null = null 1080 if (oldBackup?.backupFileName) { 1081 const backupPath = resolveBackupPath(oldBackup.backupFileName) 1082 oldContent = await readFileAsyncOrNull(backupPath) 1083 } 1084 1085 // Get new content from the new backup or current file 1086 let newContent: string | null = null 1087 if (newBackup?.backupFileName) { 1088 const backupPath = resolveBackupPath(newBackup.backupFileName) 1089 newContent = await readFileAsyncOrNull(backupPath) 1090 } 1091 // If newBackup?.backupFileName === null, the file was deleted; newContent stays null. 1092 1093 // Only notify if content actually changed 1094 if (oldContent !== newContent) { 1095 notifyVscodeFileUpdated(filePath, oldContent, newContent) 1096 } 1097 } 1098} 1099 1100/** Async read that swallows all errors and returns null (best-effort). */ 1101async function readFileAsyncOrNull(path: string): Promise<string | null> { 1102 try { 1103 return await readFile(path, 'utf-8') 1104 } catch { 1105 return null 1106 } 1107} 1108 1109const ENABLE_DUMP_STATE = false 1110function maybeDumpStateForDebug(state: FileHistoryState): void { 1111 if (ENABLE_DUMP_STATE) { 1112 // biome-ignore lint/suspicious/noConsole:: intentional console output 1113 console.error(inspect(state, false, 5)) 1114 } 1115}