claude-code · Agent 架构 · 2026-06-06

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,或者两者一起恢复。

Checkpoint snapshot 全景:命令入口只打开 Rewind UI,真正恢复分成 conversation 和 file history 两条路径。
Checkpoint snapshot 全景:命令入口只打开 Rewind UI,真正恢复分成 conversation 和 file history 两条路径。

默认路径

交互式会话默认启用 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 工具追踪到的文件状态。

ts
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 打开。

ts
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}

核心证据集中在这些文件:

text
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 收到 onRestoreMessageonRestoreCode 两个回调;conversation 恢复由 REPL 层处理,code 恢复由 file history 处理。

恢复不是一个统一事务:conversation rewind 和 code rewind 是两条可以单独执行的路径。
恢复不是一个统一事务:conversation rewind 和 code rewind 是两条可以单独执行的路径。
1/rewind 或 /checkpoint

命令只打开选择器,不直接恢复文件。

2MessageSelector 选 user message

用户选择要回到哪条 user message 之前。

3file history 计算 diff

fileHistoryGetDiffStats 根据目标 snapshot 预览文件变化。

4执行 code/conversation/both

用户可以只恢复代码、只恢复对话,或两者一起恢复。

5REPL 从新状态继续

恢复后当前会话从这个分叉状态继续运行。

什么时候建立 checkpoint

file-history snapshot 的第一步发生在用户消息进入系统时,而不是工具写完文件之后。handlePromptSubmit.ts 会把新进来的 selectable user message 变成 checkpoint 坐标。

ts
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。

ts
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 只是索引。

ts
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 时,当前文件应被删除;如果没有这个状态,系统只能恢复已有文件,无法正确撤销“新建文件”。

snapshot 是消息坐标;文件旧内容是在工具写入前由 trackEdit 补进最近的 snapshot。
snapshot 是消息坐标;文件旧内容是在工具写入前由 trackEdit 补进最近的 snapshot。

文件旧版本怎么捕获

备份必须发生在工具写文件之前。FileWriteToolFileEditToolNotebookEditTool 都会在真正写入前调用 fileHistoryTrackEdit(),把当前磁盘上的旧内容记录到当前 checkpoint。

ts
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 完整建模。

ts
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>/

ts
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 时还要处理备份迁移。

ts
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}
JSONL 只保存 snapshot 索引;真实文件内容在独立 file-history 目录。metadata 与 blob 分离,让 transcript 不被大文件撑爆。
JSONL 只保存 snapshot 索引;真实文件内容在独立 file-history 目录。metadata 与 blob 分离,让 transcript 不被大文件撑爆。

这个拆分解决了两个问题:transcript 保持可追加、可恢复;文件内容不用经过对话日志序列化。代价是 session 恢复时既要重建 metadata chain,也要保证备份 blob 能在新 session 下找到。

snapshot 如何更新

同一个 messageId 可能会被多次更新,因为工具执行和 snapshot 创建之间存在异步窗口。sessionStorage.tsisSnapshotUpdate 做了替换语义:同一 messageId 的旧 snapshot 会被新 snapshot 覆盖。

ts
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 展示哪些文件会改、插入删除大概是多少。

ts
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

ts
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 下找到文件。

ts
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()

ts
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 做三类动作:如果 backupFileNamenull,删除当前文件;如果 backup 存在且当前内容不同,复制备份回原路径;如果没有变化,跳过。

ts
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 之前的状态。

tsx
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 时,对话历史不截断。

边界关系:file history 负责被追踪文件;transcript 负责会话记录;worktree/git 不是回滚实现。
边界关系:file history 负责被追踪文件;transcript 负责会话记录;worktree/git 不是回滚实现。

Headless 与 SDK 路径

CLI 暴露了隐藏参数 --rewind-files,但它要求 --resume,且不能和 prompt 同时使用。原因是这条路径只做文件恢复,不启动一次新的对话推理。

ts
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,再决定是否恢复。

ts
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)
1用户消息成为坐标

fileHistoryMakeSnapshot 绑定 user message uuid。

2工具写前保存旧内容

fileHistoryTrackEdit 在文件工具写入前保存旧内容。

3metadata 写 transcript

sessionStorage 追加 file-history-snapshot entry。

4backup 写 file-history

真实文件内容按 sessionId 存到 ~/.claude/file-history。

5resume 重建链路

resume 时重建 FileHistoryState,并迁移旧 session 的 backup。

6applySnapshot 执行恢复

目标 snapshot 指导复制旧文件或删除新文件。

可复用设计

这个机制最值得复用的不是“做撤销按钮”,而是四个工程选择。第一,checkpoint 绑定用户消息,而不是绑定工具调用,因为用户理解的是“回到我提出某个请求之前”。第二,写前备份旧内容,避免修改后再猜测旧状态。第三,metadata 和 blob 分离,让 transcript 不被大文件撑爆。第四,恢复前做 diff preview,把回滚变成可确认动作。

边界

Rewind 的边界也很硬:它不是 git 回滚,不是完整工作区快照,不保证覆盖手工编辑、普通 bash 副作用、数据库写入、网络资源或后台进程状态。它恢复的是 Claude Code file-history 协议能追踪到的文件,以及 REPL 当前 conversation 状态。