本文档整合了原始 Shadow Git 架构设计、社区呼声分析、以及多轮对话中产生的工程补充建议。所有资料来源均经过验证。
编制依据: Claude Code, OpenCode, Codex, pi-rewind, Pydantic AI Harness 等主流方案的官方文档、开源仓库及社区反馈。
目录
- 设计目标与社区呼声分析
- 核心架构
- 运行时状态捕获流程
- Undo / Redo 交互设计
- 配置与排除规则
- 性能优化与工程兜底
- 与现有方案对比
- 风险与边界情况
- 实现优先级
- 补充建议: 社区痛点与工程完善
- 附录: Git 对象模型与空间占用原理
- 附录: 资料来源汇总
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. 附录: 资料来源汇总
Document Version: v2.0
Based on original Shadow Git design + community feedback analysis + multi-round engineering discussions
Compiled: 2026-05-21
目录
1. 设计目标与社区呼声分析
1.1 设计目标
解决现有三种主流方案(Claude Code 文件快照、Codex Ghost Commit、OpenCode git write-tree)的核心痛点:
1.2 社区呼声符合度评估
基于对 Claude Code、OpenCode、Codex、pi-rewind、Pydantic AI Harness 等方案的用户反馈与官方文档分析, Shadow Git 方案的社区呼声符合度约为 75~80%。
1.3 高度符合社区呼声的设计(约 60%)
/undo只能回退对话、不能回退文件1.4 部分符合、但存在缺口的设计(约 15~20%)
git add .扫描 45GB/54K 文件目录被社区怒批"insane behavior"paths_touched是**"尽力而为"(best-effort)**, 通过正则匹配。社区需要更可靠的追踪/undo不恢复跨仓库修改$PWD为单一 worktree, 没有设计多仓库会话的 Shadow Git 映射git checkout -- .会更新所有 tracked 文件的 mtime, 导致构建系统误判git checkout <commit> -- <file>, 没有提到 mtime 保留策略1.5 社区有强烈呼声、但方案未覆盖的领域(约 10~15%)
2. 核心架构
2.1 双层存储模型
2.2 Shadow Git 初始化
2.3 环境隔离封装
所有 Shadow Git 操作必须通过封装层执行, 绝不依赖环境变量继承:
为什么这样足够隔离?
GIT_DIR指向项目外的裸仓库, 与用户.git完全无关GIT_CONFIG_GLOBAL=/dev/null屏蔽用户~/.gitconfigcore.hooksPath=/dev/null确保任何 hook 都不执行3. 运行时状态捕获流程
3.1 Turn 生命周期与 Commit 策略
3.2 中断检测与 Commit B 的保障
Commit B 必须在以下所有场景被触发:
SIGINT, 设置中断标志finally块强制执行waitpid后强制执行/undo3.3 工具调用 Pre-hook (Ledger)
在 Agent 执行
edit_file/write_file前, 同步记录:注意:
bash命令的paths_touched是尽力而为的(通过正则匹配或运行时 hook), 不是 100% 可靠。它仅用于 UI 标记, 不作为回退的安全边界。4. Undo / Redo 交互设计
4.1
/undo执行流程4.2 UI 面板设计
默认策略:
4.3 回退执行与 Redo 支持
Redo 实现:
5. 配置与排除规则
5.1 默认排除路径
5.2 项目级覆盖
5.3 排除规则在 Shadow Git 中的应用
6. 性能优化与工程兜底
6.1 大仓库优化
git add -A扫描慢git ls-files -m -o --exclude-standard只处理变更文件git update-index --add --remove --stdin批量处理git gc --prune=now(由 Agent 后台任务触发, 非实时)max_file_size_mb的文件直接忽略, 不纳入 Shadow Git6.2 无 Git 项目优化
对于无 Git 的项目, Shadow Git 是唯一的 Git 实例, 不存在与用户 Git 的竞争。初始化时自动执行:
6.3 大仓库熔断机制(补充)
OpenCode 的
/undo实现曾因在包含 54,000 个文件, 总计 45GB 的仓库中执行git add .扫描, 被社区用户批评为 "insane behavior"。Shadow Git 应在 snapshot 前增加硬性熔断:降级策略: 触发熔断时, 仅将 Ledger 中已记录的显式修改文件加入 Shadow Git index, 未记录的外部变更不纳入本轮 snapshot, UI 中明确提示用户。
7. 与现有方案对比
~/.claude/file-history/.git内部.git/objects/~/.agent/shadows/8. 风险与边界情况
8.1 用户中途在 IDE 编辑(并发冲突)
场景: Agent 正在 Turn 7 中改代码, 用户同时在 IDE 修改了
styles.css。处理:
styles.css(非 Agent 工具调用)styles.css标记为未知来源, 默认不勾选styles.css, 则其 IDE 修改丢失——这是知情同意的结果8.2 Agent Bash 命令删除文件
场景: Agent 执行
rm -rf src/legacy/。处理:
legacy/目录内容paths_touched: ["src/legacy/"](尽力匹配)checkout会自动恢复被删除的文件8.3 跨 Turn 依赖断裂
场景: Turn 7 创建了
user_id字段, Turn 8 基于该字段写了新功能。用户 undo Turn 7。处理:
user_id已不存在, 而 Turn 8 的代码还在引用它9. 实现优先级
/undo+ 全量回退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 修改文件数)。
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。因此空间占用公式为:
设计建议: 引入按磁盘上限而非固定数量的清理策略:
紧急清理逻辑: 优先删除 dangling commit/tree; 若仍超限, 按 LRU 策略删除最旧的 turn snapshot。
10.3 P2 - 文件 mtime 保留策略
问题背景: OpenCode 使用
git checkout <tree> -- .恢复文件时, 会更新所有 tracked 文件的 mtime(修改时间戳)。这导致构建系统(Make, Webpack, Vite, Gradle)误判文件已变更, 触发不必要的全量重新编译。设计建议: 在
checkout_files()执行后, 恢复文件至 baseline commit 的原始 mtime:备选方案: 在 snapshot 时额外记录每个文件的
(path, mtime_ns, size)元数据到 Ledger, undo 时直接用 Ledger 恢复, 不依赖 git log。10.4 P2 - Redo Stack 持久化
问题背景: 原始方案中 RedoStack 是纯内存结构, 程序重启或崩溃后丢失。pi-rewind 采用 Git refs 存储 checkpoints (
refs/pi-checkpoints/), 天然具备持久化能力, "survives restarts"。Claude Code 用户也多次反馈希望 checkpoint 能跨会话保留。设计建议: 将 RedoStack 从内存列表迁移为 Shadow Git 的自定义 ref:
持久化结构包含:
turn,from_commit,to_commit,selected_files,undo_timestamp,session_id。10.5 P2 - 跨仓库(Monorepo)映射
问题背景: OpenCode 的
/undo明确不恢复跨仓库修改: 如果 Agent 在一个会话中同时修改了主仓库和子模块/外部目录的文件, undo 只会恢复当前工作目录下的变更, 其他仓库的变更被静默忽略。这在 monorepo(pnpm workspace, Turborepo, Nx)或微前端项目中是常见痛点。设计建议: 将 ShadowGit 从单仓库单实例扩展为会话级多实例管理:
自动发现机制: Agent 在 tool call 中修改文件时, 若文件路径不在 primary project 内, 自动调用
register_secondary_repo()。支持通过.agent.yml预配置 monorepo 子包路径。10.6 P3 - 会话分支(Conversation Branching)
问题背景: 社区对 AI Coding Agent 的"探索式编程"有强烈需求: 用户希望在某一轮对话后分叉出多条路径(方案 A / 方案 B), 独立探索后对比效果, 再决定保留哪条分支。
现有方案支持情况:
--branch参数, 在指定 checkpoint 上创建新分支继续工作claude --continue --fork-session类似git checkout -b设计建议: 在 Shadow Git 的线性 snapshot 基础上, 引入轻量级分支语义, 利用 Git 原生 branch + tag 机制, 零额外存储成本:
UI 集成: 在
/undo面板中增加"Fork Branch"按钮; 分支切换时工作目录状态通过git checkout原子切换; 每个分支拥有独立的 Ledger 记录。11. 附录: Git 对象模型与空间占用原理
11.1 Git 三种核心对象
把 Git 想象成一台时间机器版的文件复印机:
11.2 Tree 对象的具体结构
关键特性:
src/utils/helpers/会生成root tree -> src tree -> utils tree -> helpers tree的链条。11.3 为什么 Shadow Git 需要 Prune?
虽然单个 tree 只有几十到几百字节, 但数量累积会导致膨胀:
如果 Agent 每轮只改了一个文件, Git 的 packfile 会把内容压缩得很好, 但tree 对象无法被增量压缩(它们是结构化数据, 不是文本), 所以每轮都会产生新的 tree 对象。1000 轮下来, tree 对象可能累积到几十 MB。
更关键的是: 如果某轮 commit 被 prune 删除了, 但 tree 对象还被其他 commit 引用, 它就不会被 gc 清理。直到没有任何 commit/tag 指向它时, 才真正释放。
11.4 空间占用公式
导致空间爆炸的场景:
12. 附录: 资料来源汇总
/undo/redo机制、45GB/54K 文件扫描问题、533GB 孤儿 pack、git checkout mtime 问题、跨仓库限制refs/pi-checkpoints/)、Redo stack、Smart filtering--branch参数与 checkpoint 设计Document Version: v2.0
Based on original Shadow Git design + community feedback analysis + multi-round engineering discussions
Compiled: 2026-05-21