Git worktrees 并非编码代理的隔离边界
摘要
本文解释了 Git worktrees 并未为 AI 编码代理提供真正的隔离,因为它们与父仓库共享钩子、配置和引用,从而允许代理在主机上执行任意代码。基准测试表明,适当隔离的克隆具有可比的性能,挑战了 worktrees 既隔离又廉价的观点。
暂无内容
查看缓存全文
缓存时间: 2026/07/30 16:51
# Git 工作树不是 AI 编码代理的隔离边界
来源:https://fletch.sh/blog/git-worktrees-vs-clones-for-ai-agents/
所有文章 (https://fletch.sh/blog)
git
大多数并行运行 AI 编码代理的工具都会为每个代理分配一个 Git 工作树。工作树与你的实际仓库共享引用、配置、暂存区和钩子。以下粘贴即可运行的复现示例展示了一个代理如何在你主仓库中执行代码并重写你的提交身份,同时还有基准测试证明,一个完全隔离的克隆成本相同。
给一个编码代理分配自己的 Git 工作树(这是大多数并行运行代理工具的做法),这个代理就可以安装一个钩子,这个钩子会在你下次*你自己*在实际仓库中提交时运行。它可以重写你自己提交的作者邮箱。它可以将另一个代理的暂存项弹出到自己的树中。这一切都不需要漏洞或特殊命令。工作树从来就不是一个边界。它只是附加到同一个 `.git` 的第二个工作目录,而所有重要的东西都存在于那个 `.git` 中。
工作树之所以成为这里的默认选择,是因为它是最明显的答案。一个命令,没有重复历史,大约一秒钟就能获得第二个检出。其宣传是几乎零成本的隔离,但这两部分实际上都是错误的:隔离远没有“隔离工作树”这个词暗示的那么严格,而正确做法的成本其实是一样的。我在这篇文章的早期版本中对成本部分的判断是错误的,直到我进行了测量。
在一个链接的工作树内部,`.git` 不是一个目录,而是一个文件:
```
$ cat ../my-worktree/.git
gitdir: /path/to/repo/.git/worktrees/my-worktree
```
一切都源于这个路径。Git 将仓库状态分为每个工作树的状态和公共状态,并且它会告诉你哪些是哪些:
```
$ cd ../my-worktree
$ git rev-parse --git-dir # 每个工作树
/path/to/repo/.git/worktrees/my-worktree
$ git rev-parse --git-common-dir # 与父仓库和所有同级共享
/path/to/repo/.git
```
每个工作树的状态是一个简短的列表:`HEAD`、索引、`ORIG_HEAD` 以及一些 bisect 和 rebase 文件。其他所有内容都通过 `--git-common-dir` 解析回原始仓库:
- 对象存储(`.git/objects`),这是关键,并且可以安全共享。
- 引用和分支(`refs/heads`、`packed-refs`)。每个工作树共享同一个命名空间。
- 配置(`.git/config`)。
- 暂存区(`refs/stash`)。
- **钩子**(`.git/hooks`),共享目录在这里变成了任意代码执行。
所以隔离是存在的,但范围很窄:你的工作目录、`HEAD` 和索引。这些东西有用,但它们不是边界。边界意味着限制在工作树内的进程无法触及之外的状态,而第二个列表恰恰列出了它可以触及的方式。“隔离工作树”这个说法被广泛使用,出现在工具描述和建议讨论中,却从未明确指的是哪个列表。
## 这让代理能够做什么
先说最糟糕的情况。下面的每个代码块都会创建自己的仓库,所以你可以将它们粘贴到一个空目录中,按任意顺序运行。以下每一行输出都是真实的,在 Git 2.50.1 上捕获。
### 在你的主机上执行代码
`.git/hooks` 位于公共目录中。因此,从工作树内部安装的钩子会安装到父仓库中,并且下次你在父仓库中运行触发命令时,它会以你的身份运行。
```
git init -q --initial-branch=main hook-demo
cd hook-demo
git commit -q --allow-empty -m init
git branch agent-work
git worktree add -q ../hook-agent agent-work
# "隔离"工作树将钩子写入父仓库的 .git
cd ../hook-agent
cat > "$(git rev-parse --git-common-dir)/hooks/pre-commit" <<'EOF'
#!/bin/sh
echo "*** hook running as $(whoami) in $(pwd) ***"
EOF
chmod +x "$(git rev-parse --git-common-dir)/hooks/pre-commit"
# 现在你,在自己的仓库中,做一个普通提交
cd ../hook-demo
git commit -q --allow-empty -m "an ordinary commit"
# -> *** hook running as you in /path/to/hook-demo ***
```
这也说明了为什么工作树不能作为容器化代理的隔离层。将链接工作树交给容器意味着要挂载父仓库的实际 `.git`,因为工作树自己的状态就在那里。该挂载点主机端的可写 `.git/hooks` 会在主机上运行,容器就不再有意义。
### 重写你的提交者身份
配置文件是共享的,因此在工作树内执行 `git config` 会写入父仓库的 `.git/config`:
```
git init -q --initial-branch=main id-demo
cd id-demo
git commit -q --allow-empty -m init
git branch agent-work
git worktree add -q ../id-agent agent-work
cd ../id-agent
git config user.email "[email protected]"
cd ../id-demo
git config user.email
# -> [email protected]
git config --show-origin user.email
# -> file:.git/config [email protected]
git commit -q --allow-empty -m "a commit you made yourself"
git log -1 --format='%an <%ae>'
# -> Your Name <[email protected]>
```
仔细看最后两行。这不是代理的提交。这是你的提交,在你的仓库里,却带有别人设置的作者信息。
### 取走另一个工作树的暂存项
每个仓库只有一个 `refs/stash`,它的行为就像一个在无协调写入者之间共享的单一栈:
```
git init -q --initial-branch=main stash-demo
cd stash-demo
echo original > file.txt && git add file.txt && git commit -q -m "add file"
git branch branch-a
git branch branch-b
git worktree add -q ../stash-a branch-a
git worktree add -q ../stash-b branch-b
cd ../stash-a
echo "AGENT A PRECIOUS WORK" > file.txt
git stash -q # A 暂存它的工作
cd ../stash-b
git stash pop # B 弹出它,完全不需要请求
cat file.txt
# -> AGENT A PRECIOUS WORK
cd ../stash-a
git stash list # -> empty
cat file.txt # -> original. A 的工作现在在 B 的树中
```
有一个细节很关键,这就是为什么网上流传的一些版本不工作:`file.txt` 在分支创建*之前*就已经提交了,所以它在两个工作树中都是被跟踪的。简单的 `git stash` 会忽略未跟踪的文件,所以如果是一个全新文件,它会输出 `No local changes to save`,不暂存任何东西,然后 `pop` 会失败,提示 `No stash entries found`。请使用已跟踪的文件,或者 `git stash -u`。
### 重写同级的引用
每个工作树都写入同一个 `refs/heads` 和同一个对象存储。一个运行 `git gc` 的代理会为整个仓库(包括父仓库)执行垃圾回收。一个强制更新分支、变基或使用 `git reset --hard` 的代理,会修改其他工作树解析时使用的引用,一个代理孤立的提交可能在另一个代理脚下变得无引用。
这些都不是特殊命令。它们是代理在混乱并决定清理时可能会使用的东西。
### 分支名冲突
共享引用还意味着 Git 不允许在两个工作树中使用同一个分支:
```
$ git worktree add ../probe main
Preparing worktree (checking out 'main')
fatal: 'main' is already used by worktree at '/path/to/repo'
```
与其他问题相比,这并不严重,但这是人们首先会遇到的障碍,也是推动他们为每个工作树生成一次性分支来满足 Git 要求的原因。
## 那些“修复”实际上并不能修复问题
每次讨论这个问题时,都会出现两种缓解措施。但它们都不起作用。
**每个工作树的配置。** `extensions.worktreeConfig` 默认是关闭的,即使打开,也只有当写入者主动使用 `git config --worktree` 时才有效。普通的 `git config`(任何没有特别注意的东西都会运行这个)仍然会写入共享文件:
```
git config extensions.worktreeConfig true # 你在父仓库中选择启用
cd ../my-worktree
git config --worktree user.email [email protected]
git -C ../repo config user.email # -> [email protected], 不受影响
git config user.email [email protected]
git -C ../repo config user.email # -> [email protected]
```
**把钩子移出 `.git`。** 这是一个循环问题。`core.hooksPath` 是配置,而配置是共享的,所以工作树可以把它指向任何它想要的地方:
```
cd repo && git config core.hooksPath ../safe-hooks # 你的缓解措施
cd ../my-worktree
mkdir -p evil-hooks && printf '#!/bin/sh\necho pwned\n' > evil-hooks/pre-commit
chmod +x evil-hooks/pre-commit
git config core.hooksPath "$(pwd)/evil-hooks" # 配置是共享的
cd ../repo && git commit --allow-empty -m "an ordinary commit"
# -> pwned
```
`--separate-git-dir` 也同样失败。它重新定位了 `.git`,但每个工作树仍然共享它被重定位到的那个 `.git`。
这三种方法都限制了一个试图表现良好的写入者。但它们都没有减少工作树*能够*写入的内容。
## 那么就用克隆吧。成本相同。
常见的反对意见是,每个工作进程克隆一份意味着复制历史,但对于本地源来说,情况并非如此。这是我之前搞错的部分,所以这里有数据。针对 git/git (https://github.com/git/git) 的完整克隆进行测量:81,772 次提交,4,828 个跟踪文件,318 MB 的 `.git`,58 MB 的工作树。每一行从该本地源创建一个新的检出。
关于方法的一个说明,因为它会改变答案。磁盘用量是作为整个树的累计增量来测量的。`du` 每次调用会统计一个 inode 一次,所以单独测量硬链接克隆会算上它实际上从未增加的字节。
| 创建方式 | 挂钟时间 | 磁盘增加 | `.git` 表观大小 | 复制的包文件 |
|--------|--------|---------|--------------|------------|
| `git clone --no-hardlinks` | 1791 ms | 373 MB | 315 MB | 1 |
| `git clone` (本地) | 982 ms | 58.9 MB | 318 MB | 1 (硬链接) |
| `git clone --shared` | 870 ms | 58.9 MB | 660 KB | 0 |
| `git worktree add` | 826 ms | 58.7 MB | 4 KB | 0 |
看下面三行。58.7 到 58.9 MB,826 到 982 毫秒。就你的磁盘而言,它们是相同的操作,这个数字就是工作目录的检出,与源的 58 MB 树几乎完全匹配。无论你选择哪种机制,你只为一份文件副本付费,其他都不重要。
本地的 `git clone` 并不复制对象,而是硬链接它们,这就是为什么它的 `.git` 报告为 318 MB,但实际磁盘只增加了 59 MB:
```
$ stat -f '%i links=%l' reference/.git/objects/pack/*.pack
154102172 links=2
$ stat -f '%i links=%l' plain-clone/.git/objects/pack/*.pack
154102172 links=2 # 同一个 inode
```
只有 `--no-hardlinks` 会为复制付费,而且没人会无意运行这个。如果你之前因为假设本地克隆会复制历史而避免使用它们,那么实际上它们不会。
`--shared` 更进一步,什么都不复制。它只写了一个文件:
```
$ cat shared-clone/.git/objects/info/alternates
/path/to/reference/.git/objects
$ find shared-clone/.git/objects -type f | grep -v /info/ | wc -l
0
```
因此它的 `.git` 只有 660 KB,而普通克隆的硬链接版本是 318 MB。它真正带来的特性是独立于历史大小:`--shared` 在源历史数量上是 O(1),在检出文件数量上是 O(n)。检出占主导,这正是表格所显示的。
我之前在这篇文章中描述过,这花费“千字节和毫秒”。这对元数据来说是对的,但对整个操作来说不对。它花费千字节的 `.git` 加上一个工作树,时间不到一秒。同样花费 59 MB,你得到的是:
```
$ cd plain-clone && git config user.zzTest x && git -C ../reference config user.zzTest
# -> (未设置)
$ cd shared-clone && git config user.zzTest x && git -C ../reference config user.zzTest
# -> (未设置)
$ cd worktree && git config user.zzTest x && git -C ../reference config user.zzTest
# -> set-from-worktree
```
| 状态 | 工作树 | `--shared` 克隆 |
|------|--------|----------------|
| 对象存储 | 共享 | 共享,通过 alternates |
| 引用/分支 | 共享 | 隔离 |
| 配置 | 共享 | 隔离 |
| 暂存区 | 共享 | 隔离 |
| 钩子 | 共享 | 隔离 |
| 索引 | 隔离 | 隔离 |
| HEAD | 隔离 | 隔离 |
两者都共享一个昂贵且安全共享的对象存储。克隆还隔离了那五个一旦有多个写入者就会变成隐患的项目。它借用对象,因此依赖于源对象存储保持在原地。不要删除源对象存储,也不要在克隆存在时对源运行激进的 `git gc`,否则可能会修剪掉克隆仍在引用的对象。如果你想以后切断依赖,`git clone --dissociate` 会复制借用的对象并解除依赖。
普通的硬链接克隆是更健壮的那一个,原因值得了解。硬链接本身就能保持 inode 存活:如果源删除或重新打包了那个包文件,克隆的链接仍然保持数据开放。`--shared` 没有这种保护,因为它保存的是路径而不是引用。你失去的是 O(1) 特性、永久去重以及跨文件系统的能力——当工作空间位于另一个卷上或被绑定挂载到容器中时,跨文件系统能力就开始重要了。
两者没有哪个严格更好。它们失败的方式不同,但都隔离了引用、配置、暂存区和钩子。
## 什么时候工作树是正确的选择
工作树有意共享一个 `.git`,对于它被设计的目的,这种共享正是关键:
- 一个人在两三个分支之间切换。你*希望*分支、暂存区和配置在所有地方可见。
- 快速构建或测试另一个分支而不影响你的索引。
- 任何你作为唯一写入者并且知道每个命令在执行前会做什么的情况。
即使使用代理,在有人阅读每个差异的情况下监督一两个代理,工作树和 shell 别名也足够。但无协调的写入者就不可行了。“共享一个 `.git`”和“运行几个不经请求就行动的进程”是直接矛盾的,驱动程序再小心也无法改变共享目录所允许的行为。
所以:人驱动时用工作树,其他东西驱动时用克隆。成本在任何情况下都一样,这意味着你真正选择的只是一个混乱的进程能造成多大的损害。
---
*声明:我正在构建 Fletch (https://fletch.sh/),一个运行编码代理的 macOS 应用,所以我必须在这两者中选择一个。它给每个代理分配 `git clone --shared`,原因如上测量。它的工作树模式放在开发者标志后面,因为在容器化环境下,这意味着要挂载真正的 `.git`,而第一个复现中的钩子会在主机上运行。*
相似文章
@github: Git worktrees自2015年以来就已存在,但如今它们重新兴起。@cassidoo解释了它们是什么以及为什么AI时代……
Git worktrees(Git工作树)是自2015年起就存在的功能,随着AI时代并行工作的需求增加,它们正变得越来越受欢迎。本文解释了工作树是什么、它们与分支的区别,以及为什么它们能减少开发者的上下文切换开销。
在 Magit 中使用 Git Worktrees
本文介绍了如何在 Magit 中使用 Git worktrees 进行并行开发,讨论了其相对于传统分支的优势,并提及了 AI agent 应用和 Jujutsu 版本控制系统。
@realchendahuang: Once AI multiplies coding throughput by several times, how should we manage local development environments, concurrent …
本文系统介绍了Git Worktree的原理、用法及其在AI编码时代帮助开发者管理并发任务和本地开发环境的应用。
我两个代理的 git 工作树的干净合并,但它自身的测试失败了
作者描述了一个实验,其中合并两个 AI 代理的 git 工作树导致了测试失败,尽管合并是干净的,突显了在没有相互意识的情况下并行代理开发的挑战。
当四个 AI 代理同时更新同一个文件时会发生什么?
作者将 AgentWS 的工作区基础设施与受保护的 Git worktree 进行对比,用于多代理文件更新场景,结果显示两者结果完全一致,但 AgentWS 需要的自定义代码要少得多。文章强调,ACL、工作进程故障后的恢复以及冲突处理,是普通 Git 无法满足的需求。