DEV Community

Cover image for Git Worktrees for Coding Agents Break on .env, node_modules and Port 3000. Fix All Three
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Git Worktrees for Coding Agents Break on .env, node_modules and Port 3000. Fix All Three


Your second agent finishes its task and its end-to-end tests are green. They ran against the first agent's branch.

Here is how that happens. Agent one, in its own worktree, starts the Vite dev server on 5173. Agent two, in a second worktree, starts its own. Vite finds 5173 taken and "will automatically try the next available port", so agent two's server comes up on 5174. Agent two's Playwright config still says 5173. With reuseExistingServer: !process.env.CI, the value Playwright's docs suggest for local runs, Playwright sees a server already answering on that URL and uses it. Every test passes, against code agent two never wrote.

"Give each agent its own worktree" is good advice. It stops two agents from overwriting each other's files. It does nothing about the three things a fresh worktree is missing: your .env, your installed dependencies, and a port of its own. One git hook fixes all three.

Why a new worktree starts empty

git worktree add creates a folder and checks out a commit into it. The new worktree shares the repository's history and config with your main checkout. It gets the tracked files of that commit and nothing else. Your .env is gitignored, so it stays behind. So does node_modules.

The agent vendors have noticed. Claude Code and Codex both read a .worktreeinclude file and copy the gitignored files it lists into the worktrees they create. Claude Code's docs tell you to install dependencies in each new worktree yourself or ask Claude to. Codex points you at a setup script in a local environment. Neither worktree page says anything about ports.

Two gaps remain. When you run git worktree add yourself, or mix agents from different vendors, none of that runs. And the port problem is still yours on every path.

A row of identical cream houses on one street, the newest one empty with no key in the door, no furniture inside, and two plugs fighting over a single wall socket
A fresh worktree: the files git tracks, and nothing else.

The fix below is one git hook. Git runs post-checkout after git worktree add (unless you pass --no-checkout), with the null ref as its first argument, and from the root of the new worktree. Hooks live in the repository's shared .git directory, so one hook serves every worktree. The three fixes are three parts of the same file.

Fix 1: copy .env, don't symlink it

Symlinking .env into each worktree looks tidy and causes trouble later. Every worktree then reads and writes one file. An agent that changes DATABASE_URL in its worktree changes it for all of them, and any value that should differ per worktree, such as the port in an app URL, can't.

Copy instead. To stay compatible with Claude Code and Codex, read the same .worktreeinclude file and keep it to plain paths. The vendors accept .gitignore-style patterns. This script skips anything with a * in it.

#!/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

# git lists the main worktree first
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
    # ignored files only, the same rule the vendors use
    git check-ignore -q -- "$f" || continue
    mkdir -p "$(dirname "$f")"
    cp -p "$main/$f" "$f"
  done < "$list"
fi
Enter fullscreen mode Exit fullscreen mode

The case "$1" line exits on any ordinary branch switch, because the previous HEAD there has a character other than 0 in it. The git worktree list docs say "the main worktree is listed first", which is where the real .env lives. The git check-ignore line skips anything git doesn't ignore, the same rule Claude Code and Codex apply, so a tracked file is never copied. Files that already exist in the worktree are left alone.

.git/hooks isn't committed. To share the hook with your team, commit it as scripts/hooks/post-checkout and run git config core.hooksPath scripts/hooks (githooks).

Fix 2: install fast, and never share node_modules

The tempting shortcut is a symlink from each worktree's node_modules to the main checkout's. It works until an 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 under it mid-task. Each worktree needs its own node_modules. What you can control is how much each one costs.

npm ci is the slow path: an existing node_modules "will be automatically removed" before it installs, every time. Two package managers make the second install much cheaper. pnpm keeps each package once in a content-addressable store and hard-links files from there, "consuming no additional disk space". Its default import method tries cloning first on macOS and Windows, hard links first on Linux. Bun does the same kind of thing: hard links by default on Linux and Windows, clonefile on macOS.

On npm and a Mac, you have one more trick. When the worktree's lockfile is identical to the main checkout's, clone the existing node_modules with cp -c, which copies with clonefile(2) and falls back to a normal copy when cloning isn't possible. Append this 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
Enter fullscreen mode Exit fullscreen mode

A clone is a copy-on-write snapshot, so the agent can install, upgrade or delete packages in its worktree without touching yours. One caveat: a package whose install script compiled native code may have absolute paths baked in. If one misbehaves in a cloned worktree, run npm ci there.

Fix 3: a port block per worktree

Every worktree runs the same package.json, so every dev server reaches for the same number: 3000 for Next.js, 5173 for Vite. Vite then moves to the next free port without telling the rest of your tooling, which is how the opening scenario happens. The fix is to give every worktree its own port and make every tool read it from one place.

Putting PORT in the copied .env doesn't work for Next.js. Its docs say "PORT cannot be set in .env", because the HTTP server boots before anything else is loaded. So keep the number in a small gitignored file, .worktree-port. The last part of the hook picks it:

# 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
Enter fullscreen mode Exit fullscreen mode

It skips a block that another worktree already claimed and a port something is already listening on. Blocks of 10 leave room for an API on port + 1 or a debugger on port + 2. The main checkout keeps 3000. The listening check needs lsof. Add .worktree-port to .gitignore.

A switchboard patch panel with five cables, each coming from a different tree branch and plugged into its own evenly spaced socket
One port block per worktree, picked once, read by every tool.

Now point the dev server and the tests at that file. A tiny wrapper for the dev script, wired up as "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"
Enter fullscreen mode Exit fullscreen mode

And Playwright reads the same file, so its url and baseURL follow the 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,
  },
});
Enter fullscreen mode Exit fullscreen mode

reuseExistingServer is safe again, because the only server it can find on this URL is this worktree's. On Vite, pass the same number with --port and set server.strictPort: true, so a taken port fails loudly instead of drifting. Any URL in .env that names the port, such as an app URL, needs the per-worktree value too. That's the other reason Fix 1 copies instead of symlinking.

The same three fixes in Hermes IDE

I build Hermes IDE, so weigh this section with that in mind. Hermes starts every task (⌘N, or Ctrl+Shift+N on Windows and Linux) on its own hermes/ branch in its own worktree, for Claude Code, Codex, Antigravity CLI, OpenCode and other agents. Instead of a git hook, it reads a recipe from your repo, .hermes/worktree.toml, and runs it in each new task's worktree before the agent starts. Use one or the other: if you switch to the recipe, remove the hook from .git/hooks (or your core.hooksPath), or both will write .worktree-port. The three fixes above, as a recipe:

# .hermes/worktree.toml
setup = [
  "pnpm install --frozen-lockfile",
  "echo $HERMES_PORT_WEB > .worktree-port",
]
copy = [".env", ".env.local"]

[ports]
web = 3100
Enter fullscreen mode Exit fullscreen mode

From the recipe docs: copy takes files from the project folder into the same place in the worktree and keeps any file already there. It only copies files git ignores. If a pattern matches a file git doesn't ignore, Hermes refuses the whole recipe and setup doesn't run. Each name under [ports] gets the first free port at or above its number that no other worktree has been given, and setup commands see it as HERMES_PORT_WEB. Since 2.2.4, that also skips a port a running dev server already uses. The echo line hands the number to the same .worktree-port file, so scripts/dev.sh and the Playwright config above work unchanged. One difference from Fix 3: Hermes hands out ports one at a time, not in blocks of 10, so the next task may get port + 1. If you run an API next to the web server, give it its own name under [ports], such as api = 3200, instead of adding 1.

Setup commands run in order, with sh on macOS and Linux and cmd on Windows. On Windows, write that line as echo %HERMES_PORT_WEB% > .worktree-port. The first failing command stops the rest and lands in the inbox. The first time Hermes sees the recipe, and again whenever it changes, it shows you the commands and runs nothing until you choose Run setup. Values in copied KEY=value files are masked in the setup log, apart from non-secret keys such as PORT and NODE_ENV, and that log stays in memory only.

Dependencies need less from you. Hermes 2.0's changelog says "a new task's dependencies are ready in seconds when they match your main checkout", so the install step is there for the branches where they don't.

Hermes is young: the current release, 2.3.1, came out on October 13, 2026. Its isolation is the git worktree: each task gets its own branch and folder on your machine. If you only use Claude Code or only use Codex, its .worktreeinclude, a setup step and the port block from Fix 3 may be all you need. Hermes IDE is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor.

What none of this fixes

A worktree isolates files. Anything outside the folder is still shared. Five worktrees against one local Postgres means one agent's migration can break another agent's tests, so give each worktree its own database name, picked the same way as the port. A docker compose file with fixed host ports collides exactly like next dev does. Many OAuth providers only redirect to the exact callback URLs you registered, so register the ports in your blocks or keep auth tests in the main checkout.

Before you start five agents, list what they share outside the folder (database, compose ports, OAuth callbacks) and split each one the way the port is split.


If this was useful

The hook above is the whole plain-git version: paste it, run git worktree add, and check that the new folder has a .env, a node_modules and a .worktree-port. If you'd rather have the agent launcher, inbox and review on top, download Hermes IDE, which is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. If it saves you a broken test run, a star on GitHub helps other developers find it. For the agent patterns underneath parallel work like this, the AI Agents Pocket Guide is the short read.

AI Agents Pocket Guide: Patterns for Building Autonomous Systems with LLMs

Top comments (0)