基于 Claude Code 源码通过 sourceMap 还原逆向分析其文件版本控制系统。核心模块utils/fileHistory.ts负责追踪 AI 编辑过的文件创建备份快照支持一键回滚。一、为什么 AI 编辑文件需要版本控制AI 修改代码和人类改代码有一个本质区别AI 可能一口气改很多文件而且改完你未必能立刻发现问题。等你发现 AI 把代码改坏了可能已经过了好几轮对话。这时候你需要一个后悔药——回到 AI 改之前的某个时间点。Git 当然可以做这件事但 Claude Code 的使用者不一定都用 Git而且 Git 的粒度是手动 commitAI 中间改了什么你得自己 diff。Claude Code 需要一个更轻量、更自动的方案让用户能够看到每轮对话改了哪些文件、改了多少行一键回滚到 AI 改之前的某个节点不需要手动管理版本这就是fileHistory模块的职责。二、核心数据模型2.1 三个关键类型// 单个文件的备份记录typeFileHistoryBackup{backupFileName:string|null// null 表示该版本文件不存在version:number// 版本号单调递增backupTime:Date}// 一个快照——对应主对话中的一条消息typeFileHistorySnapshot{messageId:UUID// 关联的主对话消息 IDtrackedFileBackups:Recordstring,FileHistoryBackup// 文件路径 → 备份信息timestamp:Date}// 整体状态typeFileHistoryState{snapshots:FileHistorySnapshot[]// 快照列表最多 100 个trackedFiles:Setstring// 当前被追踪的文件集合snapshotSequence:number// 单调递增计数器}一个快照对应主对话中的一轮消息。快照中记录了当时所有被追踪文件的备份状态。2.2 备份文件存储备份文件存储在~/.claude/file-history/{sessionId}/目录下~/.claude/file-history/ ├── a1b2c3d4-session-id/ │ ├── a1b2c3d4e5f6g7h8v1 ← 文件 A 的第一个版本 │ ├── a1b2c3d4e5f6g7h8v2 ← 文件 A 的第二个版本 │ └── d4e5f6a1b2c3d4e5v1 ← 文件 B 的第一个版本文件名格式{sha256(path)[:16]}v{version}。使用 SHA256 哈希避免路径中的特殊字符问题。三、核心流程3.1 追踪文件编辑fileHistoryTrackEdit当 AI 即将修改一个文件时通过 Edit、Write 等工具fileHistoryTrackEdit被调用。它的工作是在文件被修改之前保存一份原始内容的备份。时间线 AI 决定编辑 file.ts ↓ fileHistoryTrackEdit 被调用 ↓ 检查 file.ts 是否已在最近快照中被追踪 ├── 已追踪 → 跳过不覆盖 v1 备份 └── 未追踪 → 创建 file.ts 的 v1 备份 ↓ AI 实际修改 file.ts关键细节如果同一个文件在一次对话轮次中被多次编辑fileHistoryTrackEdit只会创建一次 v1 备份。后续的编辑不会覆盖 v1——因为 v1 代表的是AI 改之前的状态。// 防止重复追踪if(mostRecent.trackedFileBackups[trackingPath]){// Already tracked in the most recent snapshot// Do not touch v1 backup.return}3.2 创建快照fileHistoryMakeSnapshot每轮对话结束后fileHistoryMakeSnapshot对所有被追踪的文件进行一次盘点// 对每个追踪文件检查是否需要新备份awaitPromise.all(Array.from(captured.trackedFiles,asynctrackingPath{// 1. stat 文件判断是否存在// 2. 如果存在且内容没变 → 复用上一次的备份引用// 3. 如果存在且内容变了 → 创建新版本备份// 4. 如果文件不存在 → 记录 null文件被删除}))这里有一个重要的优化未变化的文件不会创建新备份只是把上一个快照的引用复制过来。假设追踪 3 个文件经历 4 轮对话 Snapshot 1: Av1, Bv1, Cv1 → 磁盘: 3 个备份文件 Snapshot 2: Av2, Bv1, Cv1 → 磁盘: 1 个备份文件仅 A 变了 Snapshot 3: Av2, Bv1, Cv2 → 磁盘: 1 个备份文件仅 C 变了 Snapshot 4: Av2, Bv1, Cv2 → 磁盘: 无新增全都没变3.3 回滚fileHistoryRewind当用户想要回到某个历史节点时asyncfunctionfileHistoryRewind(updateFileHistoryState,messageId,// 目标快照的 messageId){// 1. 找到目标快照consttargetSnapshotcaptured.snapshots.findLast(snapshotsnapshot.messageIdmessageId)// 2. 将所有追踪文件恢复到快照中的版本constfilesChangedawaitapplySnapshot(captured,targetSnapshot)}applySnapshot对每个追踪文件获取目标版本对应的备份文件名用copyFile将备份覆盖回原路径恢复文件权限四、变更检测的三层优化checkOriginFileChanged是判断文件是否被修改过的核心函数。它用三层递进的策略尽量避免全量读取文件内容第一层快速判断 ├── 文件大小不同 → 已变化 ├── 文件权限不同 → 已变化 └── 继续下一层 第二层时间戳判断 ├── mtime 备份时间 → 未变化文件比备份更旧不可能被改过 └── 继续下一层 第三层全量内容比较 └── readFile 两个文件逐字节比较大部分情况下第一层或第二层就能得出结论避免了昂贵的磁盘 I/O。五、备份策略全量拷贝 引用复用不是增量 Diff每个版本的备份都是完整的文件拷贝通过copyFile系统调用而不是增量 diffasyncfunctioncreateBackup(filePath,version){constbackupFileNamegetBackupFileName(filePath,version)// 如 a1b2c3v1constbackupPathresolveBackupPath(backupFileName)awaitcopyFile(filePath,backupPath)// 全量拷贝不走 JS 堆awaitchmod(backupPath,srcStats.mode)// 保留文件权限return{backupFileName,version,backupTime:newDate()}}选择全量拷贝的原因copyFile是内核层面的零拷贝操作不走 JavaScript 堆大文件不会 OOM实现简单可靠不需要处理 diff/patch 的边界情况恢复时只需一次copyFile不需要按顺序回放所有 diff引用复用节省空间快照中引用复用的设计让存储开销大幅降低。一个被追踪但长期不变的文件无论经历多少轮对话在磁盘上只有一份备份。六、谁会被追踪谁不会只有被 AI 工具编辑过的文件才会进入追踪集合。AI 用 Edit 工具改了file.ts→file.ts被加入trackedFilesAI 用 Write 工具创建了new.ts→new.ts被追踪AI 只读取了readonly.tsRead 工具 → 不会被追踪用户手动编辑了任何文件 → 不会新加入追踪但如果文件已被追踪变更会被下一个快照捕获用户手动修改已追踪文件时的行为AI 编辑 file.ts → file.ts 被追踪创建 v1 备份 用户手动编辑 file.ts 下一轮 AI 对话 → fileHistoryMakeSnapshot 检测到 file.ts 内容变了 → 创建 v2 备份包含用户的修改系统不区分谁改的文件——只关心文件内容是否与最新备份不同。七、回滚的注意事项7.1 回滚会覆盖用户修改applySnapshot不区分改动来源。如果用户在 AI 编辑之后手动改了文件回滚到 AI 编辑之前的快照会把用户的手动修改也一并覆盖AI 改了 file.ts → 快照记录 v1原始→ v2AI 改后 用户手动改 file.ts → 下一快照 v3用户改后 用户回滚到 v1 → v3 被覆盖用户的修改丢失这类似git reset --hard的语义——用户主动触发回滚时通常预期这个行为。7.2 最多 100 个快照constMAX_SNAPSHOTS100超出后淘汰最早的快照。对于长会话早期的快照可能已经不可回滚。八、多 Agent 协作的文件安全8.1 子 Agent 不产生快照这是源码中一个容易被忽视的设计。在forkedAgent.ts中// forkedAgent.ts:432updateFileHistoryState:(){},// 空操作子 agent通过 AgentTool 启动的 agent拿到的updateFileHistoryState是一个空函数。当子 agent 调用FileEditTool编辑文件时虽然工具内部会调用fileHistoryTrackEdit但因为回调是空函数captured变量为undefined函数在第 107 行直接返回updateFileHistoryState(state{capturedstate// 子 agent 中 captured undefinedreturnstate})if(!captured)return// 直接退出不创建备份8.2 为什么这样设计快照绑定的是主对话的 messageId而不是子 agent 的内部消息 IDtypeFileHistorySnapshot{messageId:UUID// 主对话消息 IDtrackedFileBackups:Recordstring,FileHistoryBackup}子 agent 有自己独立的消息链它们的 messageId 在主对话中不存在。如果让子 agent 也写入快照会破坏一个主轮次 一个快照的模型。8.3 实际影响快照粒度是主对话轮次主轮次 N用户提问 ├→ 主 agent 编辑 fileA → Snapshot N 包含 fileA 的变更 ├→ 子 agent B 编辑 fileB → 无快照 └→ 子 agent C 编辑 fileC → 无快照 主轮次 N1用户继续提问 ├→ fileHistoryMakeSnapshot → Snapshot N1 创建 │ 包含 fileB、fileC 的累积变更 └→ 主 agent 继续编辑回滚能力目标效果回滚到 Snapshot N撤销主 agent 所有子 agent 的改动回滚到 Snapshot N1保留所有子 agent 的改动撤销之后的改动风险无法选择性回滚——子 agent A 和 B 各改了不同文件你无法只撤销 A 的改动而保留 B 的。8.4 Worktree 隔离Claude Code 提供了isolation: worktree作为更强的隔离手段// forkSubagent.ts:209You are operating in an isolated git worktree at${worktreeCwd}— same repository, separate working copy. Your changes stay in this worktree and will not affect the parents files.在 worktree 中运行的子 agent 拥有独立的文件副本天然不冲突。完成后由主 agent 决定是否合并。这是处理高风险多 agent 文件编辑的推荐方式。九、文件冲突的乐观并发控制多 agent 编辑同一文件时Claude Code 没有全局文件锁而是采用乐观并发 失败重试的策略防线在FileEditTool中第一层时间戳校验if(lastWriteTimereadTimestamp.timestamp){return{behavior:ask,message:File has been modified since read... Read it again before attempting to write it.,errorCode:7,}}每个 agent 各自维护readFileState记录上次读取文件的时间戳。编辑前比对磁盘 mtime发现文件被改过就拒绝写入。第二层old_string 匹配constactualOldStringfindActualString(file,old_string)if(!actualOldString){return{behavior:ask,message:String to replace not found in file.}}即使通过了时间戳检查还要验证old_string确实存在于文件中。如果另一个 agent 已经改了这段内容匹配失败。冲突处理流程Agent A: 读取 file.ts → 看到 function foo() {...} Agent B: 读取 file.ts → 看到同一个 function foo() {...} Agent B: 编辑 foo() → 成功mtime 更新 Agent A: 尝试编辑 foo() → mtime 检查失败 → 报错 Read it again Agent A: 重新读取 file.ts → 看到 B 修改后的版本 → 基于新内容编辑 → 成功先到先得后到的被拒绝必须重新读取后再编辑。没有自动 merge没有操作队列串行化。十、Session Resume 的备份迁移当用户恢复一个历史会话时需要把旧 session 的备份文件迁移到新 session 目录。copyFileHistoryForResume处理这个过程asyncfunctioncopyFileHistoryForResume(log){// 1. 获取旧 session 的备份目录constpreviousSessionIdlastMessage?.sessionId// 2. 创建新 session 的备份目录constnewBackupDirjoin(getClaudeConfigHomeDir(),file-history,sessionId)awaitmkdir(newBackupDir,{recursive:true})// 3. 并行迁移所有备份文件优先硬链接失败时降级为拷贝awaitlink(oldBackupPath,newBackupPath)// 硬链接优先零拷贝// fallback:awaitcopyFile(oldBackupPath,newBackupPath)// 跨文件系统时降级}优先使用硬链接link而非拷贝——硬链接不占用额外磁盘空间两个路径指向同一份数据。只有在硬链接失败时比如跨文件系统才降级为copyFile。十一、VSCode 扩展通知每次创建新快照时系统会通知 VSCode 扩展哪些文件发生了变化asyncfunctionnotifyVscodeSnapshotFilesUpdated(oldState,newState){for(consttrackingPathofnewState.trackedFiles){constoldBackupoldSnapshot?.trackedFileBackups[trackingPath]constnewBackupnewSnapshot.trackedFileBackups[trackingPath]// 只通知内容实际变化的文件if(oldContent!newContent){notifyVscodeFileUpdated(filePath,oldContent,newContent)}}}这让 VSCode 中的 Claude Code 扩展能够实时显示文件变更增强用户对 AI 编辑行为的感知。十二、设计总结设计决策选择原因备份策略全量拷贝简单可靠大文件不走 JS 堆空间优化引用复用未变化的文件不重复备份变更检测三层递进mtime → 大小/权限 → 内容逐级降级冲突处理乐观并发无锁失败时重新读取快照粒度主对话轮次子 agent 不独立快照简化模型隔离手段Worktree可选强隔离适合高风险场景最大快照数100防止无限增长整体来看fileHistory 是一个面向AI 改坏了要能回滚这个具体场景设计的轻量级版本控制系统。它不追求 Git 级别的功能完整性而是在简单性和安全性之间找到了一个适合 AI 编程助手场景的平衡点。