Claude Code 源码解析(02)Checkpoint Snapshot:Rewind 如何同时回退对话和代码
这篇源码解析基于本地 Claude Code 源码,拆解 /rewind 与 /checkpoint 背后的 file-history snapshot:什么时候创建检查点,文件旧版本如何备份到 ~/.claude/file-history,metadata 如何写入 transcript,以及恢复时为什么对话回退和代码回滚是两条并行路径。
- 聚焦
- Checkpoint / snapshot 机制:/rewind 与 /checkpoint 入口、file-history snapshot 创建、备份存储、对话回退、代码恢复、resume 迁移与边界
- 源码
- assets/projects/claude-code
- 版本
- 4b9d30f
先给结论
Claude Code 的 checkpoint 不是 git commit,也不是整个工作区的全量快照。源码里的主路径叫 Rewind:/rewind 打开消息选择器,/checkpoint 只是它的 alias;用户选中某条历史 user message 后,可以恢复 conversation、恢复 code,或者两者一起恢复。
默认路径
交互式会话默认启用 file checkpointing。每个可选择的 user message 会成为一个逻辑 checkpoint;文件工具写入前把旧文件内容补进最近的 snapshot。
可选路径
CLI 的 --rewind-files 和 SDK control 的 rewind_files 只做文件恢复;SDK/non-interactive 场景还受显式开关限制,不等同完整 UI Rewind。
先排除同名误解
Claude Code 源码里有多个 snapshot 语境:shell snapshot、context collapse snapshot、agent memory snapshot、attribution snapshot。checkpoint/Rewind 这条线讲的是 utils/fileHistory.ts 里的 file-history snapshot,它的目标是恢复会话内由 Claude 工具追踪到的文件状态。
1// assets/projects/claude-code/src/commands/rewind/index.ts2const rewind = {3 description: `Restore the code and/or conversation to a previous point`,4 name: 'rewind',5 aliases: ['checkpoint'],6 type: 'local',7 load: () => import('./rewind.js'),8}这段说明 checkpoint 没有独立实现,它只是 rewind 的别名。真正命令实现也很薄:它不恢复文件,只把 UI 打开。
1// assets/projects/claude-code/src/commands/rewind/rewind.ts2export async function call(_args: string, context: ToolUseContext) {3 if (context.openMessageSelector) {4 context.openMessageSelector()5 }6 return { type: 'skip' }7}核心证据集中在这些文件:
1# 1 assets/projects/claude-code/src/commands/rewind/index.ts2# 2 assets/projects/claude-code/src/commands/rewind/rewind.ts3# 3 assets/projects/claude-code/src/components/MessageSelector.tsx4# 4 assets/projects/claude-code/src/utils/handlePromptSubmit.ts5# 5 assets/projects/claude-code/src/QueryEngine.ts6# 6 assets/projects/claude-code/src/utils/fileHistory.ts7# 7 assets/projects/claude-code/src/utils/sessionStorage.ts8# 8 assets/projects/claude-code/src/hooks/useFileHistorySnapshotInit.ts9# 9 assets/projects/claude-code/src/screens/REPL.tsx10# 10 assets/projects/claude-code/src/cli/print.ts11# 11 assets/projects/claude-code/src/entrypoints/sdk/controlSchemas.ts宏观架构
Rewind 不是一个统一事务,而是一个 UI 入口背后的两套恢复动作。MessageSelector 收到 onRestoreMessage 和 onRestoreCode 两个回调;conversation 恢复由 REPL 层处理,code 恢复由 file history 处理。
命令只打开选择器,不直接恢复文件。
用户选择要回到哪条 user message 之前。
fileHistoryGetDiffStats 根据目标 snapshot 预览文件变化。
用户可以只恢复代码、只恢复对话,或两者一起恢复。
恢复后当前会话从这个分叉状态继续运行。
什么时候建立 checkpoint
file-history snapshot 的第一步发生在用户消息进入系统时,而不是工具写完文件之后。handlePromptSubmit.ts 会把新进来的 selectable user message 变成 checkpoint 坐标。
1// assets/projects/claude-code/src/utils/handlePromptSubmit.ts2newMessages.filter(selectableUserMessagesFilter).forEach(message => {3 void fileHistoryMakeSnapshot(4 updater => setAppState(prev => ({5 ...prev,6 fileHistory: updater(prev.fileHistory),7 })),8 message.uuid,9 )10})SDK query loop 也有同一套逻辑。只要启用了 file history 且持久化 session,QueryEngine 会为用户输入里的 selectable user message 建 snapshot。
1// assets/projects/claude-code/src/QueryEngine.ts2if (fileHistoryEnabled() && persistSession) {3 messagesFromUserInput4 .filter(messageSelector().selectableUserMessagesFilter)5 .forEach(message => {6 void fileHistoryMakeSnapshot(updateFileHistoryState, message.uuid)7 })8}这一步的状态变化很小:它只是创建一个带 messageId 的空或半空 snapshot。它真正的意义是给后续文件备份提供“回到哪条用户请求之前”的坐标。用户不会说“回到 tool_use_abc 之前”,用户会说“回到我让你改登录页之前”。
snapshot 里存什么
FileHistorySnapshot 不保存真实文件内容。它保存 messageId、路径到 backup 的映射,以及时间戳。真实内容被写到 file-history 目录,snapshot 只是索引。
1// assets/projects/claude-code/src/utils/fileHistory.ts2export type FileHistorySnapshot = {3 messageId: UUID4 trackedFileBackups: Record<string, FileHistoryBackup>5 timestamp: Date6}7 8export type FileHistoryBackup = {9 backupFileName: string | null10 version: number11 backupTime: Date12}backupFileName: null 是一个重要状态,不是缺失值。它表示目标 checkpoint 时这个文件不存在。恢复到这个 snapshot 时,当前文件应被删除;如果没有这个状态,系统只能恢复已有文件,无法正确撤销“新建文件”。
文件旧版本怎么捕获
备份必须发生在工具写文件之前。FileWriteTool、FileEditTool、NotebookEditTool 都会在真正写入前调用 fileHistoryTrackEdit(),把当前磁盘上的旧内容记录到当前 checkpoint。
1// assets/projects/claude-code/src/tools/FileEditTool/FileEditTool.ts2if (fileHistoryEnabled()) {3 await fileHistoryTrackEdit(4 updateFileHistoryState,5 absoluteFilePath,6 parentMessage.uuid,7 )8}这个顺序解释了为什么 checkpoint 可以恢复到“Claude 修改之前”:不是在修改后猜测旧内容,而是在写入发生前先把旧内容存成 backup blob,再让工具继续执行。
Bash 的情况更窄。源码有 simulated sed edit 这类可追踪路径,但普通 shell 命令的任意副作用无法被 file history 完整建模。
1// assets/projects/claude-code/src/tools/BashTool/BashTool.tsx2if (fileHistoryEnabled() && parentMessage) {3 await fileHistoryTrackEdit(4 toolUseContext.updateFileHistoryState,5 absoluteFilePath,6 parentMessage.uuid,7 )8}metadata 和 blob 分离
Claude Code 没有把大文件内容塞进 transcript。它把 snapshot metadata 写成 file-history-snapshot entry,把真实文件备份放到 ~/.claude/file-history/<sessionId>/。
1// assets/projects/claude-code/src/utils/sessionStorage.ts2async insertFileHistorySnapshot(messageId, snapshot, isSnapshotUpdate) {3 const fileHistoryMessage = {4 type: 'file-history-snapshot',5 messageId,6 snapshot,7 isSnapshotUpdate,8 }9 await this.appendEntry(fileHistoryMessage)10}真实备份路径由 resolveBackupPath() 决定。sessionId 是路径的一部分,所以 resume 到新 session 时还要处理备份迁移。
1// assets/projects/claude-code/src/utils/fileHistory.ts2function resolveBackupPath(backupFileName: string, sessionId?: string): string {3 return join(4 getClaudeConfigHomeDir(),5 'file-history',6 sessionId || getSessionId(),7 backupFileName,8 )9}这个拆分解决了两个问题:transcript 保持可追加、可恢复;文件内容不用经过对话日志序列化。代价是 session 恢复时既要重建 metadata chain,也要保证备份 blob 能在新 session 下找到。
snapshot 如何更新
同一个 messageId 可能会被多次更新,因为工具执行和 snapshot 创建之间存在异步窗口。sessionStorage.ts 对 isSnapshotUpdate 做了替换语义:同一 messageId 的旧 snapshot 会被新 snapshot 覆盖。
1// assets/projects/claude-code/src/utils/sessionStorage.ts2const existingIndex = isSnapshotUpdate3 ? indexByMessageId.get(snapshot.messageId)4 : undefined5if (existingIndex === undefined) {6 indexByMessageId.set(snapshot.messageId, snapshots.length)7 snapshots.push(snapshot)8} else {9 snapshots[existingIndex] = snapshot10}这不是冲突合并算法,而是“同一逻辑 checkpoint 的最新索引 wins”。如果一个工具在 snapshot 创建后补进了新的 tracked file,更新后的 snapshot 会替换旧版本,避免 resume 后少一部分文件映射。
恢复前还有 dry-run 语义:fileHistoryGetDiffStats() 会比较当前文件和目标 backup,向 UI 展示哪些文件会改、插入删除大概是多少。
1// assets/projects/claude-code/src/utils/fileHistory.ts2const stats = await computeDiffStatsForFile(3 filePath,4 backupFileName === null ? undefined : backupFileName,5)6if (stats?.insertions || stats?.deletions) {7 return { filePath, stats }8}resume 后怎么找回 checkpoint
恢复能力不能只存在内存里。resume 时,session loader 会从 transcript 里收集 file-history-snapshot entry,构造 snapshot chain,再在 REPL 初始化时写回 FileHistoryState。
1// assets/projects/claude-code/src/hooks/useFileHistorySnapshotInit.ts2if (initialFileHistorySnapshots) {3 fileHistoryRestoreStateFromLog(initialFileHistorySnapshots, onUpdateState)4}如果 resume 之后 sessionId 变了,原 session 下的 backup blob 需要被迁移到当前 session 的 file-history 目录。copyFileHistoryForResume() 会把旧备份 hard link 或 copy 过去,让后续 resolveBackupPath() 能在当前 sessionId 下找到文件。
1// assets/projects/claude-code/src/utils/fileHistory.ts2const newBackupDir = join(3 getClaudeConfigHomeDir(),4 'file-history',5 sessionId,6)7await mkdir(newBackupDir, { recursive: true })恢复 metadata
transcript 里的 file-history-snapshot entry 重建 messageId -> trackedFileBackups 的 snapshot chain。
恢复 backup blob
旧 session 的备份文件被 hard link 或 copy 到当前 sessionId 下,保证 applySnapshot 可以找到旧内容。
恢复代码怎么执行
代码恢复入口是 fileHistoryRewind()。它按 messageId 找最近的目标 snapshot,然后交给 applySnapshot()。
1// assets/projects/claude-code/src/utils/fileHistory.ts2export async function fileHistoryRewind(updateFileHistoryState, messageId) {3 const targetSnapshot = captured.snapshots.findLast(4 snapshot => snapshot.messageId === messageId,5 )6 if (!targetSnapshot) throw new Error('The selected snapshot was not found')7 await applySnapshot(captured, targetSnapshot)8}applySnapshot() 对每个 tracked file 做三类动作:如果 backupFileName 是 null,删除当前文件;如果 backup 存在且当前内容不同,复制备份回原路径;如果没有变化,跳过。
1// assets/projects/claude-code/src/utils/fileHistory.ts2if (backupFileName === null) {3 await unlink(filePath)4 filesChanged.push(filePath)5 continue6}7 8if (await checkOriginFileChanged(filePath, backupFileName)) {9 await restoreBackup(filePath, backupFileName)10 filesChanged.push(filePath)11}这条路径只处理被 tracked 的文件。它不会撤销 git index、数据库、后台进程、外部编辑器保存的文件,也不会理解工具之外的任意文件系统副作用。
恢复对话怎么执行
REPL 层把 MessageSelector 的两个恢复动作分开传入。代码恢复调用 fileHistoryRewind();对话恢复调用 handleRestoreMessage,语义上是把当前 conversation 恢复到选中 user message 之前的状态。
1// assets/projects/claude-code/src/screens/REPL.tsx2<MessageSelector3 onRestoreCode={async (message: UserMessage) => {4 await fileHistoryRewind(updateFileHistoryState, message.uuid)5 }}6 onRestoreMessage={handleRestoreMessage}7/>这解释了 UI 为什么有三个选项:Restore code and conversation、Restore conversation、Restore code。它们不是同一个事务的不同名字,而是两个状态面的组合。只恢复 conversation 时,磁盘文件不回滚;只恢复 code 时,对话历史不截断。
Headless 与 SDK 路径
CLI 暴露了隐藏参数 --rewind-files,但它要求 --resume,且不能和 prompt 同时使用。原因是这条路径只做文件恢复,不启动一次新的对话推理。
1// assets/projects/claude-code/src/cli/print.ts2if (options.rewindFiles && !options.resume) {3 process.stderr.write(`Error: --rewind-files requires --resume\n`)4}5 6if (options.rewindFiles && inputPrompt) {7 process.stderr.write(8 `Error: --rewind-files is a standalone operation and cannot be used with a prompt\n`,9 )10}SDK control schema 也有 rewind_files,并支持 dry_run。这适合自动化场景先拿 diff,再决定是否恢复。
1// assets/projects/claude-code/src/entrypoints/sdk/controlSchemas.ts2export const SDKControlRewindFilesRequestSchema = lazySchema(() =>3 z.object({4 subtype: z.literal('rewind_files'),5 user_message_id: z.string(),6 dry_run: z.boolean().optional(),7 })8)fileHistoryMakeSnapshot 绑定 user message uuid。
fileHistoryTrackEdit 在文件工具写入前保存旧内容。
sessionStorage 追加 file-history-snapshot entry。
真实文件内容按 sessionId 存到 ~/.claude/file-history。
resume 时重建 FileHistoryState,并迁移旧 session 的 backup。
目标 snapshot 指导复制旧文件或删除新文件。
可复用设计
这个机制最值得复用的不是“做撤销按钮”,而是四个工程选择。第一,checkpoint 绑定用户消息,而不是绑定工具调用,因为用户理解的是“回到我提出某个请求之前”。第二,写前备份旧内容,避免修改后再猜测旧状态。第三,metadata 和 blob 分离,让 transcript 不被大文件撑爆。第四,恢复前做 diff preview,把回滚变成可确认动作。
边界
Rewind 的边界也很硬:它不是 git 回滚,不是完整工作区快照,不保证覆盖手工编辑、普通 bash 副作用、数据库写入、网络资源或后台进程状态。它恢复的是 Claude Code file-history 协议能追踪到的文件,以及 REPL 当前 conversation 状态。
