# 文件历史系统完全解析

文件历史系统是 Claude Code 的"后悔药"——当 AI 修改了代码但结果不对时，一条 `/rewind` 命令就能回退到**最近 100 个快照之内**的任意一轮对话之后的文件状态（更早的快照因 FIFO 清理已不可用，详见 §3.1）。这个系统最独特的设计决策是：**按对话轮次（而非单次文件编辑）组织快照**，让用户能以"我和 AI 的第 N 轮对话"为粒度回退，而非在零散的文件编辑中大海捞针。底层实现采用业界标准的工程做法——SHA256 路径哈希、mtime 去重、hard-link 复用、FIFO 淘汰——但在快照粒度的选择上做出了真正值得关注的架构决策。本章将解析快照的完整生命周期、存储命名策略、100 快照上限下的清理机制，以及这套独立快照方案与 git 集成方案之间的深层取舍。

> **源码位置**：`src/utils/fileHistory/`、`src/utils/fsOperations/`

> 💡 **通俗理解**：文件历史系统就像游戏的自动存档——每次 AI 修改代码前自动保存一个快照（存档点），你可以随时"读档"回到之前的版本。关键区别在于：存档点不是按每次文件修改设的，而是按每轮"你问 AI 答"的对话来设的——就像 RPG 游戏在每个剧情节点存档，而不是每走一步存一次。最多保留 100 个存档，最旧的自动覆盖，而且聪明到不会对没变化的文件重复存档。

### 🌍 行业背景：AI 编程工具的撤销与回退机制

当 AI 修改代码出错时如何回退？这是所有 AI 编程工具必须解决的用户信任问题，但各家方案路线截然不同：

- **Cursor**：依赖 VS Code 内置的 undo/redo 栈，AI 修改与手动修改共享同一撤销历史。Composer 模式有独立的"Accept/Reject Changes"机制，但粒度是单次编辑，不支持按对话轮次回退。
- **Aider**：每次 AI 修改后自动执行 `git commit`，撤销操作就是 `git revert` 或 `/undo` 命令。这套方案有实质优势：git 历史是无限的（不受 100 快照限制）、可搜索的、对团队可见的——其他成员可以通过 `git log` 看到 AI 做了哪些修改。代价是 git 历史中会出现大量自动 commit，部分用户认为这降低了历史可读性，但也有用户认为这恰好提供了最细粒度的审计追踪。
- **Codex（OpenAI）**：每个 Agent 在 OS 级隔离环境中运行，修改结果以 patch 形式呈现，用户显式 apply 才生效。这代表了一种根本不同的设计哲学——"预防式"而非"治愈式"。**OpenCode** 选择了基于底层 Git 还原的撤销机制，保障操作安全可控——代表了"版本控制优先"的文件安全路线。
- **GitHub Copilot Workspace**：所有修改在云端草稿中进行，用户手动选择哪些修改合入。类似 Code Review 模式。
- **JetBrains AI**：利用 IDE 的 Local History 功能，每次保存都记录快照，与 AI 无关的修改也会被记录。
- **Windsurf**：提供 Cascade 的 checkpoint 机制，按 AI 操作轮次保存状态。

从设计哲学上看，这些方案可归纳为三种路线：

1. **预防式**（Codex 的沙箱预览、Copilot Workspace 的云端草稿、Google 的 AI 编程工具（产品名以官方为准） 的 Artifacts 前置审查）——修改在用户确认前不生效，不需要回退
2. **集成式**（Aider 的 git auto-commit）——利用现有版本控制基础设施，回退历史无限且对团队可见，但与 git 历史耦合
3. **独立式**（Claude Code 的快照系统、Windsurf 的 checkpoint）——独立于 git 的轻量级机制，不污染历史但有容量限制

Claude Code 选择了独立式路线，核心设计决策是**按对话轮次组织快照**——支持精确回退到"第 N 轮对话之后"的文件状态。这比 Cursor 的 undo 栈更精确（可以跨多次编辑回退），但相比 Aider 的 git 集成方案，存在快照上限（100 个）和团队不可见性的局限。

---

## 概述

当 AI 修改了你的代码、你想回到之前的状态时，文件历史系统让你不需要 `git stash` 或 `git checkout` ——一条 `/rewind` 命令就能精确回退到任意一轮对话之后的文件状态。系统的核心设计是按对话轮次组织快照，在每次 Edit/Write 操作前自动追踪文件原始内容、每条 assistant 消息后将该轮修改打包为一个快照。底层通过 SHA256 路径哈希和 mtime 去重避免冗余备份，用 hard-link 实现跨 session 复用，FIFO 策略限制在 100 个快照以内。

---

> **[图表预留 3.11-A]**：快照生命周期图 — trackEdit(前) → makeSnapshot(后) → getdiffStats(查) → rewind(还原) 的完整链路

---

## 核心设计决策：按对话轮次组织快照

这是整个文件历史系统中最值得关注的架构决策——**快照的粒度不是文件编辑，而是对话轮次**。

在一次 AI 编程对话中，用户说"帮我重构这个模块"，AI 可能连续修改 5-10 个文件。如果按文件编辑粒度创建快照（像 Cursor 那样），用户回退时面对的是一串零散的文件修改记录——"main.ts 被修改了"、"utils.ts 被修改了"——需要自己判断哪些修改属于同一轮对话。而 Claude Code 按对话轮次创建快照后，用户看到的是"第 3 轮对话（AI 修改了 5 个文件）"这样的完整语义单元，一次 rewind 就能回退整轮对话的所有修改。

**为什么轮次比编辑更适合 AI 编程场景？**

用户的心智模型是"对话"而非"编辑"。当用户想撤销时，回忆的是"第三轮对话让 AI 做的事情不对"，而不是"第七次文件写入需要回退"。按轮次组织让回退操作与用户的认知粒度对齐。

**技术实现**：`fileHistoryMakeSnapshot()` 在三个入口点（REPL.tsx、QueryEngine.ts、handlePromptSubmit.ts）于每条 assistant 消息之后调用，将该轮对话中所有被追踪的文件修改打包成一个快照，以 `messageId` 为键。这意味着一个快照可能包含零个（AI 没修改文件的对话）到数十个（大规模重构）文件备份。

**边界情况**：当一轮对话修改了大量文件（比如 50 个），单个快照会包含所有文件的备份。回退时是全有或全无的——不能只回退其中 3 个文件的修改。这在大多数场景下是合理的（用户通常想回退整个"请求"的结果），但在少数情况下可能不够灵活。

**与竞品对比**：Windsurf 的 checkpoint 采用了类似的轮次粒度，但 Aider 的 per-edit commit 提供了更细的粒度选择——用户可以用 `git checkout <commit> -- <file>` 或 `git revert --no-commit` 配合手动调整，精确撤销某次 commit 对某个文件的修改（`git revert` 是"创建一个反向提交"，不是直接重置文件内容；要回退单个文件更自然的命令是 `git checkout` 或 `git restore`——这里措辞为 revert 只是口语上的"撤销"，严格操作需要组合命令）。这是粒度选择中"语义直觉"与"精确控制"的经典取舍。

> 💡 **通俗理解**：想象你请装修师傅改造房间。按"编辑粒度"存档就像每搬一块砖拍一张照——回头翻照片时你根本不记得哪几张是"拆墙"、哪几张是"装灯"。按"轮次粒度"存档就像每完成一个你提出的需求拍一张——"拆完墙了"、"灯装好了"——回退时一目了然。

---

## 1. 快照生命周期

### 1.1 创建（Pre-edit 追踪）

`fileHistoryTrackEdit()`（`fileHistory.ts:86-193`）在 Edit/Write 工具修改文件**之前**调用。三阶段设计（以下编号 1/2/3 和源码内部注释的 "Phase 2" 同义——本节用"三阶段"叙述，下面第 3 点引用的"Phase 2"指的就是本节第 2 阶段"异步备份"）：

1. **阶段 1 · 去重短路检查**（lines 99-118）：如果文件已在最近快照中追踪且内容未变，直接返回——这是标准的去重短路（dedup short-circuit）模式，在条件不满足时跳过后续操作
2. **阶段 2 · 异步备份**（lines 120-128）：通过 `createBackup()` 捕获前编辑内容
3. **阶段 3 · 竞态条件再检查**（lines 131-192）：阶段 2 是异步的，可能有并发修改（多个工具调用并发执行或用户同时手动编辑）——提交前再次验证文件状态是否一致。如果检测到不一致，系统会以最新状态为准重新创建备份

**幂等性保证**：备份文件的命名采用"路径哈希 + 版本号"的方案（§2.2），而幂等性由"版本号只在内容真正变化时递增"保证——对同一文件的多次 `trackEdit()` 调用，如果内容未变就复用已有 `@v{n}` 备份文件，不创建新版本。这里并不是用"内容哈希"作为文件名键（那是 Git 对象存储的方案）。

### 1.2 存储（每轮对话后的事件驱动快照）

`fileHistoryMakeSnapshot()`（`fileHistory.ts:195-342`）在每条 assistant 消息之后调用（REPL.tsx、QueryEngine.ts、handlePromptSubmit.ts 三个入口点）。

快照结构（lines 299-303）：
```typescript
{
  messageId: UUID,           // 此快照对应的消息 ID
  trackedFileBackups: {...}, // 追踪路径 → FileHistoryBackup 映射
  timestamp: Date            // 快照时间戳
}
```

三层优化避免冗余备份：

1. **stat-before-content**（lines 233-239）：先 `stat()` 文件，ENOENT = 已删除，避免对不存在的文件做读取
2. **mtime 去重**（lines 257-269）：如果文件 mtime < 最新备份的 mtime，说明文件未被修改——复用最新版本，不创建新备份
3. **版本号递增**（lines 272-275）：每个追踪文件有独立的 v1, v2, v3... 计数器，只在内容真正变化时递增

### 1.3 查询（Rewind 前预览）

`fileHistoryGetDiffStats()`（`fileHistory.ts:413-483`）被 `MessageSelector.tsx` 调用，让用户在确认 rewind 前看到变更概览：

- 使用 npm `diff` 库的 `diffLines()` 计算行级差异
- 返回 `DiffStats`：变更文件列表 + 插入/删除行数

`fileHistoryHasAnyChanges()`（`fileHistory.ts:494-531`）是轻量级变体——只返回布尔值，在第一个变化文件处 early-exit。用于 UI 的"是否显示 rewind 选项"判断。

### 1.4 还原（Rewind 执行）

`fileHistoryRewind()`（`fileHistory.ts:347-397`）：

```
通过 messageId 查找目标快照（findLast）
  → applySnapshot()
    → 对每个追踪文件：
       ├── 备份为 null（文件原本不存在）→ unlink() 删除
       └── 备份存在 → restoreBackup()（仅在内容变化时执行）
  → 记录 tengu_file_history_rewind_success 分析事件
```

## 2. 存储与命名

### 2.1 目录结构

```
~/.claude/file-history/
├── {sessionId1}/
│   ├── a1b2c3d4e5f6g7h8@v1    ← /Users/.../main.ts 的第一个版本
│   ├── a1b2c3d4e5f6g7h8@v2    ← 同一文件的第二个版本
│   ├── 9z8y7x6w5v4u3t2s@v1    ← 另一个文件
│   └── ...
└── {sessionId2}/
    └── ...
```

### 2.2 备份文件命名

> **注**：路径哈希 + 版本号后缀是一种标准的文件命名策略——用哈希把长路径映射为短文件名，实现 O(1) 查找。Git 对象存储（以内容 SHA1 命名）、Docker 层缓存、npm cache 都用类似策略。版本号后缀 `@v1, @v2...` 让同一文件的多个版本共存。这里不需要过度类比数据库概念——它就是一个常见的哈希映射加版本编号，工程上简洁有效。

`getBackupFileName()`（`fileHistory.ts:725-730`）：

```typescript
const fileNameHash = createHash('sha256')
  .update(filePath)              // 哈希完整的绝对路径
  .digest('hex')
  .slice(0, 16)                  // 取前 16 个十六进制字符（64 位）
return `${fileNameHash}@v${version}`
```

**为什么哈希路径而不是内容？**
- 从路径到备份文件名是 O(1) 计算——给定路径立即知道在哪里找备份
- 不需要运行时内容比较来生成文件名
- 16 字符（64 位）在生日悖论下需要约 2^32（43 亿）个不同路径才有 50% 的碰撞概率，对单 session 规模而言概率极低。但需要注意**碰撞的后果是严重的**：如果两个不同路径产生相同的哈希前缀，它们的备份文件会互相覆盖——导致 rewind 时恢复到错误文件的内容，且用户不会收到任何警告。源码中没有碰撞检测机制（比如在写入备份前检查已有文件是否属于同一路径）。尽管概率极低，但对于安全关键功能（代码回退），这种静默数据损坏的风险值得了解

## 3. MAX_SNAPSHOTS 与清理

### 3.1 上限

`const MAX_SNAPSHOTS = 100`（`fileHistory.ts:54`）

清理策略（lines 305-311）：
```typescript
allSnapshots.length > MAX_SNAPSHOTS
  ? allSnapshots.slice(-MAX_SNAPSHOTS)  // 保留最新 100 个
  : allSnapshots
```

**FIFO 清理**：超过 100 个快照时，最旧的自动移除。

### 3.2 Sequence 计数器

`snapshotSequence`（line 312）即使旧快照被清除仍然递增——用作 `useGitDiffStats` 的活动信号。这是一种"逻辑时钟"设计——sequence 单调递增，永不回退。

## 4. Hard-Link 跨 Session 复用

`fileHistory.ts:978-1010`：当恢复 session 时（session resume），系统使用 `link()` 创建 hard-link 而不是 `copyFile()`：

- **零额外磁盘开销**：hard-link 共享 inode
- **失败回退**：如果 `link()` 失败（跨文件系统等），回退到 `copyFile()`
- **适用场景**：用户关闭终端后重新打开 Claude Code 继续之前的 session

## 5. 崩溃恢复与元数据持久化

一个"完全解析"不能只覆盖正常路径——系统在异常退出时的行为同样重要。

**备份文件 vs 快照元数据**：文件历史系统有两层数据。第一层是磁盘上的备份文件（`~/.claude/file-history/{sessionId}/{hash}@v{n}`），这些是持久化的。第二层是快照元数据——即"哪个 messageId 对应哪些文件的哪个版本"的映射关系，这些存储在内存中的 `FileHistoryState` 对象里。

**崩溃的后果**：如果 Claude Code 进程非正常退出（崩溃、终端被强制关闭、系统断电），磁盘上的备份文件会保留，但内存中的快照元数据会丢失。这意味着：

1. 备份文件在磁盘上是完整的，但系统不知道哪个备份对应哪轮对话
2. Session resume 时，`restoreFileHistory()`（`fileHistory.ts:978-1010`）会通过 hard-link 复用旧 session 的备份文件，但如果元数据丢失，快照列表为空——用户无法通过 `/rewind` 回退到崩溃前的对话状态
3. 不存在独立的元数据持久化机制（没有写到磁盘的 JSON/SQLite 等）

**Rewind 执行中途失败**：`fileHistoryRewind()` 对每个追踪文件逐一还原。如果在还原过程中发生错误（磁盘满、权限不足），已还原的文件不会回滚——文件状态会处于部分还原的不一致状态。系统没有事务性保证或回滚机制。

这是独立快照方案相比 git 集成方案的一个结构性弱点：git 的引用日志（reflog）和对象存储提供了天然的持久化和崩溃恢复能力，而内存中的快照元数据在面对进程崩溃时是脆弱的。

---

## 6. 集成点

### 6.1 工具集成

- **Edit 工具**（`FileEditTool.ts:435-438`）：修改前调用 `fileHistoryTrackEdit()`
- **Write 工具**（`FileWriteTool.ts:259-261`）：写入前调用 `fileHistoryTrackEdit()`
- **NotebookEdit**：类似模式

### 6.2 消息循环集成

- **REPL.tsx**（lines 3092-3099）：每条 assistant 消息后调用 `fileHistoryMakeSnapshot()`
- **QueryEngine.ts**（lines 645-650）：同上
- **handlePromptSubmit.ts**（lines 528-533）：同上

### 6.3 UI 集成

- **MessageSelector.tsx**（lines 77, 173）：调用 `fileHistoryGetDiffStats()` 显示 rewind 预览
- **/rewind 命令**：调用 `fileHistoryRewind()`

## 7. 设计取舍与评价

### 真正的亮点

1. **按对话轮次组织快照**——这是整个系统最重要的设计决策（详见上文核心设计决策一节），让回退粒度与用户的认知模型对齐
2. **`snapshotSequence` 作为逻辑时钟**——即使快照被 FIFO 清理，sequence 也不会回退，防止 UI 组件（`useGitDiffStats` hook）因 sequence 回退而触发不必要的重新渲染。这是一个面向 React 状态管理的精巧考量
3. **`fileHistoryHasAnyChanges()` 的查询分离**——只需知道"有没有变化"来决定是否显示 rewind 按钮，不需要计算具体差异。用廉价的 early-exit 布尔查询替代昂贵的全量 diff 计算，这种"分离查询策略"在 AI Agent 的 UI 响应优化中很有价值

### 标准工程做法（实现扎实但不需过度赞美）

以下做法是工程上正确的选择，但本身是业界标准实践：

- **stat-before-content 优化**：先 `stat()` 再读内容是文件操作的标准模式，几乎所有需要条件性读取文件的程序（rsync、make、构建工具）都这么做
- **路径哈希 O(1) 查找**：SHA256 哈希路径映射是常见的文件命名策略
- **Hard-link 跨 session 复用**：这是 Unix 文件系统的标准技巧。值得注意的是 fallback 到 `copyFile()` 的容错处理
- **mtime 去重**：避免对未修改文件创建冗余备份
- **FIFO 淘汰**：100 快照上限的简单清理策略

### 代价与风险

1. **路径哈希碰撞的静默后果**：虽然 64 位哈希碰撞概率极低，但碰撞时备份会被静默覆盖，没有检测机制（详见 2.2 节讨论）
2. **快照元数据不持久化**：进程崩溃后快照映射丢失，备份文件仍在但无法关联到对话轮次（详见第 5 节）
3. **Rewind 无事务保证**：还原过程中如果中途失败，文件状态会不一致
4. **路径哈希意味着文件重命名后被视为"新文件"**——重命名前的历史不会关联到新路径
5. **mtime 去重依赖文件系统的时间戳精度**——不仅 NFS 等网络文件系统可能不可靠，本地文件系统也有精度差异（ext4 纳秒级、HFS+ 秒级、FAT32 两秒级）。如果两次修改发生在同一个 mtime 窗口内（用户手动编辑 + AI 几乎同时编辑），mtime 去重可能错误地认为文件未修改，导致快照丢失
6. **100 快照上限对长 session 可能不够**——早期修改无法回退，且没有分级存储策略（比如最近 50 个保留全部细节，更早的按指数衰减保留关键快照）
7. **三个入口点的耦合风险**——`makeSnapshot()` 需要在 REPL.tsx、QueryEngine.ts、handlePromptSubmit.ts 三个位置手动调用。如果未来新增入口点（比如 API 模式），开发者必须记得插入 snapshot 调用，否则会出现"该入口的修改不可回退"的 bug
8. **Hard-link 跨文件系统不工作**——如果 `~/.claude` 和项目在不同分区，回退到 copyFile 会增加磁盘使用

### 为什么不用 git 集成？

这是整个设计中最关键的架构问题，值得严肃讨论。

Aider 已经证明 git auto-commit 方案是可行的，git worktree 可以在不污染主分支的情况下保存历史，git stash 可以保存临时状态。那么 Claude Code 为什么选择独立实现？

**独立方案的真正理由**（不仅仅是"不污染历史"）：

1. **无 git 仓库的场景**：用户可能在没有 `git init` 的目录中使用 Claude Code，独立方案不依赖 git 的存在
2. **git 操作的性能开销**：每次 AI 修改后执行 `git add` + `git commit` 在大仓库中可能有可感知的延迟，尤其是有大量文件的 monorepo
3. **git 锁竞争**：如果用户同时在另一个终端执行 git 操作（rebase、merge），自动 commit 可能因 `.git/index.lock` 而失败
4. **实现复杂度**：需要处理 `.gitignore` 规则、submodule、shallow clone 等边缘情况

**独立方案的代价**（需要诚实承认）：

1. **团队不可见性**：`~/.claude/file-history/` 是纯本地的，团队成员无法通过 git log 看到 AI 做了什么修改。Aider 的方案在协作场景中有天然优势
2. **有限的回退深度**：100 快照 vs git 的无限历史
3. **崩溃脆弱性**：快照元数据不持久化（见第 5 节），而 git 的 reflog 和对象存储天然具备崩溃恢复能力
4. **不可搜索**：无法像 `git log -S "function_name"` 那样搜索历史修改

这是一个合理的工程权衡——为了更广的适用场景（不依赖 git）和更低的实现风险（不与用户的 git 操作冲突），付出了持久化和可见性的代价。

---

