/ utils / fileHistory.ts
fileHistory.ts
   1  import { createHash, type UUID } from 'crypto'
   2  import { diffLines } from 'diff'
   3  import type { Stats } from 'fs'
   4  import {
   5    chmod,
   6    copyFile,
   7    link,
   8    mkdir,
   9    readFile,
  10    stat,
  11    unlink,
  12  } from 'fs/promises'
  13  import { dirname, isAbsolute, join, relative } from 'path'
  14  import {
  15    getIsNonInteractiveSession,
  16    getOriginalCwd,
  17    getSessionId,
  18  } from 'src/bootstrap/state.js'
  19  import { logEvent } from 'src/services/analytics/index.js'
  20  import { notifyVscodeFileUpdated } from 'src/services/mcp/vscodeSdkMcp.js'
  21  import type { LogOption } from 'src/types/logs.js'
  22  import { inspect } from 'util'
  23  import { getGlobalConfig } from './config.js'
  24  import { logForDebugging } from './debug.js'
  25  import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
  26  import { getErrnoCode, isENOENT } from './errors.js'
  27  import { pathExists } from './file.js'
  28  import { logError } from './log.js'
  29  import { recordFileHistorySnapshot } from './sessionStorage.js'
  30  
  31  type BackupFileName = string | null // The null value means the file does not exist in this version
  32  
  33  export type FileHistoryBackup = {
  34    backupFileName: BackupFileName
  35    version: number
  36    backupTime: Date
  37  }
  38  
  39  export 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  
  45  export 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  
  54  const MAX_SNAPSHOTS = 100
  55  export type DiffStats =
  56    | {
  57        filesChanged?: string[]
  58        insertions: number
  59        deletions: number
  60      }
  61    | undefined
  62  
  63  export 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  
  73  function 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   */
  86  export 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   */
 198  export 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   */
 347  export 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  
 399  export 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   */
 414  export 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   */
 494  export 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   */
 537  async 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   */
 600  export 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   */
 640  function 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   */
 677  async 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  
 725  function 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  
 733  function 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   */
 748  async 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   */
 804  async 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   */
 847  function 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   */
 867  function 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  
 878  function 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   */
 888  export 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   */
 922  export 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   */
1054  async 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). */
1101  async function readFileAsyncOrNull(path: string): Promise<string | null> {
1102    try {
1103      return await readFile(path, 'utf-8')
1104    } catch {
1105      return null
1106    }
1107  }
1108  
1109  const ENABLE_DUMP_STATE = false
1110  function 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  }