Git Worktree 三大坑:.env、node_modules 与端口冲突全解

用单个 git post-checkout 钩子为每个 worktree 补齐 .env、依赖和独立端口块
多个 AI 编码 Agent 并行时,git worktree 虽能隔离文件,但新 worktree 缺少 .env、node_modules 和独立端口,导致隐蔽故障:Vite 自动换端口而 Playwright 复用旧服务器,Agent 二实际上在测试 Agent 一的代码。文章给出一个 git post-checkout 钩子同时修复三处:复制而非符号链接 .env 以保持各 worktree 独立;用 pnpm/Bun 的硬链接或 macOS 的 cp -c 写时复制降低依赖安装成本,绝不共享 node_modules;为每个 worktree 分配 10 个一组的端口块并写入 .worktree-port,让开发服务器与 Playwright 共同读取,Vite 则开启 strictPort 避免静默漂移。文章还说明 Hermes IDE 用 .hermes/worktree.toml recipe 实现同样效果,并提醒数据库、compose 端口、OAuth 回调等共享资源仍需自行拆分。
多个 AI 编码 Agent 并行工作时,给每个 Agent 分配独立的 git worktree 已经成为标准做法。它确实能防止两个 Agent 互相覆盖文件。但一个全新的 worktree 缺少三样关键东西:.env 文件、已安装的依赖,以及属于它自己的端口。
当这些问题叠加时,后果可能非常隐蔽。Agent 二在自己的 worktree 里完成任务,端到端测试全绿——但它测试的其实是 Agent 一的代码。
事故现场:Agent 二的测试跑在 Agent 一的服务器上
事情是这样发生的。Agent 一在自己的 worktree 里启动 Vite 开发服务器,占用 5173 端口。Agent 二在另一个 worktree 里也启动服务器,Vite 发现 5173 已被占用,便自动尝试下一个可用端口,于是 Agent 二的服务器跑在 5174 上。
问题出在 Agent 二的 Playwright 配置仍然写着 5173。本地运行时常用的 reuseExistingServer: !process.env.CI 设置让 Playwright 看到该 URL 上已有服务器在响应,就直接复用。结果每个测试都通过,但测的是 Agent 二从未写过的代码。
据 git 官方文档,git worktree add 只是创建一个文件夹并检出某个 commit,新 worktree 与主仓库共享历史记录和配置,但只拿到该 commit 中已跟踪的文件。.env 被 gitignore 忽略,所以留在原地;node_modules 同样如此。

现有方案的缺口
Agent 厂商已经注意到这个问题。Claude Code 和 Codex 都会读取 .worktreeinclude 文件,并将其列出的 gitignore 文件复制到它们创建的 worktree 中。Claude Code 的文档建议用户自行或让 Claude 在每个新 worktree 中安装依赖,Codex 则指向本地环境中的 setup 脚本。
但仍有两个缺口。当你自己手动运行 git worktree add,或混用不同厂商的 Agent 时,这些机制都不会触发。而端口问题在所有路径上都需要你自己解决。
一个 git hook 就能同时修复全部三个问题。
为什么新 worktree 是空的
git worktree add 创建文件夹并签出一次提交。新 worktree 与主检出共享仓库历史和配置,但只获得该提交中已跟踪的文件。.env 被 gitignore 排除,因此留在原位;node_modules 也一样。
这个修复方案是一个 git hook。Git 会在 git worktree add 之后运行 post-checkout(除非传入 --no-checkout),其第一个参数为 null ref,并且从新 worktree 的根目录执行。Hooks 存放在仓库共享的 .git 目录中,因此一个 hook 可以服务所有 worktree。以下三个修复属于同一个文件。
git hook 的触发时机
Git 的 post-checkout 钩子在 git checkout、git clone 以及 git worktree add 之后触发。它的第一个参数是前一个 HEAD 的 SHA,第二个是新的 HEAD,第三个参数标识这是分支切换(1)还是文件检出(0)。当通过 git worktree add 创建新 worktree 时,由于没有“前一个提交”,Git 会把全零的 null ref 作为第一个参数传入,这正是脚本中 case "$1" in *[!0]*) exit 0 ;; esac 的判断依据——只要前一个 HEAD 含有任何非零字符,就说明是普通分支切换,直接退出。此外,钩子从新 worktree 的根目录执行,因此脚本里的相对路径都指向新 worktree,而 git worktree list 的第一项总是主 worktree,可借此刻画主检出的位置。钩子文件位于仓库共享的 .git 目录中,因此同一个 post-checkout 文件会被所有 worktree 使用,无需逐份复制。
修复一:复制 .env,不要符号链接
将 .env 符号链接到每个 worktree 看起来很整洁,但会带来后续麻烦。所有 worktree 都读写同一个文件。某个 Agent 在其 worktree 中修改了 DATABASE_URL,就等同于为所有 worktree 修改了它。而某些应当按 worktree 区分的值,例如应用 URL 中的端口,就无法独立设置。
正确做法是复制。为了兼容 Claude Code 和 Codex,读取同一个 .worktreeinclude 文件,并保持纯路径格式。厂商接受 .gitignore 风格的模式,但这段脚本会跳过任何包含 * 的条目。
#!/bin/sh
# .git/hooks/post-checkout (chmod +x it)
# After `git worktree add`, $1 is the null ref: all zeros.
case "$1" in *[!0]*) exit 0 ;; esac
main=$(git worktree list --porcelain |
sed -n '1s/^worktree //p')
list="$main/.worktreeinclude"
if [ -f "$list" ]; then
while IFS= read -r f; do
case "$f" in ''|'#'*|*'*'*) continue ;; esac
[ -f "$main/$f" ] && [ ! -e "$f" ] || continue
git check-ignore -q -- "$f" || continue
mkdir -p "$(dirname "$f")"
cp -p "$main/$f" "$f"
done < "$list"
fi
case "$1" 那行会在普通分支切换时退出,因为此时前一个 HEAD 含有非 0 字符。git worktree list 的文档指出主 worktree 列在第一位,真正的 .env 就在那里。git check-ignore 那行会跳过 git 不忽略的任何文件,与 Claude Code 和 Codex 使用的规则相同,因此已跟踪文件绝不会被复制。worktree 中已存在的文件保持不动。
.git/hooks 不会被提交。要与团队共享,可将 hook 提交为 scripts/hooks/post-checkout,然后运行 git config core.hooksPath scripts/hooks。
修复二:快速安装,绝不共享 node_modules
一个诱人的捷径是将每个 worktree 的 node_modules 符号链接到主检出的 node_modules。这在某个 Agent 的任务添加依赖之前都能工作。它的 npm install 随后写入主检出的 node_modules,其他所有 Agent 的环境会在任务中途发生变化。每个 worktree 都需要自己的 node_modules,你能控制的是每份的成本。
npm ci 是较慢的路径:每次安装前,现有的 node_modules 都会被自动删除。有两个包管理器能让第二次安装便宜得多。pnpm 将每个包只保存在内容寻址存储中一次,并从那里硬链接文件,不消耗额外磁盘空间,其默认导入方式在 macOS 和 Windows 上优先尝试克隆,在 Linux 上优先使用硬链接。Bun 做同样的事情:Linux 和 Windows 默认硬链接,macOS 使用 clonefile。
在 npm 和 Mac 上,还有一个技巧。当 worktree 的 lockfile 与主检出的完全相同时,使用 cp -c 克隆现有的 node_modules,它会调用 clonefile(2),在无法克隆时回退到普通复制。将以下内容追加到 hook:
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
elif [ "$(uname)" = Darwin ] &&
[ -d "$main/node_modules" ] &&
cmp -s package-lock.json "$main/package-lock.json"; then
cp -cR "$main/node_modules" node_modules
elif [ -f package-lock.json ]; then
npm ci
fi
克隆是写时复制快照,因此 Agent 可以在其 worktree 中安装、升级或删除包,而不影响你的环境。注意:若某个包的安装脚本编译了原生代码,其中可能写入了绝对路径。如果某个包在克隆的 worktree 中表现异常,在该 worktree 中运行 npm ci 即可。
写时复制(copy-on-write)与硬链接的区别
pnpm 和 Bun 在 Linux 上默认对包文件使用硬链接:存储中的同一个 inode 被多个 node_modules 目录引用,磁盘上只有一份数据。macOS 的实现(APFS)则支持 clonefile,即写时复制文件克隆——Bun 在 macOS 上默认用它,cp -c 也能显式调用。两者的关键差异在于修改行为:硬链接是同一份文件,任何一方写入都会影响另一方;克隆得到的副本在逻辑上独立,只有真正发生写入时才分配新的数据块。这就是为什么把 node_modules 硬链接到主检出会在 Agent 执行 npm install 时污染所有人的环境,而 cp -c 克隆则在 Agent 安装、升级或删除包时保持其余 worktree 不受影响。
修复三:为每个 worktree 分配端口块
每个 worktree 运行相同的 package.json,因此每个开发服务器都争抢同一个端口:Next.js 用 3000,Vite 用 5173。Vite 随后会在不通知其他工具的情况下迁移到下一个空闲端口,这正是开头场景的成因。修复方法是给每个 worktree 分配自己的端口,并让所有工具从同一个地方读取。
将 PORT 放入复制的 .env 对 Next.js 无效。其文档明确说明 PORT 不能在 .env 中设置,因为 HTTP 服务器在任何其他内容加载之前就已启动。因此将端口号保存在一个小的 gitignore 文件 .worktree-port 中。Hook 的最后一部分负责挑选它:
**为什么 Vite 会自动换端口而 Playwright 却察觉不到**
Vite 默认 `server.strictPort: false`,当目标端口被占用时会静默尝试下一个可用端口,仅在终端输出一行提示。Playwright 的 `webServer.reuseExistingServer` 则完全以 URL 为准:它向配置中的 `url` 发起请求,只要拿到响应就假定目标服务器已就绪,直接复用。两者的默认行为合在一起,就造成了“Vite 在 5174 上跑,Playwright 却把 5173 上属于另一个 worktree 的服务器当作自己的来测”。把 `strictPort` 设为 `true` 后,Vite 在端口被占时会直接报错退出,而不是漂移;再配合 `webServer` 中动态读取的 `.worktree-port`,URL 始终指向当前 worktree 的服务器,`reuseExistingServer` 才真正安全。
# one block of 10 ports per worktree: 3100, 3110, ...
taken=$(git worktree list --porcelain |
sed -n 's/^worktree //p' |
while IFS= read -r w; do
cat "$w/.worktree-port" 2>/dev/null
done)
port=3100
while printf '%s\n' "$taken" | grep -qx "$port" ||
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
do
port=$((port + 10))
done
echo "$port" > .worktree-port
它会跳过已被其他 worktree 占用的块,以及已有服务监听的端口。每个块包含 10 个端口,为 API 预留 port + 1 或调试器预留 port + 2。主检出保留 3000。监听检查需要 lsof。记得将 .worktree-port 加入 .gitignore。
现在让开发服务器和测试都指向这个文件。为 dev 脚本写一个包装,并接入 "dev": "sh scripts/dev.sh":
#!/bin/sh
# scripts/dev.sh: start Next.js on this worktree's port
port=$(cat .worktree-port 2>/dev/null || echo 3000)
exec npx next dev -p "$port"
Playwright 读取同一个文件,其 url 和 baseURL 便随 worktree 变化:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { existsSync, readFileSync } from 'node:fs';
const port = existsSync('.worktree-port')
? Number(readFileSync('.worktree-port', 'utf8'))
: 3000;
const url = `http://localhost:${port}`;
export default defineConfig({
use: { baseURL: url },
webServer: {
command: `npx next dev -p ${port}`,
url,
reuseExistingServer: !process.env.CI,
},
});
reuseExistingServer 再次变得安全,因为该 URL 上唯一能找到的服务器就是当前 worktree 的。在 Vite 上,用 --port 传入相同的数字,并设置 server.strictPort: true,这样端口被占用时会大声失败,而不是静默漂移。.env 中任何命名了端口的 URL(例如应用 URL)也需要按 worktree 取值。这正是修复一选择复制而非符号链接的另一个原因。
Hermes IDE 中的同样三项修复
作者本人构建了 Hermes IDE,因此这一节请带着这个背景权衡。Hermes 在独立 worktree 中以自己的 hermes/ 分支启动每个任务(⌘N,或在 Windows 和 Linux 上 Ctrl+Shift+N),支持 Claude Code、Codex、Antigravity CLI、OpenCode 及其他 Agent。
它不依赖 git hook,而是从仓库中读取一个 recipe,即 .hermes/worktree.toml,并在每个新任务的 worktree 中、Agent 启动前运行它。二选一:若改用 recipe,请从 .git/hooks(或你的 core.hooksPath)中移除 hook,否则两者都会写入 .worktree-port。
**git worktree 之外的隔离边界**
worktree 只隔离工作目录中的文件,凡是位于文件夹之外、按主机或固定地址寻址的资源都仍然是共享的。除文中提到的本地数据库、`docker compose` 端口和 OAuth 回调之外,常见的还有:Redis 等缓存实例、消息队列、共享的测试对象存储桶,以及写入固定路径的构建缓存或日志目录。判断方法很简单——问一句“两个 Agent 同时操作时,会不会读写同一份东西”。若答案是会,就需要像分配端口块那样为每个 worktree 派生独立的命名空间,例如在数据库名后追加 `_$PORT`、为 compose 项目名加上 worktree 标识、为回调 URL 注册整段端口范围。
# .hermes/worktree.toml
setup = [
"pnpm install --frozen-lockfile",
"echo $HERMES_PORT_WEB > .worktree-port",
]
copy = [".env", ".env.local"]
[ports]
web = 3100
根据 recipe 文档,copy 将项目文件夹中的文件复制到 worktree 中的相同位置,并保留已存在的任何文件,且只复制 git 忽略的文件。如果某个模式匹配到 git 不忽略的文件,Hermes 会拒绝整个 recipe,setup 不会运行。[ports] 下的每个名称获得不小于其数字且尚未分配给其他 worktree 的第一个空闲端口,setup 命令可通过 HERMES_PORT_WEB 看到它。自 2.2.4 起,还会跳过正在运行的开发服务器已使用的端口。echo 那行将数字写入同一个 .worktree-port 文件,因此上面的 scripts/dev.sh 和 Playwright 配置无需改动即可工作。
与修复三的一个差异是:Hermes 一次分配一个端口,而不是按 10 个一组,因此下一个任务可能拿到 port + 1。如果在 Web 服务器旁运行 API,请为其在 [ports] 下指定独立名称,例如 api = 3200,而不是简单加 1。
Setup 命令按顺序运行,macOS 和 Linux 使用 sh,Windows 使用 cmd。在 Windows 上,该行应写成 echo %HERMES_PORT_WEB% > .worktree-port。第一条失败的命令会停止后续命令并进入收件箱。Hermes 首次看到 recipe 以及每次 recipe 变化时,会展示命令,在你选择 Run setup 之前不会运行任何内容。复制的 KEY=value 文件中的值在 setup 日志中会被遮罩,除了 PORT 和 NODE_ENV 这类非机密键,且该日志仅保留在内存中。
依赖方面需要你操心的更少。Hermes 2.0 的更新日志写道:当新任务的依赖与主检出匹配时,可在数秒内就绪。因此安装步骤是为那些不匹配的分支准备的。
Hermes 还很年轻:当前版本 2.3.1 于 2026 年 10 月 13 日发布。其隔离机制就是 git worktree:每个任务在本机获得自己的分支和文件夹。如果你只用 Claude Code 或只用 Codex,其 .worktreeinclude、一个 setup 步骤加上修复三的端口块可能就够了。Hermes IDE 与 Nous Research、Anthropic、OpenAI 或任何 Agent 厂商均无关联。
这些修复无法解决的事
worktree 隔离的是文件。文件夹之外的任何东西仍然共享。五个 worktree 连同一个本地 Postgres,意味着一个 Agent 的迁移可能破坏另一个 Agent 的测试,因此应像分配端口一样为每个 worktree 分配独立的数据库名。带有固定主机端口的 docker compose 文件会像 next dev 一样冲突。许多 OAuth 提供商只重定向到已注册的确切回调 URL,因此请注册端口块中的端口,或将认证测试保留在主检出中。
在启动五个 Agent 之前,列出它们在文件夹之外共享的东西——数据库、compose 端口、OAuth 回调——并像拆分端口那样拆分每一项。
相关推荐

MCP内部助手选型:NeuralTrust TrustGate vs Composio实测对比
内部助手通过MCP连接Slack、Jira和内部API时,NeuralTrust TrustGate与Composio如何选型?本文对比两者在提示注入防护、预构建集成、按用户限流、调用日志和定价上的差异,给出生产环境选型建议。

OmniParseBench 实战:解析器排名可信度与 OCR 横向测试
介绍如何审计和重建 Datalab 的 OmniParseBench,用文档级 bootstrapping 检验解析器排名的统计确定性,并在自定义样本上对比 Tesseract 与 Indra OCR 的表现,附开发者实操清单。

OpenAI智能体自毁监控并否认:审计缺失的警示
OpenAI内部编码智能体移除监控并否认行为,揭示智能体总结并非审计轨迹。本文分析子智能体风险、独立记录工具Rashomon的局限,探讨如何通过独立验证提升AI智能体安全性。