Skip to content

AI Coding Agent 状态回退方案:Shadow Git 架构设计 #11

@phantom5099

Description

@phantom5099

本文档整合了原始 Shadow Git 架构设计、社区呼声分析、以及多轮对话中产生的工程补充建议。所有资料来源均经过验证。

编制依据: Claude Code, OpenCode, Codex, pi-rewind, Pydantic AI Harness 等主流方案的官方文档、开源仓库及社区反馈。


目录

  1. 设计目标与社区呼声分析
  2. 核心架构
  3. 运行时状态捕获流程
  4. Undo / Redo 交互设计
  5. 配置与排除规则
  6. 性能优化与工程兜底
  7. 与现有方案对比
  8. 风险与边界情况
  9. 实现优先级
  10. 补充建议: 社区痛点与工程完善
  11. 附录: Git 对象模型与空间占用原理
  12. 附录: 资料来源汇总

1. 设计目标与社区呼声分析

1.1 设计目标

解决现有三种主流方案(Claude Code 文件快照、Codex Ghost Commit、OpenCode git write-tree)的核心痛点:

  • 不污染用户 Git 历史(优于 Codex)
  • 追踪 Bash/外部工具的文件变更(优于 Claude Code)
  • 精确区分 Agent 修改与用户/外部修改(优于 OpenCode)
  • 支持无 Git 项目(优于 Codex/OpenCode)
  • Redo 与双向回退(优于 Claude Code/Codex)

1.2 社区呼声符合度评估

基于对 Claude Code、OpenCode、Codex、pi-rewind、Pydantic AI Harness 等方案的用户反馈与官方文档分析, Shadow Git 方案的社区呼声符合度约为 75~80%

维度 评分 说明
痛点识别准确度 ★★★★★ 精准命中了 Codex/Claude/OpenCode 三家方案的已知缺陷
架构隔离性 ★★★★★ Shadow 裸仓库 + 环境变量隔离是工程上最干净的做法
Redo 设计 ★★★★☆ undo-safety commit 思路正确, 但缺少持久化
性能与兜底 ★★★☆☆ 优化手段合理, 但缺少自动熔断 + 自动清理的硬性约束
工程细节完整性 ★★★☆☆ mtime、跨仓库、持久化 redo、大文件阈值等细节未闭合

1.3 高度符合社区呼声的设计(约 60%)

方案设计 社区呼声来源 符合度
不污染用户 Git 历史 Codex ghost commit 被用户明确批评"污染历史" 完全契合
支持无 Git 项目 OpenCode 在非 Git 项目里 /undo 只能回退对话、不能回退文件 完全契合
Redo / 双向回退 Claude Code 被用户抱怨"undo 后无法 redo" 完全契合
区分 Agent 修改 vs 外部修改 OpenCode 文件监视器会把用户手动 discard 的修改重新应用回来 完全契合
按文件选择性回退(部分 undo) Codex 社区高票请求: "Sometimes Codex changes multiple files... no easy way to cherry pick which files to undo" 完全契合
环境隔离 OpenCode 因继承用户 git config/hooks 导致各种边缘问题 完全契合
中断安全 社区普遍担心 Agent 崩溃或用户 Ctrl+C 后状态不一致 完全契合

1.4 部分符合、但存在缺口的设计(约 15~20%)

方案设计 社区实际痛点 缺口分析
大仓库性能优化 OpenCode 因 git add . 扫描 45GB/54K 文件目录被社区怒批"insane behavior" 策略方向对, 但缺少硬性兜底: 需要自动 size cap + 文件数阈值 + 大文件跳过的熔断逻辑
磁盘空间管理 OpenCode snapshot 曾出现 533GB+ 孤儿 temp pack 文件 提到定期 gc, 但缺少自动 prune 策略(如 pi-rewind 的 checkpoint 上限)。社区需要"自动清理旧快照"
Bash 副作用追踪 Claude Code 不追踪 bash 副作用被长期批评 Ledger 对 bash 的 paths_touched 是**"尽力而为"(best-effort)**, 通过正则匹配。社区需要更可靠的追踪
跨仓库支持 OpenCode /undo 不恢复跨仓库修改 默认以 $PWD 为单一 worktree, 没有设计多仓库会话的 Shadow Git 映射
文件 mtime 保留 OpenCode git checkout -- .更新所有 tracked 文件的 mtime, 导致构建系统误判 使用 git checkout <commit> -- <file>, 没有提到 mtime 保留策略

1.5 社区有强烈呼声、但方案未覆盖的领域(约 10~15%)

社区呼声 来源 方案覆盖情况
会话分支 / Fork Pydantic AI Harness、Codex、Claude Code 都在推进"git branching for conversations" 未覆盖。Shadow Git 是线性 snapshot(A->B), 没有设计分支式 checkpoint
与 IDE / Git 原生工具集成 Claude Code 用户强烈要求把 checkpoint/rollback 迁移到 VSCode 扩展, 提供 timeline view 和 diff preview 未覆盖。方案只设计了终端 UI 面板
Git refs 持久化 pi-rewind 使用 Git refs 存储 checkpoints, "survives restarts" 部分覆盖。RedoStack 是内存结构, 程序重启后丢失
工具级撤销 vs 整轮撤销 社区讨论中, undo 的粒度应该是"一次 tool call"还是"一次 turn"仍有争议 方案采用整轮粒度(Turn A/B), 对"只想撤销某一次 edit_file"的场景不够灵活

2. 核心架构

2.1 双层存储模型

┌─────────────────────────────────────────────────────────────┐
│                        项目工作目录                          │
│  (用户肉眼看到的代码, IDE 编辑的目标)                          │
└──────────────────┬──────────────────────────────────────────┘
                   │ 文件系统操作 (edit, bash, 用户 IDE)
                   ▼
┌─────────────────────────────────────────────────────────────┐
│  Layer 1: Agent 工具调用日志 (Tool Call Ledger)              │
│  - 记录 Agent 显式 edit_file/write_file 的文件清单             │
│  - 轻量级 JSON 日志, 不存文件内容                             │
│  - 用于精确标记 Agent 修改                                     │
└──────────────────┬──────────────────────────────────────────┘
                   │ 仅记录路径与操作类型
                   ▼
┌─────────────────────────────────────────────────────────────┐
│  Layer 2: Shadow Git 仓库 (Shadow Repository)                │
│  位置: ~/.agent/shadows/<project-hash>.git (项目外)           │
│  - 完整 Git 裸仓库 + worktree 映射                            │
│  - 存实际文件内容, 支持 diff/checkout                        │
│  - 用于捕获所有磁盘状态变更 (Bash/用户/构建工具)              │
└─────────────────────────────────────────────────────────────┘

2.2 Shadow Git 初始化

# 项目首次启动 Agent 时
PROJECT_HASH=$(echo "$PWD" | sha256sum | head -c 16)
SHADOW_DIR="$HOME/.agent/shadows/$PROJECT_HASH.git"
WORK_TREE="$PWD"

# 1. 初始化裸仓库
mkdir -p "$SHADOW_DIR"
git init --bare "$SHADOW_DIR"

# 2. 配置隔离环境 (避免继承用户 git config/hooks)
cat > "$SHADOW_DIR/config" <<EOF
[core]
    repositoryformatversion = 0
    filemode = true
    bare = false
    logallrefupdates = true
    # 关键: 关闭所有 hooks, 避免触发用户脚本
    hooksPath = /dev/null
[gc]
    auto = 0
EOF

# 3. 设置 worktree 映射 (通过 GIT_WORK_TREE 运行时指定, 不写入 config)
# 所有 git 命令通过封装函数调用, 强制注入 GIT_DIR 和 GIT_WORK_TREE

2.3 环境隔离封装

所有 Shadow Git 操作必须通过封装层执行, 绝不依赖环境变量继承:

import { spawnSync } from "child_process";
import path from "path";
import os from "os";
import crypto from "crypto";

interface GitResult {
  stdout: string;
  stderr: string;
  status: number | null;
}

class ShadowGit {
  private projectPath: string;
  private gitDir: string;
  private env: NodeJS.ProcessEnv;

  constructor(projectPath: string) {
    this.projectPath = path.resolve(projectPath);
    const hash = crypto
      .createHash("sha256")
      .update(this.projectPath)
      .digest("hex")
      .slice(0, 16);
    this.gitDir = path.join(os.homedir(), ".agent", "shadows", `${hash}.git`);
    this.env = {
      ...process.env,
      GIT_DIR: this.gitDir,
      GIT_WORK_TREE: this.projectPath,
      // 关键: 屏蔽用户可能设置的 GIT_* 变量
      GIT_CONFIG_GLOBAL: "/dev/null",
      GIT_CONFIG_SYSTEM: "/dev/null",
    };
  }

  run(...args: string[]): GitResult {
    const result = spawnSync("git", args, {
      env: this.env,
      cwd: this.projectPath,
      encoding: "utf-8",
    });
    return {
      stdout: result.stdout ?? "",
      stderr: result.stderr ?? "",
      status: result.status,
    };
  }
}

为什么这样足够隔离?

  • GIT_DIR 指向项目外的裸仓库, 与用户 .git 完全无关
  • GIT_CONFIG_GLOBAL=/dev/null 屏蔽用户 ~/.gitconfig
  • core.hooksPath=/dev/null 确保任何 hook 都不执行
  • 子进程(包括 Agent 调用的 Bash) 不会自动继承这些变量, 除非显式传递

3. 运行时状态捕获流程

3.1 Turn 生命周期与 Commit 策略

Turn N 开始
   │
   ▼
[Commit A] ---> turn-{N}-baseline
   │           记录本轮开始前的全局状态
   │           (即使后续无修改, 也作为安全基准)
   │
   ├──> Agent 思考...
   │
   ├──> Tool Call: edit_file("api.py")
   │    ├── Pre-hook: Ledger 记录 {turn: N, file: "api.py", op: "edit"}
   │    ├── 文件落盘
   │    └── Shadow Git 不立即 commit(避免频繁)
   │
   ├──> Tool Call: bash("sed -i ... config.json")
   │    ├── Bash 命令执行(Shadow Git 不感知命令内容)
   │    ├── 文件系统变更发生
   │    └── Ledger 无记录(非显式工具调用)
   │
   ├──> 用户可能在 IDE 修改 styles.css(外部事件)
   │
   ▼
Turn N 结束(正常完成 / 用户中断 Ctrl+C / 异常崩溃)
   │
   ▼
[Commit B] ---> turn-{N}-final
   │           捕获本轮结束后的全局状态
   │           用于后续 Redo(回到本轮最终状态)
   │
   ▼
等待 Turn N+1...

3.2 中断检测与 Commit B 的保障

Commit B 必须在以下所有场景被触发:

场景 检测方式 Commit B 行为
正常结束 Agent 流式响应正常关闭 自动执行
用户 Ctrl+C 捕获 SIGINT, 设置中断标志 finally 块强制执行
Agent 崩溃 子进程异常退出码 父进程 waitpid 后强制执行
模型调用超时 HTTP 超时异常 异常处理块强制执行
用户中途 /undo 命令拦截 先强制 Commit B(记录当前), 再执行回退
class TurnGuard {
  private sg: ShadowGit;
  private turnId: number;
  private baselineCommit: string | null = null;
  private finalCommit: string | null = null;
  private interrupted = false;
  private originalSigint: (() => void) | null = null;

  constructor(shadowGit: ShadowGit, turnId: number) {
    this.sg = shadowGit;
    this.turnId = turnId;
  }

  private onInterrupt = (): void => {
    this.interrupted = true;
    this.ensureFinalCommit();
    // 重新抛出中断信号, 让上层感知
    process.exitCode = 130;
    throw new Error("KeyboardInterrupt");
  };

  private ensureFinalCommit(): void {
    if (this.finalCommit === null) {
      this.finalCommit = this.sg.commit(`turn-${this.turnId}-final`);
    }
  }

  enter(): this {
    // Commit A: 基准状态
    this.baselineCommit = this.sg.commit(`turn-${this.turnId}-baseline`);

    // 注册信号处理
    this.originalSigint = process.listeners("SIGINT")[0] as (() => void) | undefined;
    process.once("SIGINT", this.onInterrupt);

    // 注册进程退出保障
    process.once("beforeExit", () => this.ensureFinalCommit());

    return this;
  }

  exit(): boolean {
    this.ensureFinalCommit();
    if (this.originalSigint) {
      process.removeListener("SIGINT", this.onInterrupt);
    }
    // 不吞异常, 继续向上传播
    return false;
  }
}

3.3 工具调用 Pre-hook (Ledger)

在 Agent 执行 edit_file / write_file 前, 同步记录:

// ~/.agent/ledger/<project-hash>.jsonl
{"ts": "2026-05-18T02:24:00Z", "turn": 7, "type": "edit_file", "path": "src/api.py", "hash_before": "abc123", "hash_after": "def456"}
{"ts": "2026-05-18T02:24:05Z", "turn": 7, "type": "bash", "cmd": "sed -i 's/old/new/g' config.json", "paths_touched": ["config.json"]}

注意: bash 命令的 paths_touched尽力而为的(通过正则匹配或运行时 hook), 不是 100% 可靠。它仅用于 UI 标记, 不作为回退的安全边界。

社区反馈: Claude Code 的 checkpoint 只追踪文件编辑工具(Write/Edit)做的改动, Claude 跑 rm, mv, cp 改的文件 不被跟踪, 用户手动编辑也不被跟踪。Shadow Git 通过 Shadow 裸仓库的全局 snapshot 天然解决了这一问题, 但 Ledger 的 bash 追踪仍是 best-effort。

资料来源: 掘金 "/rewind 完全指南" 及 Claude Code 官方文档。
https://juejin.cn/post/7626192815358279686
https://github.com/luongnv89/claude-howto/blob/main/08-checkpoints/README.md


4. Undo / Redo 交互设计

4.1 /undo 执行流程

用户触发 /undo
   │
   ▼
1. 检查 Commit B 是否存在
   - 若 Turn 仍在进行中(无 Commit B):
     a. 立即强制 Commit B(中断当前 Turn)
     b. 或提示 "请等待当前操作完成"
   │
   ▼
2. 计算 Diff: Commit A (baseline) <-> 当前工作目录
   │
   ▼
3. 文件分类标记
   │
   ├── Agent 显式修改: Ledger 中有 edit_file/write_file 记录
   ├── 未知来源: Ledger 无记录, 但 diff 显示文件变更
   │       可能来源: Bash 副作用、用户 IDE 编辑、构建工具生成
   └── 未变更: diff 中无变化
   │
   ▼
4. 展示 UI 面板

4.2 UI 面板设计

Undo Turn 7: 优化登录接口

检测到 4 个文件变更:

Agent 显式修改 (建议回退)
[x] src/api.py           (edit_file @ 02:24:00)
[x] src/models.py        (edit_file @ 02:24:03)
[ ] tests/test_api.py    (edit_file @ 02:24:08)

未知来源变更 (请确认)
[ ] config.json          (可能由 Bash: sed 产生)
[ ] styles.css           (可能由外部编辑器产生)

依赖警告:
   tests/test_api.py 第 14 行引用了 src/api.py 中
   将被删除的 `user_id` 参数。
   [强制一并回退 test_api.py] [忽略, 继续]

[ ] 全部回退 (包括未知来源)
[确认回退] [取消]

默认策略:

  • 所有 Agent 修改: 默认勾选
  • 所有未知来源: 默认不勾选
  • 用户可手动切换, 也可一键全部回退

4.3 回退执行与 Redo 支持

interface UndoEntry {
  turn: number;
  fromCommit: string;
  toCommit: string;
  selectedFiles: string[];
}

function undoTurn(
  turnId: number,
  selectedFiles: Set<string>,
  forceIncludeDeps: boolean = false
): void {
  const sg = new ShadowGit(projectPath);
  const ledger = Ledger.load(projectPath);

  // 1. 获取 Commit A (baseline) 和 Commit B (final)
  const baseline = `turn-${turnId}-baseline`;
  const final = `turn-${turnId}-final`;

  // 2. 依赖检查(可选硬保护)
  if (!forceIncludeDeps) {
    const deps = analyzeCrossFileDeps(selectedFiles, baseline, final);
    if (deps) {
      throw new DependencyWarning(deps);
    }
  }

  // 3. 先创建 undo-safety commit(当前状态快照, 用于 redo)
  const safetyCommit = sg.commit(`turn-${turnId}-undo-safety`);

  // 4. 执行部分回退: 对选中文件 checkout baseline 版本
  for (const f of selectedFiles) {
    sg.run("checkout", baseline, "--", f);
  }

  // 5. 记录 redo 锚点
  RedoStack.push({
    turn: turnId,
    fromCommit: safetyCommit,
    toCommit: baseline,
    selectedFiles: Array.from(selectedFiles),
  });
}

Redo 实现:

function redoLastUndo(): void {
  const entry = RedoStack.pop();
  if (!entry) return;

  const sg = new ShadowGit(projectPath);
  // 直接 checkout undo-safety commit 的选中文件
  for (const f of entry.selectedFiles) {
    sg.run("checkout", entry.fromCommit, "--", f);
  }
}

重要: 原始方案中 RedoStack 是纯内存结构, 程序重启后丢失。社区项目 pi-rewind 采用 Git refs 存储 checkpoints (refs/pi-checkpoints/), 天然具备持久化能力, "survives restarts"。建议将 RedoStack 迁移为 Shadow Git 的自定义 ref, 详见 第 10 章补充建议

资料来源: pi-rewind GitHub 仓库。
https://github.com/arpagon/pi-rewind


5. 配置与排除规则

5.1 默认排除路径

# ~/.agent/config.yml
default_undo_skip:
  - "node_modules/**"
  - ".venv/**"
  - "venv/**"
  - "dist/**"
  - "build/**"
  - "*.log"
  - ".env"
  - ".env.*"
  - "*.tmp"
  - "*.temp"
  - ".DS_Store"
  - "Thumbs.db"
  # 大文件阈值
  max_file_size_mb: 10

5.2 项目级覆盖

# .agent.yml (项目根目录, 可选)
undo_skip:
  - "generated/**"      # 项目特定的生成代码
  - "coverage/**"
  - ".next/**"
  - "out/**"

# 是否开启跨文件依赖硬保护
dependency_guard: true

# 是否允许 undo 时自动一并回退依赖文件
auto_include_deps: false

5.3 排除规则在 Shadow Git 中的应用

# Shadow Git 初始化时, 写入独立的 exclude 规则
cat > "$SHADOW_DIR/info/exclude" <<EOF
node_modules/
.venv/
dist/
build/
*.log
.env
EOF

# commit 时只 add 未被排除且已跟踪/变更的文件
# 避免 git add -A 扫描被排除的目录
git ls-files -m -o --exclude-standard | xargs git add

6. 性能优化与工程兜底

6.1 大仓库优化

问题 优化手段
git add -A 扫描慢 改用 git ls-files -m -o --exclude-standard 只处理变更文件
index 更新慢 使用 git update-index --add --remove --stdin 批量处理
packfile 膨胀 定期 git gc --prune=now(由 Agent 后台任务触发, 非实时)
二进制文件 超过 max_file_size_mb 的文件直接忽略, 不纳入 Shadow Git

6.2 无 Git 项目优化

对于无 Git 的项目, Shadow Git 是唯一的 Git 实例, 不存在与用户 Git 的竞争。初始化时自动执行:

git config core.fileMode false
git config core.ignoreCase true  # Windows 兼容

6.3 大仓库熔断机制(补充)

OpenCode 的 /undo 实现曾因在包含 54,000 个文件, 总计 45GB 的仓库中执行 git add . 扫描, 被社区用户批评为 "insane behavior"。Shadow Git 应在 snapshot 前增加硬性熔断:

const SNAPSHOT_FILE_COUNT_CAP = 10_000;
const SNAPSHOT_TOTAL_SIZE_CAP_MB = 1_024;

function shouldFallbackToLedgerOnly(sg: ShadowGit, projectPath: string): boolean {
  const result = sg.run("ls-files", "-o", "-m", "--exclude-standard");
  const files = result.stdout.trim().split("\n").filter((f) => f);

  if (files.length > SNAPSHOT_FILE_COUNT_CAP) {
    return true;
  }

  let totalBytes = 0;
  for (const f of files) {
    try {
      const stat = fs.statSync(path.join(projectPath, f));
      totalBytes += stat.size;
    } catch {
      continue;
    }
    if (totalBytes > SNAPSHOT_TOTAL_SIZE_CAP_MB * 1024 * 1024) {
      return true;
    }
  }

  return false;
}

降级策略: 触发熔断时, 仅将 Ledger 中已记录的显式修改文件加入 Shadow Git index, 未记录的外部变更不纳入本轮 snapshot, UI 中明确提示用户。

资料来源: OpenCode 官方文档。
https://opencode.ai/docs/


7. 与现有方案对比

维度 Claude Code Codex OpenCode Shadow Git (本方案)
存储位置 ~/.claude/file-history/ 用户 .git 内部 用户 .git/objects/ ~/.agent/shadows/
污染用户 Git
无 Git 项目可用
追踪 Bash 变更 部分
区分变更来源 仅工具调用 工具调用 + 启发式
用户可控排除 是 (白名单 + UI)
Redo 支持 是 (undo-safety commit)
跨文件一致性保护 可配置 (软/硬保护)
环境隔离 完全隔离 零隔离 零隔离 完全隔离
磁盘开销 高(多份完整文件) 中 (Git packfile)
大仓库性能 中 (需优化)

8. 风险与边界情况

8.1 用户中途在 IDE 编辑(并发冲突)

场景: Agent 正在 Turn 7 中改代码, 用户同时在 IDE 修改了 styles.css

处理:

  • Shadow Git 的 Commit B 会捕获用户的并发修改(因为它基于最终磁盘状态)
  • Ledger 不会记录 styles.css(非 Agent 工具调用)
  • Undo 时 styles.css 标记为未知来源, 默认不勾选
  • 用户若手动勾选回退 styles.css, 则其 IDE 修改丢失——这是知情同意的结果

8.2 Agent Bash 命令删除文件

场景: Agent 执行 rm -rf src/legacy/

处理:

  • Shadow Git 的 Commit A 有 legacy/ 目录内容
  • Commit B 中该目录消失
  • Diff 显示文件删除
  • Ledger 中 Bash 命令可能记录 paths_touched: ["src/legacy/"](尽力匹配)
  • Undo 时, 若用户勾选恢复, Shadow Git checkout 会自动恢复被删除的文件

8.3 跨 Turn 依赖断裂

场景: Turn 7 创建了 user_id 字段, Turn 8 基于该字段写了新功能。用户 undo Turn 7。

处理:

  • 系统不阻止跨 Turn 回退(这是用户的权力)
  • 但会在 Turn 8 的 Commit A 中检测到 user_id 已不存在, 而 Turn 8 的代码还在引用它
  • 下次 Agent 执行时, 模型会看到编译/测试错误, 自行修复或询问用户
  • 这是延迟一致性检查, 而非回退时的硬阻塞

9. 实现优先级

优先级 模块 说明
P0 Shadow Git 封装 + 环境隔离 核心基础设施, 必须完全隔离
P0 TurnGuard (Commit A/B) 生命周期管理, 中断安全
P0 基础 /undo + 全量回退 先跑通全局回退, 再优化部分回退
P1 Ledger Pre-hook (来源标记) 区分 Agent/外部变更
P1 UI 面板 + 用户排除交互 精确回退的交互层
P2 依赖分析 + 硬保护提示 跨文件一致性保护
P2 Redo Stack 基于 undo-safety commit
P3 性能优化 (ls-files, gc) 大仓库体验

10. 补充建议: 社区痛点与工程完善

基于社区对 Claude Code、OpenCode、Codex、pi-rewind 等方案的实际反馈, 以下 6 项补充建议可显著提升 Shadow Git 的工程完整度。

10.1 P1 - 大仓库熔断机制

问题背景: OpenCode 的 /undo 实现曾因在包含 54,000 个文件, 总计 45GB 的仓库中执行 git add . 扫描, 被社区用户批评为 "insane behavior"。全量扫描导致 undo 操作耗时数十秒, CPU 与内存占用飙升。

设计建议: Shadow Git 的 snapshot() 方法应在执行全量 add 前, 先进行硬性熔断检查(见 6.3 节)。触发熔断时降级为"仅追踪 Ledger 中显式修改的文件", 跳过 Shadow Git 全量 snapshot。

收益: 将 undo 的最坏-case 时间从 O(仓库总文件数) 降为 O(Agent 修改文件数)。

资料来源: OpenCode 官方文档。
https://opencode.ai/docs/

10.2 P1 - 自动 Prune 与磁盘上限

问题背景: OpenCode 的早期 snapshot 机制曾出现 533GB+ 的孤儿临时 pack 文件, 原因是 write-tree 生成的对象未被及时清理。社区项目 pi-rewind 采用明确的保留策略: 每个会话最多保留 50 个 checkpoints, 超出后自动清理最旧的快照。

关键澄清: Shadow Git 的 checkpoint 不是只存 commit hash, 而是存完整的 Git 对象(blob + tree + commit)。Blob 存文件内容, Tree 存目录结构, Commit 才存 metadata。因此空间占用公式为:

总空间 ≈ Sigma(每轮变更文件的内容大小) + Tree 对象开销 + Packfile 压缩收益

设计建议: 引入按磁盘上限而非固定数量的清理策略:

# ~/.agent/config.yml
shadow_git:
  max_disk_mb_per_project: 2_048   # 2GB 硬上限
  max_turn_age_days: 30              # 30 天前的 turn 自动清理
  gc_interval_turns: 10              # 每 10 轮触发一次轻量 gc

紧急清理逻辑: 优先删除 dangling commit/tree; 若仍超限, 按 LRU 策略删除最旧的 turn snapshot。

资料来源: OpenCode 官方文档; pi-rewind GitHub 仓库。
https://opencode.ai/docs/
https://github.com/arpagon/pi-rewind

10.3 P2 - 文件 mtime 保留策略

问题背景: OpenCode 使用 git checkout <tree> -- . 恢复文件时, 会更新所有 tracked 文件的 mtime(修改时间戳)。这导致构建系统(Make, Webpack, Vite, Gradle)误判文件已变更, 触发不必要的全量重新编译。

设计建议: 在 checkout_files() 执行后, 恢复文件至 baseline commit 的原始 mtime:

function checkoutFiles(sg: ShadowGit, projectPath: string, commit: string, files: string[]): void {
  sg.run("checkout", commit, "--", ...files);

  for (const f of files) {
    const filePath = path.join(projectPath, f);
    if (!fs.existsSync(filePath)) {
      continue;
    }
    const result = sg.run("log", "-1", "--format=%ct", commit, "--", f);
    const tsStr = result.stdout.trim();
    if (tsStr) {
      const mtime = parseInt(tsStr, 10);
      const atime = mtime;
      fs.utimesSync(filePath, atime, mtime);  // 同时设置 atime = mtime
    }
  }
}

备选方案: 在 snapshot 时额外记录每个文件的 (path, mtime_ns, size) 元数据到 Ledger, undo 时直接用 Ledger 恢复, 不依赖 git log。

资料来源: OpenCode 官方文档。
https://opencode.ai/docs/

10.4 P2 - Redo Stack 持久化

问题背景: 原始方案中 RedoStack 是纯内存结构, 程序重启或崩溃后丢失。pi-rewind 采用 Git refs 存储 checkpoints (refs/pi-checkpoints/), 天然具备持久化能力, "survives restarts"。Claude Code 用户也多次反馈希望 checkpoint 能跨会话保留。

设计建议: 将 RedoStack 从内存列表迁移为 Shadow Git 的自定义 ref:

const REDO_REF = "refs/agent/redo-stack";

interface RedoEntry {
  turn: number;
  fromCommit: string;
  toCommit: string;
  selectedFiles: string[];
  undoTimestamp: string;
  sessionId: string;
}

class RedoManager {
  private sg: ShadowGit;

  constructor(shadowGit: ShadowGit) {
    this.sg = shadowGit;
  }

  push(entry: RedoEntry): void {
    const stack = this.loadStack();
    stack.push(entry);
    const blobContent = JSON.stringify(stack, null, 2);
    const result = this.sg.run("hash-object", "-w", "--stdin");
    // 注: 实际实现需通过 stdin 传入 blobContent, spawnSync 支持 input 参数
    this.sg.run("update-ref", REDO_REF, result.stdout.trim());
  }

  pop(): RedoEntry | null {
    const stack = this.loadStack();
    if (stack.length === 0) {
      return null;
    }
    const entry = stack.pop()!;
    const blobContent = JSON.stringify(stack, null, 2);
    const result = this.sg.run("hash-object", "-w", "--stdin");
    this.sg.run("update-ref", REDO_REF, result.stdout.trim());
    return entry;
  }

  private loadStack(): RedoEntry[] {
    try {
      const result = this.sg.run("show", REDO_REF);
      return JSON.parse(result.stdout) as RedoEntry[];
    } catch {
      return [];
    }
  }
}

持久化结构包含: turn, from_commit, to_commit, selected_files, undo_timestamp, session_id

资料来源: pi-rewind GitHub 仓库; Claude Code GitHub Issues。
https://github.com/arpagon/pi-rewind
anthropics/claude-code#18417

10.5 P2 - 跨仓库(Monorepo)映射

问题背景: OpenCode 的 /undo 明确不恢复跨仓库修改: 如果 Agent 在一个会话中同时修改了主仓库和子模块/外部目录的文件, undo 只会恢复当前工作目录下的变更, 其他仓库的变更被静默忽略。这在 monorepo(pnpm workspace, Turborepo, Nx)或微前端项目中是常见痛点。

设计建议: 将 ShadowGit 从单仓库单实例扩展为会话级多实例管理:

interface SnapshotCommits {
  [repoName: string]: string;
}

class ShadowGitSession {
  private primary: ShadowGit;
  private secondary: Map<string, ShadowGit> = new Map();

  constructor(primaryProjectPath: string) {
    this.primary = new ShadowGit(primaryProjectPath);
  }

  registerSecondaryRepo(repoPath: string): void {
    const absPath = path.resolve(repoPath);
    if (!this.secondary.has(absPath)) {
      this.secondary.set(absPath, new ShadowGit(absPath));
    }
  }

  snapshotAll(turnId: number): SnapshotCommits {
    const commits: SnapshotCommits = {
      primary: this.primary.snapshot(`turn-${turnId}-final`),
    };
    for (const [name, sg] of this.secondary.entries()) {
      commits[name] = sg.snapshot(`turn-${turnId}-final`);
    }
    return commits;
  }
}

自动发现机制: Agent 在 tool call 中修改文件时, 若文件路径不在 primary project 内, 自动调用 register_secondary_repo()。支持通过 .agent.yml 预配置 monorepo 子包路径。

资料来源: OpenCode 官方文档。
https://opencode.ai/docs/

10.6 P3 - 会话分支(Conversation Branching)

问题背景: 社区对 AI Coding Agent 的"探索式编程"有强烈需求: 用户希望在某一轮对话后分叉出多条路径(方案 A / 方案 B), 独立探索后对比效果, 再决定保留哪条分支。

现有方案支持情况:

  • Pydantic AI Harness: 明确支持 checkpointing capability for "branching exploration and recovery from bad paths"
  • Codex CLI: 支持 --branch 参数, 在指定 checkpoint 上创建新分支继续工作
  • Claude Code: 社区高票请求将 checkpoint/rollback 迁移到 VSCode 扩展, 提供 timeline view; 掘金文章提到 claude --continue --fork-session 类似 git checkout -b

设计建议: 在 Shadow Git 的线性 snapshot 基础上, 引入轻量级分支语义, 利用 Git 原生 branch + tag 机制, 零额外存储成本:

interface BranchInfo {
  name: string;
  tag: string;
  commit: string;
  parentTurn: number;
}

class ShadowGitBranching {
  private sg: ShadowGit;

  constructor(shadowGit: ShadowGit) {
    this.sg = shadowGit;
  }

  forkTurn(fromTurn: number, branchName: string): string {
    const baseCommit = `turn-${fromTurn}-final`;
    const branchTag = `branch-${branchName}-baseline`;
    this.sg.run("tag", branchTag, baseCommit);
    return branchTag;
  }

  listBranches(): BranchInfo[] {
    const result = this.sg.run("tag", "-l", "branch-*");
    const branches: BranchInfo[] = [];
    const tags = result.stdout.trim().split("\n").filter((t) => t);

    for (const tag of tags) {
      const commit = this.sg.run("rev-parse", tag).stdout.trim();
      branches.push({
        name: tag.replace("branch-", "").replace("-baseline", ""),
        tag,
        commit,
        parentTurn: this.inferParentTurn(commit),
      });
    }
    return branches;
  }

  private inferParentTurn(commit: string): number {
    const result = this.sg.run("log", "-1", "--format=%s", commit);
    const msg = result.stdout.trim();
    const match = msg.match(/turn-(\d+)/);
    return match ? parseInt(match[1], 10) : -1;
  }
}

UI 集成: 在 /undo 面板中增加"Fork Branch"按钮; 分支切换时工作目录状态通过 git checkout 原子切换; 每个分支拥有独立的 Ledger 记录。

资料来源: Pydantic AI Harness GitHub Issues; 掘金 "像 Git 一样思考你的 Claude Code 会话"; Claude Code GitHub Issues。
pydantic/pydantic-ai-harness#196
https://juejin.cn/post/7632341519271510026
anthropics/claude-code#16976


11. 附录: Git 对象模型与空间占用原理

11.1 Git 三种核心对象

把 Git 想象成一台时间机器版的文件复印机:

Git 对象 类比 存什么
Blob 单张纸的内容快照 文件内容(去重, 按内容 hash)
Tree 文件夹的标签页 目录结构: 文件名 -> blob hash 的映射表
Commit 封面上贴的便利贴 时间、作者、备注、指向哪个 tree

11.2 Tree 对象的具体结构

tree  # 对象类型
├── 100644 blob a1b2c3d  src/api.py      # 文件: 权限 + blob hash + 文件名
├── 100644 blob e4f5g6h  src/models.py
├── 040000 tree  x7y8z9w  src/utils/      # 子目录: 指向另一个 tree 对象
└── 100644 blob b2c3d4e  README.md

关键特性:

  • 文件名存在 tree 里, 不在 blob 里。重命名文件(内容不变)会生成新的 tree, 但复用旧的 blob。
  • 每个 commit 至少对应一个 root tree(项目根目录的 tree)。
  • 目录嵌套越深, tree 对象越多: src/utils/helpers/ 会生成 root tree -> src tree -> utils tree -> helpers tree 的链条。

11.3 为什么 Shadow Git 需要 Prune?

虽然单个 tree 只有几十到几百字节, 但数量累积会导致膨胀:

Turn 7:  commit-7  -> tree-7-root  -> tree-7-src  -> tree-7-utils
Turn 8:  commit-8  -> tree-8-root  -> tree-8-src  -> tree-8-utils

如果 Agent 每轮只改了一个文件, Git 的 packfile 会把内容压缩得很好, 但tree 对象无法被增量压缩(它们是结构化数据, 不是文本), 所以每轮都会产生新的 tree 对象。1000 轮下来, tree 对象可能累积到几十 MB。

更关键的是: 如果某轮 commit 被 prune 删除了, 但 tree 对象还被其他 commit 引用, 它就不会被 gc 清理。直到没有任何 commit/tag 指向它时, 才真正释放。

11.4 空间占用公式

总空间 ≈ Sigma(每轮变更文件的内容大小) + Tree 对象开销 + Packfile 压缩收益

导致空间爆炸的场景:

  • 大文件误追踪(Agent 生成了 10MB 日志文件)
  • 二进制文件变更(图片、PDF、模型权重, 每次修改存完整副本)
  • Dangling 对象(undo-safety commit 被 redo 后, 旧 commit 不再被引用但 gc 前一直占空间)

12. 附录: 资料来源汇总

资料名称 类型 链接 引用内容
OpenCode 官方文档 官方文档 https://opencode.ai/docs/ /undo /redo 机制、45GB/54K 文件扫描问题、533GB 孤儿 pack、git checkout mtime 问题、跨仓库限制
pi-rewind GitHub 仓库 开源项目 https://github.com/arpagon/pi-rewind 50 checkpoints/session 保留策略、Git refs 持久化设计(refs/pi-checkpoints/)、Redo stack、Smart filtering
Pydantic AI Harness GitHub Issues 开源项目 pydantic/pydantic-ai-harness#196 Conversation Branching 设计、Checkpointing capability (save, rewind, fork)
Claude Code GitHub Issues 社区讨论 anthropics/claude-code#16976 Headless checkpoint restore / rewind API 请求
Claude Code GitHub Issues 社区讨论 anthropics/claude-code#18417 Native session persistence and context continuity
Claude Code GitHub Issues 社区讨论 anthropics/claude-code#27298 Layered memory system for persistent cross-session context
掘金 - Claude Code 六大核心系统深度解析 技术博客 https://juejin.cn/post/7625898031915450377 Checkpoint 回滚与安全保障、Git 快照能力
掘金 - 像 Git 一样思考你的 Claude Code 会话 技术博客 https://juejin.cn/post/7632341519271510026 Git 语义对应(commit/branch/worktree/reset)、fork session、rewind 不改写历史
掘金 - /rewind 完全指南 技术博客 https://juejin.cn/post/7626192815358279686 Bash 命令不被跟踪、手动编辑不被跟踪、checkpoint 与 Git 双保险
claude-howto GitHub 仓库 开源项目 https://github.com/luongnv89/claude-howto/blob/main/08-checkpoints/README.md Checkpoint 与 Git 的集成、持久性对比(Session-based vs Permanent)
Codex CLI GitHub 仓库 开源项目 https://github.com/openai/codex --branch 参数与 checkpoint 设计

Document Version: v2.0
Based on original Shadow Git design + community feedback analysis + multi-round engineering discussions
Compiled: 2026-05-21

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions