(
updateFileHistoryState: (
updater: (prev: FileHistoryState) => FileHistoryState,
) => void,
messageId: UUID,
)
| 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 |
no test coverage detected