为什么每个 AI 编码 agent 都该有自己的 git worktree
把两个 agent 指向同一个 checkout,它们会互相破坏。这是解法——以及怎么让它自动发生。
问题
我用 AI 编码 agent 做开发——常常同时开着四个 Claude Code 或 Codex 会话,每个还会派生 sub-agent。有很长一段时间它们全在同一个 checkout 里干活。这就是那个陷阱。
一个 git 工作目录只有一个 HEAD:一个分支、一个 index、磁盘上一套文件。在里面跑多个 agent,它们不是在协作——而是在没有任何锁的情况下互相覆盖同一批物理文件。三起真实事故:
- 一个 agent 在构建中途
git switch。另一个 agent 的cargo正在编译,读到了撕裂的源码——一半旧分支、一半新分支——带着指向谁都没碰过的代码的幽灵报错失败。 - 两个 agent 改同一批文件。一个正改到一半,另一个也动了同样的文件,差点把对方未提交的工作 stash 掉。
- 一个会话一直住在共享的
main树里,手里攥着未提交的改动。别人合并后main在它脚下前进;git switch main被挡住(同一个分支只能在一处被 check out);一个无法合并的.pbxproj炸成了冲突。
不同分支也救不了你——这正是大多数人忽略的地方。分支只是共享历史里的一个标签;一个工作目录在同一时刻仍然只能 check out 一个分支。所以共用一个目录的两个 agent 只能不停地把它 git switch 来回切,而这一切换正是制造损坏的那一下。(未提交的改动根本不在任何分支上——它就是共享目录里的文件。)解药必须落在磁盘上:第二套文件。
这就是 worktree:git worktree add 把一个分支 check out 到它自己的目录,配自己的 HEAD 和 index,共享同一个仓库。每个 agent 拿到私有的文件,在物理上无法碰撞。「一个分支在它自己的目录里」本身就是 worktree——救你的是这个,而不是分支。
同样的 agent,两种布局:共用一个 checkout(互相碰撞) vs 各自一个 worktree(互不干扰)。
怎么配置
你可以完全不碰 git,甚至不用开终端。你在编辑器或 App 里用自然语言描述需求——「加一个加注滑块;在单独的 worktree 里做,做绿了再合并回来」——agent 替你跑 git(你只需批准那条命令)。Claude Code 和 Codex 都是会执行 shell 的 agent,所以「建 worktree」是它们做的事,不是你做的。配上下面的 hook,你连「在 worktree 里」都不用说。
Claude Code —— 一等公民支持
Claude Code 内置 worktree:你开口要一个(或者在 CLI 上用 --worktree flag 启动),它就跑 git worktree add、在你默认分支上拉出新树、在里面干活;做绿之后由你叫它合并回来——内置功能不会自己合并。给 sub-agent 加 isolation: worktree,它会拿到一个一次性 worktree,没有改动地收尾时自动移除。你全程都在说人话。
Codex —— 思路一样,注意 sandbox
Codex 没有内置 worktree 功能,但它也是会跑 shell 的 agent:你同样开口,它会自己跑 git worktree add。唯一一个真实的坑是 sandbox。Codex 的 workspace-write 模式只能写它的主工作区——也就是你启动它的那个目录(或用 -C 指定的目录)——外加任何额外的可写根目录。所以如果你在主仓库里启动 Codex,像 ~/.codex/worktrees/<repo>/<name> 这样位于树外的 worktree(本仓库的工具正是放在这里)默认是不可写的。三种解法:直接在 worktree 里启动 Codex(codex -C ~/.codex/worktrees/<repo>/<name>),让它成为工作区;或保持主仓库为工作区、用 --add-dir <那个目录> 把 worktree 加进来;或者,对于反复用到的布局,在 ~/.codex/config.toml 的 [sandbox_workspace_write].writable_roots 里列上父目录。把 worktree 放在主工作区之内,则这些都不需要。
新问题:怎么让每个新 session 和子 agent 都遵守这条规则
到这里,worktree 还是在你或 agent「记得开口」时才被建出来。可一旦有几十个 session 和 sub-agent,「记得」必然失守。解法是用 hook 把它变成结构性的——让 worktree 成为默认路径,而不是要记住的事:
- 一个 SessionStart hook:在共享
main树里打开的会话,会收到「先建 worktree 再编辑」的提醒。 - 一个 PreToolUse hook:当编辑目标落在
main树里时,硬阻断编辑工具(Edit/Write/MultiEdit)——于是 agent 对main的编辑被拒绝,被逼上 worktree。(覆盖:ALLOW_MAIN_EDIT=1。) - 提交/合并闸门(一个
pre-commithook 加一个 Bash PreToolUse hook),让工作只能通过受认可的合并进入main。
hook 把常规的编辑/提交/合并路径卡住;两个封装脚本把剩下的也变成一步——一个建 worktree(链接 .env、分配固定错开的备用端口、避开主栈的 :3001/:5173),一个做 --no-ff 合并并移除它、树脏就拒绝。
底层怎么运作
主仓库的 .git 下只有一个对象数据库、一套 ref。每个链接的 worktree 在 .git/worktrees/<name>/ 有一个小管理目录,存自己的 HEAD 和 index;它的工作目录里放的是一个 .git 文件(一行指针指回去),而不是目录。
| 状态 | 逐 worktree | 共享 |
|---|---|---|
| HEAD、index、工作文件、进行中的 merge/rebase | ✓ | |
| 对象、ref(分支/tag)、config、hooks、stash | ✓ |
这种拆分就是机制所在——也是为什么分支和 worktree 不可互换:worktree 是磁盘上的一个私有槽位,把某一个标签落地成真实文件。因为每个 worktree 拥有自己的 HEAD 和文件,两个可以同时停在不同分支;因为它们共享对象和 ref,一个里的 commit 在另一个里立刻可见。两个坑:stash 是仓库全局的(在一个里 push 的 stash 在所有里都看得到),以及同一个分支默认不能被 check out 两次——git 会拒绝,也会拒绝任何会去更新「别处已 check out 的分支」的操作。磁盘开销是工作集,而非历史:每个 worktree 重复 check out 的文件和构建产物(target/、node_modules/),所以 N 个 worktree ≈ N 倍工作集。
诚实的边界
- worktree 不消除合并冲突。隔离不等于协调:两个 worktree 改同几行,合并时照样冲突——worktree 只是把碰撞从「无声的线上损坏」挪成「一次你有意去解决的显式合并」。让分支小、短命,勤集成。
- 磁盘和重新构建的时间。每个 worktree 一次冷启动的
cargo build/npm install是实打实的若干 GB 和几分钟。 - 本地配置逐 worktree。新 worktree 没有你任何被 gitignore 的配置;把它链接进来。(server 二进制从 shell 读环境变量,而非
.env;cargo test需要DATABASE_URL,build/check不需要。) - 清理纪律。收尾 = 合并,然后
git worktree remove——别rm -rf(会留下陈旧条目)——再git worktree prune。
实用建议
- 让主树空闲停在干净的
main上,作为合并目标。 - 给每个 worktree 自己的备用端口 dev 栈和自己的
node_modules——别 symlink 到主仓库(Vite 的fs.allow会 403 掉 worktree 之外的字体)。 - 用 hook 强制,而不是靠自觉。塑造我们做法的那条洞察:一个后台 sub-agent 曾在明确的「不要合并」下合并到了
main,而没有任何环境变量能区分 sub-agent 和主线程——所以闸门必须默认拒绝,而不是一次信任检查。
总结
- 问题:一个 checkout + 多个 agent = 互相破坏(撕裂的构建、被冲掉的未提交工作)。不同分支救不了——分支只是共享历史里的一个名字,bug 在磁盘上。
- 解法:worktree = 把一个分支 check out 到它自己的目录(自己的 HEAD 和文件),架在共享仓库上——物理隔离,互不碰撞。
- 怎么用:别敲 git——用自然语言描述任务,agent 自己建 worktree。真正关键的 hook 不是「保护 main」(那是标配)——而是让每个新 session 和子 agent 都只剩 worktree 这一条能走通的路:一进 main 树就被提醒、想改 main 就被拒——worktree 仍由 agent 自己建,但绕不过去。
- 边界:worktree 消除的是工作树竞态,不是合并冲突——合并冲突靠「分支切小、勤合并」来压住。