Git Worktree's Three Big Pitfalls: .env, node_modules, and Port Conflicts Fully Solved

One git post-checkout hook fixes .env, dependencies, and ports for every worktree
Parallel AI agents in separate git worktrees silently fail because each new worktree lacks .env, node_modules, and its own port—Vite shifts ports while Playwright reuses a stale server. A single git post-checkout hook copies .env, uses pnpm/Bun or cp -c for fast isolated installs, and assigns a 10-port block written to .worktree-port that both dev server and Playwright read.
When multiple AI coding agents work in parallel, assigning each agent its own git worktree has become standard practice. It does prevent two agents from overwriting each other's files. But a brand-new worktree is missing three key things: the .env file, installed dependencies, and a port of its own.
When these problems stack up, the consequences can be very subtle. Agent two completes its task in its own worktree, and the end-to-end tests all pass — but it was actually testing agent one's code.
The Crime Scene: Agent Two's Tests Running on Agent One's Server
Here's how it happens. Agent one starts the Vite dev server in its own worktree, occupying port 5173. Agent two also starts a server in another worktree; Vite sees 5173 is taken and automatically tries the next available port, so agent two's server runs on 5174.
The problem is that agent two's Playwright config still says 5173. The commonly used local setting reuseExistingServer: !process.env.CI makes Playwright see a server already responding at that URL and just reuse it. The result: every test passes, but it's testing code agent two never wrote.
According to the official git documentation, git worktree add only creates a folder and checks out a commit; the new worktree shares history and config with the main repository, but only gets the tracked files from that commit. .env is gitignored, so it stays where it is; so does node_modules.

Gaps in the Existing Solutions
Agent vendors have already noticed this problem. Both Claude Code and Codex read the .worktreeinclude file and copy the gitignored files it lists into the worktrees they create. Claude Code's documentation suggests users install dependencies in each new worktree themselves or ask Claude to do so, while Codex points to a setup script in the local environment.
But two gaps remain. When you run git worktree add manually yourself, or mix agents from different vendors, these mechanisms don't trigger. And the port problem needs to be solved by you on every path.
A single git hook can fix all three problems at once.
Why New Worktrees Are Empty
git worktree add creates a folder and checks out a commit once. The new worktree shares repository history and config with the main checkout, but only gets the tracked files from that commit. .env is excluded by gitignore, so it stays where it is; so does node_modules.
The fix is a git hook. Git runs post-checkout after git worktree add (unless --no-checkout is passed), with its first argument being the null ref, and it executes from the root of the new worktree. Hooks live in the repository's shared .git directory, so one hook can serve all worktrees. The following three fixes belong to the same file.
When the git hook fires
Git's post-checkout hook fires after git checkout, git clone, and git worktree add. Its first argument is the SHA of the previous HEAD, the second is the new HEAD, and the third identifies whether this is a branch switch (1) or a file checkout (0). When a new worktree is created via git worktree add, since there is no "previous commit," Git passes the all-zero null ref as the first argument, which is exactly the basis for the check case "$1" in *[!0]*) exit 0 ;; esac in the script — as long as the previous HEAD contains any non-zero character, it's an ordinary branch switch, so exit immediately. In addition, the hook executes from the root of the new worktree, so relative paths in the script all point to the new worktree, while the first entry of git worktree list is always the main worktree, which can be used to locate the main checkout. The hook file lives in the repository's shared .git directory, so the same post-checkout file is used by all worktrees, with no need to copy it per worktree.
Fix One: Copy .env, Don't Symlink It
Symlinking .env into each worktree looks tidy, but it causes trouble later. All worktrees read and write the same file. If one agent changes DATABASE_URL in its worktree, that's the same as changing it for all worktrees. And values that should differ per worktree, such as the port in an app URL, can't be set independently.
The right approach is to copy. To be compatible with both Claude Code and Codex, read the same .worktreeinclude file and keep plain path format. The vendors accept .gitignore-style patterns, but this script skips any entry containing *.
#!/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
The case "$1" line exits on ordinary branch switches, because at that point the previous HEAD contains non-zero characters. The documentation for git worktree list states the main worktree is listed first, and that's where the real .env is. The git check-ignore line skips any file git doesn't ignore, using the same rule Claude Code and Codex use, so tracked files are never copied. Files that already exist in the worktree are left alone.
.git/hooks isn't committed. To share with your team, commit the hook as scripts/hooks/post-checkout, then run git config core.hooksPath scripts/hooks.
Fix Two: Fast Installs, Never Share node_modules
A tempting shortcut is symlinking each worktree's node_modules to the main checkout's node_modules. This works until one agent's task adds a dependency. Its npm install then writes into the main checkout's node_modules, and every other agent's environment changes mid-task. Each worktree needs its own node_modules; what you control is the cost per copy.
npm ci is the slower path: before each install, the existing node_modules is automatically deleted. Two package managers make the second install much cheaper. pnpm keeps each package only once in a content-addressable store and hard-links files from there, consuming no extra disk space; its default import method prefers cloning on macOS and Windows and hard-linking on Linux. Bun does the same: it hard-links by default on Linux and Windows, and uses clonefile on macOS.
On Mac with npm, there's another trick. When the worktree's lockfile is exactly the same as the main checkout's, use cp -c to clone the existing node_modules, which invokes clonefile(2) and falls back to a regular copy when cloning isn't possible. Append the following to the 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
Cloning is a copy-on-write snapshot, so an agent can install, upgrade, or delete packages in its worktree without affecting your environment. Note: if a package's install script compiles native code, absolute paths may be written into it. If a package misbehaves in a cloned worktree, run npm ci in that worktree.
Copy-on-write vs. hard links
pnpm and Bun use hard links for package files by default on Linux: the same inode in the store is referenced by multiple node_modules directories, with only one copy of the data on disk. macOS's implementation (APFS) supports clonefile, i.e., copy-on-write file cloning — Bun uses it by default on macOS, and cp -c can invoke it explicitly too. The key difference lies in modification behavior: a hard link is the same file, so any write on either side affects the other; a cloned copy is logically independent, allocating new data blocks only when a write actually occurs. That's why hard-linking node_modules to the main checkout pollutes everyone's environment when an agent runs npm install, while cp -c cloning keeps the rest of the worktrees unaffected when an agent installs, upgrades, or deletes packages.
Fix Three: Assign a Port Block to Each Worktree
Every worktree runs the same package.json, so every dev server fights over the same port: 3000 for Next.js, 5173 for Vite. Vite then migrates to the next free port without notifying other tools, which is the cause of the opening scenario. The fix is to give each worktree its own port and have all tools read from the same place.
Putting PORT into the copied .env doesn't work for Next.js. Its documentation explicitly states PORT can't be set in .env, because the HTTP server starts before anything else loads. So keep the port number in a small gitignored file, .worktree-port. The last part of the hook picks it:
**Why Vite silently switches ports while Playwright doesn't notice**
Vite defaults to `server.strictPort: false`, silently trying the next available port when the target is occupied, printing only one line to the terminal. Playwright's `webServer.reuseExistingServer` is entirely URL-based: it makes a request to the configured `url` and, as long as it gets a response, assumes the target server is ready and reuses it. The two defaults combined create the situation where "Vite is running on 5174, but Playwright treats the server on 5173, belonging to another worktree, as its own to test." Setting `strictPort` to `true` makes Vite error out and exit when the port is occupied rather than drift; combined with dynamically reading `.worktree-port` in `webServer`, the URL always points to the current worktree's server, and `reuseExistingServer` becomes truly safe.
# 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
It skips blocks already occupied by other worktrees, as well as ports already listened on by a service. Each block holds 10 ports, reserving port + 1 for an API or port + 2 for a debugger. The main checkout keeps 3000. The listen check requires lsof. Remember to add .worktree-port to .gitignore.
Now point the dev server and tests at this file. Write a wrapper for the dev script and wire it into "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 reads the same file, so its url and baseURL vary per 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 becomes safe again, because the only server that can be found at that URL is the current worktree's. On Vite, pass the same number via --port, and set server.strictPort: true so an occupied port fails loudly rather than drifting silently. Any URL in .env that names a port (such as an app URL) also needs to be per-worktree. This is another reason Fix One chose copying over symlinking.
The Same Three Fixes in Hermes IDE
The author built Hermes IDE himself, so weigh this section with that in mind. Hermes launches each task in a separate worktree on its own hermes/ branch (⌘N, or Ctrl+Shift+N on Windows and Linux), supporting Claude Code, Codex, Antigravity CLI, OpenCode, and other agents.
Instead of relying on a git hook, it reads a recipe from the repository, .hermes/worktree.toml, and runs it in each new task's worktree before the agent starts. Pick one: if you switch to the recipe, remove the hook from .git/hooks (or your core.hooksPath), otherwise both will write .worktree-port.
**Isolation boundaries beyond git worktree**
worktree only isolates files within the working directory; anything outside the folder, addressed by host or fixed address, remains shared. Besides the local database, `docker compose` ports, and OAuth callbacks mentioned in the article, common ones include: cache instances like Redis, message queues, shared test object storage buckets, and build caches or log directories that write to fixed paths. The test is simple — ask "when two agents operate simultaneously, will they read or write the same thing?" If yes, you need to derive an independent namespace per worktree just like allocating port blocks, e.g., appending `_$PORT` to the database name, adding a worktree identifier to the compose project name, or registering an entire port range for callback URLs.
# .hermes/worktree.toml
setup = [
"pnpm install --frozen-lockfile",
"echo $HERMES_PORT_WEB > .worktree-port",
]
copy = [".env", ".env.local"]
[ports]
web = 3100
According to the recipe documentation, copy copies files from the project folder to the same locations in the worktree, preserves any that already exist, and copies only gitignored files. If a pattern matches a file git doesn't ignore, Hermes rejects the entire recipe and setup doesn't run. Each name under [ports] gets the first free port not less than its number and not yet assigned to another worktree, visible to setup commands via HERMES_PORT_WEB. As of 2.2.4, it also skips ports already in use by a running dev server. The echo line writes the number to the same .worktree-port file, so the scripts/dev.sh and Playwright config above work without changes.
One difference from Fix Three: Hermes allocates one port at a time, not in groups of 10, so the next task might get port + 1. If you run an API alongside the web server, specify a separate name under [ports] for it, such as api = 3200, rather than simply adding 1.
Setup commands run in order, using sh on macOS and Linux and cmd on Windows. On Windows, that line should be written as echo %HERMES_PORT_WEB% > .worktree-port. The first failing command stops subsequent commands and goes to the inbox. The first time Hermes sees a recipe, and every time a recipe changes, it shows the commands, and nothing runs until you choose Run setup. Values in copied KEY=value files are masked in the setup log, except non-secret keys like PORT and NODE_ENV, and the log is kept only in memory.
Dependencies require less of your attention. Hermes 2.0's changelog says: when a new task's dependencies match the main checkout, it's ready within seconds. So the install step is for branches that don't match.
Hermes is still young: the current version 2.3.1 was released on October 13, 2026. Its isolation mechanism is git worktree: each task gets its own branch and folder locally. If you only use Claude Code or only Codex, its .worktreeinclude, a setup step, plus Fix Three's port block may be enough. Hermes IDE is not affiliated with Nous Research, Anthropic, OpenAI, or any agent vendor.
What These Fixes Don't Solve
worktree isolates files. Anything outside the folder remains shared. Five worktrees on one local Postgres means one agent's migration can break another agent's tests, so assign a separate database name per worktree just like ports. docker compose files with fixed host ports conflict just like next dev. Many OAuth providers only redirect to the exact registered callback URL, so register the ports in your port block, or keep auth tests in the main checkout.
Before launching five agents, list what they share outside the folder — databases, compose ports, OAuth callbacks — and split each one the way you split ports.
One-line Summary
Use a single git post-checkout hook to fill in .env, dependencies, and an independent port block per worktree
Paragraph Summary
When multiple AI coding agents run in parallel, git worktree isolates files, but a new worktree lacks .env, node_modules, and an independent port, causing subtle failures: Vite silently switches ports while Playwright reuses the old server, so agent two is effectively testing agent one's code. The article provides a single git post-checkout hook that fixes all three at once: copy rather than symlink .env to keep each worktree independent; use pnpm/Bun hard links or macOS's cp -c copy-on-write to reduce dependency installation cost, never sharing node_modules; and assign each worktree a block of 10 ports written to .worktree-port, read by both the dev server and Playwright, with Vite's strictPort enabled to avoid silent drift. The article also explains how Hermes IDE achieves the same with a .hermes/worktree.toml recipe, and reminds that shared resources like databases, compose ports, and OAuth callbacks still need to be split yourself.
Related articles

Felix Rieseberg Personal Homepage
Felix Rieseberg's personal homepage sparks attention on Hacker News. This article covers the Slack engineer's technical background, the rise and fall of nativefier, and the value of a developer homepage as a tech calling card.

@ai-sdk/vue 3.0.306 Patch Release Syncs with [email protected] Update
@ai-sdk/vue 3.0.306 released, synced with [email protected]. A routine patch release for the Vercel AI SDK ecosystem with no public API changes; the Vue adapter layer stays version-aligned with the core package.

Gemini 4 Argon API Launches: The Era of Million-Token Output Begins
Google launches Gemini 4 Argon with sparse MoE architecture, up to 1M output tokens, and a 68.90% Vals Index score, available across API, AI Studio, and the Gemini app.