mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
1117 lines
34 KiB
TypeScript
1117 lines
34 KiB
TypeScript
import { createHash, type UUID } from 'crypto'
|
||
import { diffLines } from 'diff'
|
||
import type { Stats } from 'fs'
|
||
import {
|
||
chmod,
|
||
copyFile,
|
||
link,
|
||
mkdir,
|
||
readFile,
|
||
stat,
|
||
unlink,
|
||
} from 'fs/promises'
|
||
import { dirname, isAbsolute, join, relative } from 'path'
|
||
import {
|
||
getIsNonInteractiveSession,
|
||
getOriginalCwd,
|
||
getSessionId,
|
||
} from 'src/bootstrap/state.js'
|
||
import { logEvent } from 'src/services/analytics/index.js'
|
||
import { notifyVscodeFileUpdated } from 'src/services/mcp/vscodeSdkMcp.js'
|
||
import type { LogOption } from 'src/types/logs.js'
|
||
import { inspect } from 'util'
|
||
import { getGlobalConfig } from './config.js'
|
||
import { logForDebugging } from './debug.js'
|
||
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
|
||
import { getErrnoCode, isENOENT } from './errors.js'
|
||
import { pathExists } from './file.js'
|
||
import { logError } from './log.js'
|
||
import { recordFileHistorySnapshot } from './sessionStorage.js'
|
||
|
||
type BackupFileName = string | null // The null value means the file does not exist in this version
|
||
|
||
export type FileHistoryBackup = {
|
||
backupFileName: BackupFileName
|
||
version: number
|
||
backupTime: Date
|
||
}
|
||
|
||
export type FileHistorySnapshot = {
|
||
messageId: UUID // The associated message ID for this snapshot
|
||
trackedFileBackups: Record<string, FileHistoryBackup> // Map of file paths to backup versions
|
||
timestamp: Date
|
||
}
|
||
|
||
export type FileHistoryState = {
|
||
snapshots: FileHistorySnapshot[]
|
||
trackedFiles: Set<string>
|
||
// Monotonically-increasing counter incremented on every snapshot, even when
|
||
// old snapshots are evicted. Used by useGitDiffStats as an activity signal
|
||
// (snapshots.length plateaus once the cap is reached).
|
||
snapshotSequence: number
|
||
}
|
||
|
||
// Disabled: file checkpointing causes unbounded memory growth (100 snapshots × full file backups).
|
||
// See heap snapshot analysis — re-enable only after switching to incremental diffs.
|
||
const MAX_SNAPSHOTS = 20
|
||
export type DiffStats =
|
||
| {
|
||
filesChanged?: string[]
|
||
insertions: number
|
||
deletions: number
|
||
}
|
||
| undefined
|
||
|
||
export function fileHistoryEnabled(): boolean {
|
||
if (getIsNonInteractiveSession()) {
|
||
return fileHistoryEnabledSdk()
|
||
}
|
||
return (
|
||
getGlobalConfig().fileCheckpointingEnabled !== false &&
|
||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING)
|
||
)
|
||
}
|
||
|
||
function fileHistoryEnabledSdk(): boolean {
|
||
return (
|
||
isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING) &&
|
||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Tracks a file edit (and add) by creating a backup of its current contents (if necessary).
|
||
*
|
||
* This must be called before the file is actually added or edited, so we can save
|
||
* its contents before the edit.
|
||
*/
|
||
export async function fileHistoryTrackEdit(
|
||
updateFileHistoryState: (
|
||
updater: (prev: FileHistoryState) => FileHistoryState,
|
||
) => void,
|
||
filePath: string,
|
||
messageId: UUID,
|
||
): Promise<void> {
|
||
if (!fileHistoryEnabled()) {
|
||
return
|
||
}
|
||
|
||
const trackingPath = maybeShortenFilePath(filePath)
|
||
|
||
// Phase 1: check if backup is needed. Speculative writes would overwrite
|
||
// the deterministic {hash}@v1 backup on every repeat call — a second
|
||
// trackEdit after an edit would corrupt v1 with post-edit content.
|
||
let captured: FileHistoryState | undefined
|
||
updateFileHistoryState(state => {
|
||
captured = state
|
||
return state
|
||
})
|
||
if (!captured) return
|
||
const mostRecent = captured.snapshots.at(-1)
|
||
if (!mostRecent) {
|
||
logError(new Error('FileHistory: Missing most recent snapshot'))
|
||
logEvent('tengu_file_history_track_edit_failed', {})
|
||
return
|
||
}
|
||
if (mostRecent.trackedFileBackups[trackingPath]) {
|
||
// Already tracked in the most recent snapshot; next makeSnapshot will
|
||
// re-check mtime and re-backup if changed. Do not touch v1 backup.
|
||
return
|
||
}
|
||
|
||
// Phase 2: async backup.
|
||
let backup: FileHistoryBackup
|
||
try {
|
||
backup = await createBackup(filePath, 1)
|
||
} catch (error) {
|
||
logError(error)
|
||
logEvent('tengu_file_history_track_edit_failed', {})
|
||
return
|
||
}
|
||
const isAddingFile = backup.backupFileName === null
|
||
|
||
// Phase 3: commit. Re-check tracked (another trackEdit may have raced).
|
||
updateFileHistoryState((state: FileHistoryState) => {
|
||
try {
|
||
const mostRecentSnapshot = state.snapshots.at(-1)
|
||
if (
|
||
!mostRecentSnapshot ||
|
||
mostRecentSnapshot.trackedFileBackups[trackingPath]
|
||
) {
|
||
return state
|
||
}
|
||
|
||
// This file has not already been tracked in the most recent snapshot, so we
|
||
// need to retroactively track a backup there.
|
||
const updatedTrackedFiles = state.trackedFiles.has(trackingPath)
|
||
? state.trackedFiles
|
||
: new Set(state.trackedFiles).add(trackingPath)
|
||
|
||
// Shallow-spread is sufficient: backup values are never mutated after
|
||
// insertion, so we only need fresh top-level + trackedFileBackups refs
|
||
// for React change detection. A deep clone would copy every existing
|
||
// backup's Date/string fields — O(n) cost to add one entry.
|
||
const updatedMostRecentSnapshot = {
|
||
...mostRecentSnapshot,
|
||
trackedFileBackups: {
|
||
...mostRecentSnapshot.trackedFileBackups,
|
||
[trackingPath]: backup,
|
||
},
|
||
}
|
||
|
||
const updatedState = {
|
||
...state,
|
||
snapshots: (() => {
|
||
const copy = state.snapshots.slice()
|
||
copy[copy.length - 1] = updatedMostRecentSnapshot
|
||
return copy
|
||
})(),
|
||
trackedFiles: updatedTrackedFiles,
|
||
}
|
||
maybeDumpStateForDebug(updatedState)
|
||
|
||
// Record a snapshot update since it has changed.
|
||
void recordFileHistorySnapshot(
|
||
messageId,
|
||
updatedMostRecentSnapshot,
|
||
true, // isSnapshotUpdate
|
||
).catch(error => {
|
||
logError(new Error(`FileHistory: Failed to record snapshot: ${error}`))
|
||
})
|
||
|
||
logEvent('tengu_file_history_track_edit_success', {
|
||
isNewFile: isAddingFile,
|
||
version: backup.version,
|
||
})
|
||
logForDebugging(`FileHistory: Tracked file modification for ${filePath}`)
|
||
|
||
return updatedState
|
||
} catch (error) {
|
||
logError(error)
|
||
logEvent('tengu_file_history_track_edit_failed', {})
|
||
return state
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Adds a snapshot in the file history and backs up any modified tracked files.
|
||
*/
|
||
export async function fileHistoryMakeSnapshot(
|
||
updateFileHistoryState: (
|
||
updater: (prev: FileHistoryState) => FileHistoryState,
|
||
) => void,
|
||
messageId: UUID,
|
||
): Promise<void> {
|
||
if (!fileHistoryEnabled()) {
|
||
return undefined
|
||
}
|
||
|
||
// Phase 1: capture current state with a no-op updater so we know which
|
||
// files to back up. Returning the same reference keeps this a true no-op
|
||
// for any wrapper that honors same-ref returns (src/CLAUDE.md wrapper
|
||
// rule). Wrappers that unconditionally spread will trigger one extra
|
||
// re-render; acceptable for a once-per-turn call.
|
||
let captured: FileHistoryState | undefined
|
||
updateFileHistoryState(state => {
|
||
captured = state
|
||
return state
|
||
})
|
||
if (!captured) return // updateFileHistoryState was a no-op stub (e.g. mcp.ts)
|
||
|
||
// Phase 2: do all IO async, outside the updater.
|
||
const trackedFileBackups: Record<string, FileHistoryBackup> = {}
|
||
const mostRecentSnapshot = captured.snapshots.at(-1)
|
||
if (mostRecentSnapshot) {
|
||
logForDebugging(`FileHistory: Making snapshot for message ${messageId}`)
|
||
await Promise.all(
|
||
Array.from(captured.trackedFiles, async trackingPath => {
|
||
try {
|
||
const filePath = maybeExpandFilePath(trackingPath)
|
||
const latestBackup =
|
||
mostRecentSnapshot.trackedFileBackups[trackingPath]
|
||
const nextVersion = latestBackup ? latestBackup.version + 1 : 1
|
||
|
||
// Stat the file once; ENOENT means the tracked file was deleted.
|
||
let fileStats: Stats | undefined
|
||
try {
|
||
fileStats = await stat(filePath)
|
||
} catch (e: unknown) {
|
||
if (!isENOENT(e)) throw e
|
||
}
|
||
|
||
if (!fileStats) {
|
||
trackedFileBackups[trackingPath] = {
|
||
backupFileName: null, // Use null to denote missing tracked file
|
||
version: nextVersion,
|
||
backupTime: new Date(),
|
||
}
|
||
logEvent('tengu_file_history_backup_deleted_file', {
|
||
version: nextVersion,
|
||
})
|
||
logForDebugging(
|
||
`FileHistory: Missing tracked file: ${trackingPath}`,
|
||
)
|
||
return
|
||
}
|
||
|
||
// File exists - check if it needs to be backed up
|
||
if (
|
||
latestBackup &&
|
||
latestBackup.backupFileName !== null &&
|
||
!(await checkOriginFileChanged(
|
||
filePath,
|
||
latestBackup.backupFileName,
|
||
fileStats,
|
||
))
|
||
) {
|
||
// File hasn't been modified since the latest version, reuse it
|
||
trackedFileBackups[trackingPath] = latestBackup
|
||
return
|
||
}
|
||
|
||
// File is newer than the latest backup, create a new backup
|
||
trackedFileBackups[trackingPath] = await createBackup(
|
||
filePath,
|
||
nextVersion,
|
||
)
|
||
} catch (error) {
|
||
logError(error)
|
||
logEvent('tengu_file_history_backup_file_failed', {})
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
|
||
// Phase 3: commit the new snapshot to state. Read state.trackedFiles FRESH
|
||
// — if fileHistoryTrackEdit added a file during phase 2's async window, it
|
||
// wrote the backup to state.snapshots[-1].trackedFileBackups. Inherit those
|
||
// so the new snapshot covers every currently-tracked file.
|
||
updateFileHistoryState((state: FileHistoryState) => {
|
||
try {
|
||
const lastSnapshot = state.snapshots.at(-1)
|
||
if (lastSnapshot) {
|
||
for (const trackingPath of state.trackedFiles) {
|
||
if (trackingPath in trackedFileBackups) continue
|
||
const inherited = lastSnapshot.trackedFileBackups[trackingPath]
|
||
if (inherited) trackedFileBackups[trackingPath] = inherited
|
||
}
|
||
}
|
||
const now = new Date()
|
||
const newSnapshot: FileHistorySnapshot = {
|
||
messageId,
|
||
trackedFileBackups,
|
||
timestamp: now,
|
||
}
|
||
|
||
const allSnapshots = [...state.snapshots, newSnapshot]
|
||
const updatedState: FileHistoryState = {
|
||
...state,
|
||
snapshots:
|
||
allSnapshots.length > MAX_SNAPSHOTS
|
||
? allSnapshots.slice(-MAX_SNAPSHOTS)
|
||
: allSnapshots,
|
||
snapshotSequence: (state.snapshotSequence ?? 0) + 1,
|
||
}
|
||
maybeDumpStateForDebug(updatedState)
|
||
|
||
void notifyVscodeSnapshotFilesUpdated(state, updatedState).catch(logError)
|
||
|
||
// Record the file history snapshot to session storage for resume support
|
||
void recordFileHistorySnapshot(
|
||
messageId,
|
||
newSnapshot,
|
||
false, // isSnapshotUpdate
|
||
).catch(error => {
|
||
logError(new Error(`FileHistory: Failed to record snapshot: ${error}`))
|
||
})
|
||
|
||
logForDebugging(
|
||
`FileHistory: Added snapshot for ${messageId}, tracking ${state.trackedFiles.size} files`,
|
||
)
|
||
logEvent('tengu_file_history_snapshot_success', {
|
||
trackedFilesCount: state.trackedFiles.size,
|
||
snapshotCount: updatedState.snapshots.length,
|
||
})
|
||
|
||
return updatedState
|
||
} catch (error) {
|
||
logError(error)
|
||
logEvent('tengu_file_history_snapshot_failed', {})
|
||
return state
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Rewinds the file system to a previous snapshot.
|
||
*/
|
||
export async function fileHistoryRewind(
|
||
updateFileHistoryState: (
|
||
updater: (prev: FileHistoryState) => FileHistoryState,
|
||
) => void,
|
||
messageId: UUID,
|
||
): Promise<void> {
|
||
if (!fileHistoryEnabled()) {
|
||
return
|
||
}
|
||
|
||
// Rewind is a pure filesystem side-effect and does not mutate
|
||
// FileHistoryState. Capture state with a no-op updater, then do IO async.
|
||
let captured: FileHistoryState | undefined
|
||
updateFileHistoryState(state => {
|
||
captured = state
|
||
return state
|
||
})
|
||
if (!captured) return
|
||
|
||
const targetSnapshot = captured.snapshots.findLast(
|
||
snapshot => snapshot.messageId === messageId,
|
||
)
|
||
if (!targetSnapshot) {
|
||
logError(new Error(`FileHistory: Snapshot for ${messageId} not found`))
|
||
logEvent('tengu_file_history_rewind_failed', {
|
||
trackedFilesCount: captured.trackedFiles.size,
|
||
snapshotFound: false,
|
||
})
|
||
throw new Error('The selected snapshot was not found')
|
||
}
|
||
|
||
try {
|
||
logForDebugging(
|
||
`FileHistory: [Rewind] Rewinding to snapshot for ${messageId}`,
|
||
)
|
||
const filesChanged = await applySnapshot(captured, targetSnapshot)
|
||
|
||
logForDebugging(`FileHistory: [Rewind] Finished rewinding to ${messageId}`)
|
||
logEvent('tengu_file_history_rewind_success', {
|
||
trackedFilesCount: captured.trackedFiles.size,
|
||
filesChangedCount: filesChanged.length,
|
||
})
|
||
} catch (error) {
|
||
logError(error)
|
||
logEvent('tengu_file_history_rewind_failed', {
|
||
trackedFilesCount: captured.trackedFiles.size,
|
||
snapshotFound: true,
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
export function fileHistoryCanRestore(
|
||
state: FileHistoryState,
|
||
messageId: UUID,
|
||
): boolean {
|
||
if (!fileHistoryEnabled()) {
|
||
return false
|
||
}
|
||
|
||
return state.snapshots.some(snapshot => snapshot.messageId === messageId)
|
||
}
|
||
|
||
/**
|
||
* Computes diff stats for a file snapshot by counting the number of files that would be changed
|
||
* if reverting to that snapshot.
|
||
*/
|
||
export async function fileHistoryGetDiffStats(
|
||
state: FileHistoryState,
|
||
messageId: UUID,
|
||
): Promise<DiffStats> {
|
||
if (!fileHistoryEnabled()) {
|
||
return undefined
|
||
}
|
||
|
||
const targetSnapshot = state.snapshots.findLast(
|
||
snapshot => snapshot.messageId === messageId,
|
||
)
|
||
|
||
if (!targetSnapshot) {
|
||
return undefined
|
||
}
|
||
|
||
const results = await Promise.all(
|
||
Array.from(state.trackedFiles, async trackingPath => {
|
||
try {
|
||
const filePath = maybeExpandFilePath(trackingPath)
|
||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||
|
||
const backupFileName: BackupFileName | undefined = targetBackup
|
||
? targetBackup.backupFileName
|
||
: getBackupFileNameFirstVersion(trackingPath, state)
|
||
|
||
if (backupFileName === undefined) {
|
||
// Error resolving the backup, so don't touch the file
|
||
logError(
|
||
new Error('FileHistory: Error finding the backup file to apply'),
|
||
)
|
||
logEvent('tengu_file_history_rewind_restore_file_failed', {
|
||
dryRun: true,
|
||
})
|
||
return null
|
||
}
|
||
|
||
const stats = await computeDiffStatsForFile(
|
||
filePath,
|
||
backupFileName === null ? undefined : backupFileName,
|
||
)
|
||
if (stats?.insertions || stats?.deletions) {
|
||
return { filePath, stats }
|
||
}
|
||
if (backupFileName === null && (await pathExists(filePath))) {
|
||
// Zero-byte file created after snapshot: counts as changed even
|
||
// though diffLines reports 0/0.
|
||
return { filePath, stats }
|
||
}
|
||
return null
|
||
} catch (error) {
|
||
logError(error)
|
||
logEvent('tengu_file_history_rewind_restore_file_failed', {
|
||
dryRun: true,
|
||
})
|
||
return null
|
||
}
|
||
}),
|
||
)
|
||
|
||
const filesChanged: string[] = []
|
||
let insertions = 0
|
||
let deletions = 0
|
||
for (const r of results) {
|
||
if (!r) continue
|
||
filesChanged.push(r.filePath)
|
||
insertions += r.stats?.insertions || 0
|
||
deletions += r.stats?.deletions || 0
|
||
}
|
||
return { filesChanged, insertions, deletions }
|
||
}
|
||
|
||
/**
|
||
* Lightweight boolean-only check: would rewinding to this message change any
|
||
* file on disk? Uses the same stat/content comparison as the non-dry-run path
|
||
* of applySnapshot (checkOriginFileChanged) instead of computeDiffStatsForFile,
|
||
* so it never calls diffLines. Early-exits on the first changed file. Use when
|
||
* the caller only needs a yes/no answer; fileHistoryGetDiffStats remains for
|
||
* callers that display insertions/deletions.
|
||
*/
|
||
export async function fileHistoryHasAnyChanges(
|
||
state: FileHistoryState,
|
||
messageId: UUID,
|
||
): Promise<boolean> {
|
||
if (!fileHistoryEnabled()) {
|
||
return false
|
||
}
|
||
|
||
const targetSnapshot = state.snapshots.findLast(
|
||
snapshot => snapshot.messageId === messageId,
|
||
)
|
||
if (!targetSnapshot) {
|
||
return false
|
||
}
|
||
|
||
for (const trackingPath of state.trackedFiles) {
|
||
try {
|
||
const filePath = maybeExpandFilePath(trackingPath)
|
||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||
const backupFileName: BackupFileName | undefined = targetBackup
|
||
? targetBackup.backupFileName
|
||
: getBackupFileNameFirstVersion(trackingPath, state)
|
||
|
||
if (backupFileName === undefined) {
|
||
continue
|
||
}
|
||
if (backupFileName === null) {
|
||
// Backup says file did not exist; probe via stat (operate-then-catch).
|
||
if (await pathExists(filePath)) return true
|
||
continue
|
||
}
|
||
if (await checkOriginFileChanged(filePath, backupFileName)) return true
|
||
} catch (error) {
|
||
logError(error)
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Applies the given file snapshot state to the tracked files (writes/deletes
|
||
* on disk), returning the list of changed file paths. Async IO only.
|
||
*/
|
||
async function applySnapshot(
|
||
state: FileHistoryState,
|
||
targetSnapshot: FileHistorySnapshot,
|
||
): Promise<string[]> {
|
||
const filesChanged: string[] = []
|
||
for (const trackingPath of state.trackedFiles) {
|
||
try {
|
||
const filePath = maybeExpandFilePath(trackingPath)
|
||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||
|
||
const backupFileName: BackupFileName | undefined = targetBackup
|
||
? targetBackup.backupFileName
|
||
: getBackupFileNameFirstVersion(trackingPath, state)
|
||
|
||
if (backupFileName === undefined) {
|
||
// Error resolving the backup, so don't touch the file
|
||
logError(
|
||
new Error('FileHistory: Error finding the backup file to apply'),
|
||
)
|
||
logEvent('tengu_file_history_rewind_restore_file_failed', {
|
||
dryRun: false,
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (backupFileName === null) {
|
||
// File did not exist at the target version; delete it if present.
|
||
try {
|
||
await unlink(filePath)
|
||
logForDebugging(`FileHistory: [Rewind] Deleted ${filePath}`)
|
||
filesChanged.push(filePath)
|
||
} catch (e: unknown) {
|
||
if (!isENOENT(e)) throw e
|
||
// Already absent; nothing to do.
|
||
}
|
||
continue
|
||
}
|
||
|
||
// File should exist at a specific version. Restore only if it differs.
|
||
if (await checkOriginFileChanged(filePath, backupFileName)) {
|
||
await restoreBackup(filePath, backupFileName)
|
||
logForDebugging(
|
||
`FileHistory: [Rewind] Restored ${filePath} from ${backupFileName}`,
|
||
)
|
||
filesChanged.push(filePath)
|
||
}
|
||
} catch (error) {
|
||
logError(error)
|
||
logEvent('tengu_file_history_rewind_restore_file_failed', {
|
||
dryRun: false,
|
||
})
|
||
}
|
||
}
|
||
return filesChanged
|
||
}
|
||
|
||
/**
|
||
* Checks if the original file has been changed compared to the backup file.
|
||
* Optionally reuses a pre-fetched stat for the original file (when the caller
|
||
* already stat'd it to check existence, we avoid a second syscall).
|
||
*
|
||
* Exported for testing.
|
||
*/
|
||
export async function checkOriginFileChanged(
|
||
originalFile: string,
|
||
backupFileName: string,
|
||
originalStatsHint?: Stats,
|
||
): Promise<boolean> {
|
||
const backupPath = resolveBackupPath(backupFileName)
|
||
|
||
let originalStats: Stats | null = originalStatsHint ?? null
|
||
if (!originalStats) {
|
||
try {
|
||
originalStats = await stat(originalFile)
|
||
} catch (e: unknown) {
|
||
if (!isENOENT(e)) return true
|
||
}
|
||
}
|
||
let backupStats: Stats | null = null
|
||
try {
|
||
backupStats = await stat(backupPath)
|
||
} catch (e: unknown) {
|
||
if (!isENOENT(e)) return true
|
||
}
|
||
|
||
return compareStatsAndContent(originalStats, backupStats, async () => {
|
||
try {
|
||
const [originalContent, backupContent] = await Promise.all([
|
||
readFile(originalFile, 'utf-8'),
|
||
readFile(backupPath, 'utf-8'),
|
||
])
|
||
return originalContent !== backupContent
|
||
} catch {
|
||
// File deleted between stat and read -> treat as changed.
|
||
return true
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Shared stat/content comparison logic for sync and async change checks.
|
||
* Returns true if the file has changed relative to the backup.
|
||
*/
|
||
function compareStatsAndContent<T extends boolean | Promise<boolean>>(
|
||
originalStats: Stats | null,
|
||
backupStats: Stats | null,
|
||
compareContent: () => T,
|
||
): T | boolean {
|
||
// One exists, one missing -> changed
|
||
if ((originalStats === null) !== (backupStats === null)) {
|
||
return true
|
||
}
|
||
// Both missing -> no change
|
||
if (originalStats === null || backupStats === null) {
|
||
return false
|
||
}
|
||
|
||
// Check file stats like permission and file size
|
||
if (
|
||
originalStats.mode !== backupStats.mode ||
|
||
originalStats.size !== backupStats.size
|
||
) {
|
||
return true
|
||
}
|
||
|
||
// This is an optimization that depends on the correct setting of the modified
|
||
// time. If the original file's modified time was before the backup time, then
|
||
// we can skip the file content comparison.
|
||
if (originalStats.mtimeMs < backupStats.mtimeMs) {
|
||
return false
|
||
}
|
||
|
||
// Use the more expensive file content comparison. The callback handles its
|
||
// own read errors — a try/catch here is dead for async callbacks anyway.
|
||
return compareContent()
|
||
}
|
||
|
||
/**
|
||
* Computes the number of lines changed in the diff.
|
||
*/
|
||
async function computeDiffStatsForFile(
|
||
originalFile: string,
|
||
backupFileName?: string,
|
||
): Promise<DiffStats> {
|
||
const filesChanged: string[] = []
|
||
let insertions = 0
|
||
let deletions = 0
|
||
try {
|
||
const backupPath = backupFileName
|
||
? resolveBackupPath(backupFileName)
|
||
: undefined
|
||
|
||
const [originalContent, backupContent] = await Promise.all([
|
||
readFileAsyncOrNull(originalFile),
|
||
backupPath ? readFileAsyncOrNull(backupPath) : null,
|
||
])
|
||
|
||
if (originalContent === null && backupContent === null) {
|
||
return {
|
||
filesChanged,
|
||
insertions,
|
||
deletions,
|
||
}
|
||
}
|
||
|
||
filesChanged.push(originalFile)
|
||
|
||
// Compute the diff
|
||
const changes = diffLines(originalContent ?? '', backupContent ?? '')
|
||
changes.forEach(c => {
|
||
if (c.added) {
|
||
insertions += c.count || 0
|
||
}
|
||
if (c.removed) {
|
||
deletions += c.count || 0
|
||
}
|
||
})
|
||
} catch (error) {
|
||
logError(new Error(`FileHistory: Error generating diffStats: ${error}`))
|
||
}
|
||
|
||
return {
|
||
filesChanged,
|
||
insertions,
|
||
deletions,
|
||
}
|
||
}
|
||
|
||
function getBackupFileName(filePath: string, version: number): string {
|
||
const fileNameHash = createHash('sha256')
|
||
.update(filePath)
|
||
.digest('hex')
|
||
.slice(0, 16)
|
||
return `${fileNameHash}@v${version}`
|
||
}
|
||
|
||
function resolveBackupPath(backupFileName: string, sessionId?: string): string {
|
||
const configDir = getClaudeConfigHomeDir()
|
||
return join(
|
||
configDir,
|
||
'file-history',
|
||
sessionId || getSessionId(),
|
||
backupFileName,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Creates a backup of the file at filePath. If the file does not exist
|
||
* (ENOENT), records a null backup (file-did-not-exist marker). All IO is
|
||
* async. Lazy mkdir: tries copyFile first, creates the directory on ENOENT.
|
||
*/
|
||
async function createBackup(
|
||
filePath: string | null,
|
||
version: number,
|
||
): Promise<FileHistoryBackup> {
|
||
if (filePath === null) {
|
||
return { backupFileName: null, version, backupTime: new Date() }
|
||
}
|
||
|
||
const backupFileName = getBackupFileName(filePath, version)
|
||
const backupPath = resolveBackupPath(backupFileName)
|
||
|
||
// Stat first: if the source is missing, record a null backup and skip the
|
||
// copy. Separates "source missing" from "backup dir missing" cleanly —
|
||
// sharing a catch for both meant a file deleted between copyFile-success
|
||
// and stat would leave an orphaned backup with a null state record.
|
||
let srcStats: Stats
|
||
try {
|
||
srcStats = await stat(filePath)
|
||
} catch (e: unknown) {
|
||
if (isENOENT(e)) {
|
||
return { backupFileName: null, version, backupTime: new Date() }
|
||
}
|
||
throw e
|
||
}
|
||
|
||
// copyFile preserves content and avoids reading the whole file into the JS
|
||
// heap (which the previous readFileSync+writeFileSync pipeline did, OOMing
|
||
// on large tracked files). Lazy mkdir: 99% of calls hit the fast path
|
||
// (directory already exists); on ENOENT, mkdir then retry.
|
||
try {
|
||
await copyFile(filePath, backupPath)
|
||
} catch (e: unknown) {
|
||
if (!isENOENT(e)) throw e
|
||
await mkdir(dirname(backupPath), { recursive: true })
|
||
await copyFile(filePath, backupPath)
|
||
}
|
||
|
||
// Preserve file permissions on the backup.
|
||
await chmod(backupPath, srcStats.mode)
|
||
|
||
logEvent('tengu_file_history_backup_file_created', {
|
||
version: version,
|
||
fileSize: srcStats.size,
|
||
})
|
||
|
||
return {
|
||
backupFileName,
|
||
version,
|
||
backupTime: new Date(),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Restores a file from its backup path with proper directory creation and permissions.
|
||
* Lazy mkdir: tries copyFile first, creates the directory on ENOENT.
|
||
*/
|
||
async function restoreBackup(
|
||
filePath: string,
|
||
backupFileName: string,
|
||
): Promise<void> {
|
||
const backupPath = resolveBackupPath(backupFileName)
|
||
|
||
// Stat first: if the backup is missing, log and bail before attempting
|
||
// the copy. Separates "backup missing" from "destination dir missing".
|
||
let backupStats: Stats
|
||
try {
|
||
backupStats = await stat(backupPath)
|
||
} catch (e: unknown) {
|
||
if (isENOENT(e)) {
|
||
logEvent('tengu_file_history_rewind_restore_file_failed', {})
|
||
logError(
|
||
new Error(`FileHistory: [Rewind] Backup file not found: ${backupPath}`),
|
||
)
|
||
return
|
||
}
|
||
throw e
|
||
}
|
||
|
||
// Lazy mkdir: 99% of calls hit the fast path (destination dir exists).
|
||
try {
|
||
await copyFile(backupPath, filePath)
|
||
} catch (e: unknown) {
|
||
if (!isENOENT(e)) throw e
|
||
await mkdir(dirname(filePath), { recursive: true })
|
||
await copyFile(backupPath, filePath)
|
||
}
|
||
|
||
// Restore the file permissions
|
||
await chmod(filePath, backupStats.mode)
|
||
}
|
||
|
||
/**
|
||
* Gets the first (earliest) backup version for a file, used when rewinding
|
||
* to a target backup point where the file has not been tracked yet.
|
||
*
|
||
* @returns The backup file name for the first version, or null if the file
|
||
* did not exist in the first version, or undefined if we cannot find a
|
||
* first version at all
|
||
*/
|
||
function getBackupFileNameFirstVersion(
|
||
trackingPath: string,
|
||
state: FileHistoryState,
|
||
): BackupFileName | undefined {
|
||
for (const snapshot of state.snapshots) {
|
||
const backup = snapshot.trackedFileBackups[trackingPath]
|
||
if (backup !== undefined && backup.version === 1) {
|
||
// This can be either a file name or null, with null meaning the file
|
||
// did not exist in the first version.
|
||
return backup.backupFileName
|
||
}
|
||
}
|
||
|
||
// The undefined means there was an error resolving the first version.
|
||
return undefined
|
||
}
|
||
|
||
/**
|
||
* Use the relative path as the key to reduce session storage space for tracking.
|
||
*/
|
||
function maybeShortenFilePath(filePath: string): string {
|
||
if (!isAbsolute(filePath)) {
|
||
return filePath
|
||
}
|
||
const cwd = getOriginalCwd()
|
||
if (filePath.startsWith(cwd)) {
|
||
return relative(cwd, filePath)
|
||
}
|
||
return filePath
|
||
}
|
||
|
||
function maybeExpandFilePath(filePath: string): string {
|
||
if (isAbsolute(filePath)) {
|
||
return filePath
|
||
}
|
||
return join(getOriginalCwd(), filePath)
|
||
}
|
||
|
||
/**
|
||
* Restores file history snapshot state for a given log option.
|
||
*/
|
||
export function fileHistoryRestoreStateFromLog(
|
||
fileHistorySnapshots: FileHistorySnapshot[],
|
||
onUpdateState: (newState: FileHistoryState) => void,
|
||
): void {
|
||
if (!fileHistoryEnabled()) {
|
||
return
|
||
}
|
||
// Make a copy of the snapshots as we migrate from absolute path to
|
||
// shortened relative tracking path.
|
||
const snapshots: FileHistorySnapshot[] = []
|
||
// Rebuild the tracked files from the snapshots
|
||
const trackedFiles = new Set<string>()
|
||
for (const snapshot of fileHistorySnapshots) {
|
||
const trackedFileBackups: Record<string, FileHistoryBackup> = {}
|
||
for (const [path, backup] of Object.entries(snapshot.trackedFileBackups)) {
|
||
const trackingPath = maybeShortenFilePath(path)
|
||
trackedFiles.add(trackingPath)
|
||
trackedFileBackups[trackingPath] = backup
|
||
}
|
||
snapshots.push({
|
||
...snapshot,
|
||
trackedFileBackups: trackedFileBackups,
|
||
})
|
||
}
|
||
onUpdateState({
|
||
snapshots: snapshots,
|
||
trackedFiles: trackedFiles,
|
||
snapshotSequence: snapshots.length,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Copy file history snapshots for a given log option.
|
||
*/
|
||
export async function copyFileHistoryForResume(log: LogOption): Promise<void> {
|
||
if (!fileHistoryEnabled()) {
|
||
return
|
||
}
|
||
|
||
const fileHistorySnapshots = log.fileHistorySnapshots
|
||
if (!fileHistorySnapshots || log.messages.length === 0) {
|
||
return
|
||
}
|
||
const lastMessage = log.messages[log.messages.length - 1]
|
||
const previousSessionId = lastMessage?.sessionId
|
||
if (!previousSessionId) {
|
||
logError(
|
||
new Error(
|
||
`FileHistory: Failed to copy backups on restore (no previous session id)`,
|
||
),
|
||
)
|
||
return
|
||
}
|
||
|
||
const sessionId = getSessionId()
|
||
if (previousSessionId === sessionId) {
|
||
logForDebugging(
|
||
`FileHistory: No need to copy file history for resuming with same session id: ${sessionId}`,
|
||
)
|
||
return
|
||
}
|
||
|
||
try {
|
||
// All backups share the same directory: {configDir}/file-history/{sessionId}/
|
||
// Create it once upfront instead of once per backup file
|
||
const newBackupDir = join(
|
||
getClaudeConfigHomeDir(),
|
||
'file-history',
|
||
sessionId,
|
||
)
|
||
await mkdir(newBackupDir, { recursive: true })
|
||
|
||
// Migrate all backup files from the previous session to current session.
|
||
// Process all snapshots in parallel; within each snapshot, links also run in parallel.
|
||
let failedSnapshots = 0
|
||
await Promise.allSettled(
|
||
fileHistorySnapshots.map(async snapshot => {
|
||
const backupEntries = Object.values(snapshot.trackedFileBackups).filter(
|
||
(backup): backup is typeof backup & { backupFileName: string } =>
|
||
backup.backupFileName !== null,
|
||
)
|
||
|
||
const results = await Promise.allSettled(
|
||
backupEntries.map(async ({ backupFileName }) => {
|
||
const oldBackupPath = resolveBackupPath(
|
||
backupFileName,
|
||
previousSessionId,
|
||
)
|
||
const newBackupPath = join(newBackupDir, backupFileName)
|
||
|
||
try {
|
||
await link(oldBackupPath, newBackupPath)
|
||
} catch (e: unknown) {
|
||
const code = getErrnoCode(e)
|
||
if (code === 'EEXIST') {
|
||
// Already migrated, skip
|
||
return
|
||
}
|
||
if (code === 'ENOENT') {
|
||
logError(
|
||
new Error(
|
||
`FileHistory: Failed to copy backup ${backupFileName} on restore (backup file does not exist in ${previousSessionId})`,
|
||
),
|
||
)
|
||
throw e
|
||
}
|
||
logError(
|
||
new Error(
|
||
`FileHistory: Error hard linking backup file from previous session`,
|
||
),
|
||
)
|
||
// Fallback to copy if hard link fails
|
||
try {
|
||
await copyFile(oldBackupPath, newBackupPath)
|
||
} catch (copyErr) {
|
||
logError(
|
||
new Error(
|
||
`FileHistory: Error copying over backup from previous session`,
|
||
),
|
||
)
|
||
throw copyErr
|
||
}
|
||
}
|
||
|
||
logForDebugging(
|
||
`FileHistory: Copied backup ${backupFileName} from session ${previousSessionId} to ${sessionId}`,
|
||
)
|
||
}),
|
||
)
|
||
|
||
const copyFailed = results.some(r => r.status === 'rejected')
|
||
|
||
// Record the snapshot only if we have successfully migrated the backup files
|
||
if (!copyFailed) {
|
||
void recordFileHistorySnapshot(
|
||
snapshot.messageId,
|
||
snapshot,
|
||
false, // isSnapshotUpdate
|
||
).catch(_ => {
|
||
logError(
|
||
new Error(`FileHistory: Failed to record copy backup snapshot`),
|
||
)
|
||
})
|
||
} else {
|
||
failedSnapshots++
|
||
}
|
||
}),
|
||
)
|
||
|
||
if (failedSnapshots > 0) {
|
||
logEvent('tengu_file_history_resume_copy_failed', {
|
||
numSnapshots: fileHistorySnapshots.length,
|
||
failedSnapshots,
|
||
})
|
||
}
|
||
} catch (error) {
|
||
logError(error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Notifies VSCode about files that have changed between snapshots.
|
||
* Compares the previous snapshot with the new snapshot and sends file_updated
|
||
* notifications for any files whose content has changed.
|
||
* Fire-and-forget (void-dispatched from fileHistoryMakeSnapshot).
|
||
*/
|
||
async function notifyVscodeSnapshotFilesUpdated(
|
||
oldState: FileHistoryState,
|
||
newState: FileHistoryState,
|
||
): Promise<void> {
|
||
const oldSnapshot = oldState.snapshots.at(-1)
|
||
const newSnapshot = newState.snapshots.at(-1)
|
||
|
||
if (!newSnapshot) {
|
||
return
|
||
}
|
||
|
||
for (const trackingPath of newState.trackedFiles) {
|
||
const filePath = maybeExpandFilePath(trackingPath)
|
||
const oldBackup = oldSnapshot?.trackedFileBackups[trackingPath]
|
||
const newBackup = newSnapshot.trackedFileBackups[trackingPath]
|
||
|
||
// Skip if both backups reference the same version (no change)
|
||
if (
|
||
oldBackup?.backupFileName === newBackup?.backupFileName &&
|
||
oldBackup?.version === newBackup?.version
|
||
) {
|
||
continue
|
||
}
|
||
|
||
// Get old content from the previous backup
|
||
let oldContent: string | null = null
|
||
if (oldBackup?.backupFileName) {
|
||
const backupPath = resolveBackupPath(oldBackup.backupFileName)
|
||
oldContent = await readFileAsyncOrNull(backupPath)
|
||
}
|
||
|
||
// Get new content from the new backup or current file
|
||
let newContent: string | null = null
|
||
if (newBackup?.backupFileName) {
|
||
const backupPath = resolveBackupPath(newBackup.backupFileName)
|
||
newContent = await readFileAsyncOrNull(backupPath)
|
||
}
|
||
// If newBackup?.backupFileName === null, the file was deleted; newContent stays null.
|
||
|
||
// Only notify if content actually changed
|
||
if (oldContent !== newContent) {
|
||
notifyVscodeFileUpdated(filePath, oldContent, newContent)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Async read that swallows all errors and returns null (best-effort). */
|
||
async function readFileAsyncOrNull(path: string): Promise<string | null> {
|
||
try {
|
||
return await readFile(path, 'utf-8')
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
const ENABLE_DUMP_STATE = false
|
||
function maybeDumpStateForDebug(state: FileHistoryState): void {
|
||
if (ENABLE_DUMP_STATE) {
|
||
console.error(inspect(state, false, 5))
|
||
}
|
||
}
|