Worktrees Isolate Your Files, Not Your Systems

Read22 min · Scope → Isolate → Supervise → Land · Updated 2026-09-13

You end this lesson with: fleet/isolate.sh and fleet/port-map.md — a worktree launcher that assigns a port range and a private database per agent.

Chapters

  1. What is actually going on
    01
  2. The rule and its condition
    02
  3. Worked case
    03
  4. Faded case
    04
  5. Open case
    05
  6. Where this breaks
    06
view

Lesson 04. Worktrees Isolate Your Files, Not Your Systems

Three agents work the same repository in three separate git worktree checkouts. Each one has its own branch and its own working directory: an immaculate slice of git history. Each one also runs the dev server on port 3000, because port 3000 is what the .env.local file, copied by hand into each tree, says to use. Each one also opens the same SQLite file at ./data/app.db, because that path came from the same copied file too. Agent one binds the port first. The other two silently fail to bind and nobody notices, because the terminal still prints "ready." Agent two starts a schema migration against the shared database while agent three's test suite is mid-run against that same file. The test suite reports failures that have nothing to do with the code agent three wrote. By the time a human looks, the git history is clean and the database is not. Decide now: which part of this setup did the worktree actually protect?

This lesson's deliverable is fleet/isolate.sh and fleet/port-map.md — a worktree launcher that assigns a port range and a private database per agent. It serves the Isolate stage of the course loop: the stage where you decide exactly what each agent may touch, and what stops it from touching anything else.

What is actually going on

A git worktree adds a second working directory for a repository you already have, checked out to a branch or commit of your choice, backed by the same object database and refs as every other checkout of that repository. Objects, most refs, and repo config are shared; only HEAD, the index, and a small amount of per-worktree metadata under $GIT_DIR/worktrees/<id>/ are genuinely separate per tree (git-scm.com/docs/git-worktree, observed 2026-09-11). That is the entire guarantee: which commit is checked out here, and what is staged here, are private. Nothing else is.

Everything the opening scenario collided on sits outside that guarantee. Git-ignored files, .env and .env.local among them, are never copied into a new worktree; a tree that "looks broken" on first run is almost always a forgotten environment file, not a code bug. Installed dependencies are the same story: node_modules is not tracked, so each tree needs its own install, and a shared symlink across branches works only until the branches' dependency trees diverge. Ports and database connections are resolved at the operating-system level, which has no concept of which git worktree asked for them; two trees binding the same port or writing the same file path are indistinguishable from one process doing it twice. Background processes, a dev server or a file watcher started from inside a tree, keep running after you stop looking at that tree, and git worktree remove does nothing to stop them. Git hooks live in a single .git/hooks directory by default, shared across every worktree unless you set core.hooksPath to something per-tree. Submodules are worse: git's own documentation calls multiple checkouts of a superproject with submodules experimental and unsupported, and git worktree move refuses outright to relocate a tree that contains one.

Installed dependencies deserve their own line, because the fix is more specific than "run install again." A full node_modules reinstall per worktree costs real time and disk, often five to fifteen minutes and multiple gigabytes with a plain npm or yarn install (aggregated via dev.to/rohansx and gitworktree.org, observed 2026-09-11, confidence: medium). Symlinking one worktree's node_modules into another to skip that cost works only until the two branches' dependency trees diverge, at which point the symlinked tree silently runs whatever version the other branch resolved to, the exact trap the check fence below names. pnpm's content-addressable global store is the practical way around the cost without that trap: every worktree still gets its own node_modules directory, but the files inside it are hard-linked from one store shared across every checkout on the machine, so a second install is fast and disk-light instead of a second full download. That is what the contrast table means by "a package manager's shared, content-addressed store." Bun's own install cache works on a similar principle, a global content-addressed store with per-project hard links, which is why a second bun install in another worktree of the same repo is usually much faster than the first even though each tree still ends up with its own node_modules directory.

Look at what actually crosses from the shared repository into each worktree, and what stays behind at the host level.

diagram
drawing
Three worktrees share one object store, one port, and one database unless you allocate around it.

Every arrow into the shared boxes on the right is a collision waiting on timing: whichever agent's process happens to bind the port or open the file first wins, and the others fail silently or, worse, succeed against the wrong data.

The rule and its condition

Use a plain git worktree add when the only thing separating your agents is which branch is checked out, between agents you already trust with your host's ports, its installed dependencies, and its credentials. Isolate every other shared resource yourself, port, database, caches, background processes, hooks, and submodules, or accept that two of your agents will eventually collide on one of them.

ResourceDoes a worktree isolate it?What to do about it
Working tree and branchYesNothing; this is the one thing worktrees are for
Committed objects and refsShared by designNothing to fix; the sharing is the point
Ignored env files (.env, .env.local)NoCopy explicitly at launch, then override the keys that must differ per agent
Installed dependencies (node_modules, venv)NoInstall per worktree, or use a package manager's shared, content-addressed store
Network portsNoAllocate a port block per agent from a table before launch
Database stateNoGive each agent its own database file, name, or schema
Background processesNoKill them when you remove the worktree; git worktree remove never does
Git hooksShared by defaultSet core.hooksPath per worktree if agents need different hooks
SubmodulesUnsupported for multi-checkoutRoute the submodule-touching work to a dedicated clone instead

Repo config is one more thing shared by default that this table doesn't have a row for, because it rarely bites: .git/config applies to every worktree until you opt in with git config extensions.worktreeConfig true, which enables a config.worktree file per tree for settings that should genuinely differ, a different core.sparseCheckout pattern for an agent scoped to one package of a monorepo, for instance (git-scm.com/docs/git-worktree, observed 2026-09-11). Nothing in this lesson's launcher needs it. It's the primitive to reach for the day two agents need different sparse-checkout scopes on the same monorepo, without giving each of them a separate clone.

Worked case

Nimbus Freight runs three agents overnight against a booking-service repository, each fixing a different failing test file, nobody watching. Their .env.local, checked into a shared template once and never revisited, sets PORT=3000 and DATABASE_URL=file:./data/app.db. The first overnight run is exactly the opening scenario: one dev server actually bound, two databases silently shared, one confusing morning. Decide now: what has to exist before the second run that did not exist before the first?

The fix is an allocation table the team writes once, and a launcher that reads it every time.

markdown
# fleet/port-map.md

| agent   | port | db_name |
|---------|------|---------|
| agent-a | 3101 | agent_a |
| agent-b | 3111 | agent_b |
| agent-c | 3121 | agent_c |

Each row's port is a base value, ten apart from the next agent's, leaving room for a dev port, a test port, and a websocket port per agent without touching the next row's block, the same step-by-ten convention uzi.yaml's portRange setting uses for the same problem (github.com/devflowinc/uzi, observed 2026-09-11).

sh
#!/usr/bin/env sh
# fleet/isolate.sh
# Launches one isolated agent workspace: a git worktree, a
# derived port block, and a private database file.
#
# Usage: fleet/isolate.sh <agent-name> [base-branch]
# Example: fleet/isolate.sh agent-a main

set -eu

AGENT="${1:?usage: isolate.sh <agent-name> [base-branch]}"
BASE_BRANCH="${2:-main}"

REPO_ROOT="$(git rev-parse --show-toplevel)"
PORT_MAP="$REPO_ROOT/fleet/port-map.md"
WORKTREE_DIR="$REPO_ROOT/.worktrees/$AGENT"
DB_DIR="$REPO_ROOT/.worktrees/db"
BRANCH="agent/$AGENT"

[ -f "$PORT_MAP" ] || {
  echo "no fleet/port-map.md. Add a row for $AGENT first." >&2
  exit 1
}

# 1. Look up this agent's row. The table is the allocation;
# this script only reads it, it never invents a port.
row=$(awk -F"|" -v agent="$AGENT" '
  { gsub(/^[ \t]+|[ \t]+$/, "", $2) }
  $2 == agent {
    gsub(/^[ \t]+|[ \t]+$/, "", $3)
    gsub(/^[ \t]+|[ \t]+$/, "", $4)
    print $3 "|" $4
  }
' "$PORT_MAP")

[ -n "$row" ] || {
  echo "no port-map.md row for $AGENT. Add one, then rerun." >&2
  exit 1
}

BASE_PORT=${row%%|*}
DB_NAME=${row##*|}
DEV_PORT=$BASE_PORT
TEST_PORT=$((BASE_PORT + 1))
WS_PORT=$((BASE_PORT + 2))
DB_PATH="$DB_DIR/$DB_NAME.db"

# 2. Create the worktree if it does not already exist. Reuse an
# existing one instead of failing on a second launch.
if [ -d "$WORKTREE_DIR" ]; then
  echo "reusing existing worktree at $WORKTREE_DIR"
else
  git worktree add -b "$BRANCH" "$WORKTREE_DIR" "$BASE_BRANCH"
fi

# 3. Copy the ignored env files a worktree never brings along.
for f in .env .env.local; do
  [ -f "$REPO_ROOT/$f" ] && cp "$REPO_ROOT/$f" "$WORKTREE_DIR/$f"
done

# 4. Strip any existing PORT/DATABASE_URL lines from the copy so
# our values are the only ones a dotenv loader will ever see,
# regardless of that loader's own override order.
for f in .env .env.local; do
  target="$WORKTREE_DIR/$f"
  [ -f "$target" ] || continue
  grep -v -E "^(PORT|TEST_PORT|WS_PORT|DATABASE_URL)=" \
    "$target" > "$target.tmp"
  mv "$target.tmp" "$target"
done

cat >> "$WORKTREE_DIR/.env.local" <<ENVEOF
PORT=$DEV_PORT
TEST_PORT=$TEST_PORT
WS_PORT=$WS_PORT
DATABASE_URL=file:$DB_PATH
ENVEOF

# 5. Provision this agent's own database file, distinct from
# every other agent's, so migrations and writes never cross.
mkdir -p "$DB_DIR"
touch "$DB_PATH"

echo "agent:    $AGENT"
echo "worktree: $WORKTREE_DIR"
echo "branch:   $BRANCH"
echo "ports:    dev $DEV_PORT, test $TEST_PORT, ws $WS_PORT"
echo "database: $DB_PATH"
echo
echo "Still your job: install dependencies inside $WORKTREE_DIR,"
echo "and stop any background process before you remove the tree."
diagram
drawing
The launcher's decision path from an agent name to a running, isolated workspace.

Running fleet/isolate.sh agent-a main, then agent-b, then agent-c against the table above gives three worktrees, three distinct three-port blocks, and three distinct database files under .worktrees/db/. A quick curl -sf localhost:3101 and curl -sf localhost:3111 confirm two different servers answer, not one server answering twice; comparing the three files under .worktrees/db/ by size after each agent's own migration confirms three separate databases, not three paths to one.

Illustrative composite. A comparable team, three developers sharing one monorepo, once spun up five worktrees for five agents in a single morning and ran a plain npm install in each rather than reaching for a shared store. The installs alone cost roughly seven and a half minutes back to back, about two minutes running in parallel, before any agent had written a line (aggregated via dev.to/rohansx and gitworktree.org, observed 2026-09-11, confidence: medium). Three of the five trees were also missing .env.local, because nobody had built a launcher like this lesson's yet; each one failed its first dev-server start with a database-connection error that had nothing to do with the code the agent had written, and it cost the team roughly fifteen minutes of confused debugging before anyone traced the failure back to a missing file rather than a bug. Both costs, the install time and the debugging time, are the price of treating a worktree as a finished environment instead of a checkout that still needs its ignored files and its dependencies put back. The team's actual fix afterward was smaller than the pain suggested: a five-line addition to their own launcher that copied .env.local and ran the install before printing "ready," the same order fleet/isolate.sh already follows above. Nothing about the fix required understanding git any more deeply; it required writing down, once, everything a worktree does not hand you for free.

Faded case

Skip to the open case if this is familiar. Nimbus Freight is adding agent-d, whose task is a long-running data-backfill script that also opens a websocket to stream its own progress. The current table holds agent-a at 3101, agent-b at 3111, agent-c at 3121. Decide what row to add for agent-d, and whether the websocket needs a block of its own beyond what the launcher already derives.

Open case

A team scales from three agents to eight on the same laptop. They keep the ten-port-step convention and add eight rows to port-map.md starting at 3100, none of them overlapping any other row in the file. On agent six's launch, fleet/isolate.sh reports success, the worktree exists, the database file exists, but agent six's dev server refuses to bind and exits immediately. Nothing else in the setup changed.

Where this breaks

The allocation table has no validator. Two rows can duplicate a port or a database name, and nothing here will catch it before a collision, only after. A worktree living on a network-mounted or removable volume can also be pruned out from under you: git worktree prune clears stale metadata once a missing directory has been gone longer than gc.worktreePruneExpire, so an agent you expect to be offline for a while needs git worktree lock --reason "..." first, or its tree can vanish while it is still in progress (git-scm.com/docs/git-worktree, observed 2026-09-11).

Stale worktrees also need active cleanup, not just a lock against garbage collection. git worktree list -v (or --porcelain for scripting) shows every worktree on the repo at a glance, flagging locked, prunable, and detached states, so you can see at a glance which agent trees are still legitimately in flight and which ones a human simply forgot to remove. git worktree prune -n previews what a real prune would delete before you run it without the flag; git worktree remove itself refuses a tree with uncommitted changes unless you add -f, and refuses a locked one unless you add -ff, two separate speed bumps against deleting an agent's in-progress work by accident. And if a worktree directory is ever moved by hand instead of through git worktree move, its link back to .git breaks silently; git worktree repair re-establishes that link rather than forcing you to recreate the tree from nothing.

Background processes need the same discipline as the worktree itself. Nothing about git worktree remove touches a process that started from inside the tree, so a dev server or file watcher an agent launched can keep running, and keep holding its port, long after the worktree itself is gone. Track the process id your launcher started ($! right after the command it backgrounds) in a file inside the worktree, and have your cleanup step read that file and signal the process before it removes anything; a worktree with no running process left behind is the actual finish line, not just an absent directory.

And submodules simply do not fit this pattern: a repository that depends on them needs a dedicated clone for whichever agent touches them, not another row in this table, exactly the kind of case the next lesson's tier decision exists to route around instead of force.

Key Takeaways

  • A worktree shares git's object store and separates only the branch and working directory.
  • Ports, databases, caches, and background processes stay shared unless you allocate them yourself.
  • Allocate a port block and a database name per agent before launch, never after a collision.
  • The allocation table trusts whoever edits it; nothing here validates for duplicates.
  • Lock a worktree you expect to leave idle, or garbage collection can prune it out.
  • Submodules and multi-checkout worktrees do not mix; route around them, don't fight them.

What You Have Now

fleet/isolate.sh and fleet/port-map.md — a worktree launcher that assigns a port range and a private database per agent. The script reads the table, creates the agent's worktree if it doesn't exist, rewrites the environment keys that must differ per agent, and provisions a private database file. It sits in your growing Fleet Kit next to the task-template and cost-model files from Module 1, and it is the first artifact of the Isolate stage: every decision Module 2 adds after this, the tier choice, the permission policy, the credential scope, assumes your agents have already stopped fighting each other over a port.

Further Reading

  • git-scm.com/docs/git-worktree: the primitive itself, including locks, prune, repair, and per-worktree config.
  • containers.dev/implementors/json_reference: where the launcher's job goes next once a worktree stops being enough.
  • github.com/devflowinc/uzi: a portRange config pattern for N concurrent dev servers, the same idea behind port-map.md.
  • alexlavaee.me's write-up on the parallel-agent-sessions infrastructure gap: names the port and database collision problem directly.

Exercises

  1. Run isolate.sh for two agents against a real or sample repo, then curl each dev port and confirm they answer distinctly.
  2. Add a third row to port-map.md, launch that agent, and confirm its database file diverges from the other two after each runs its own migration.
  3. Extend isolate.sh so it refuses to launch an agent whose port-map.md row duplicates an already-allocated port or database name, and note what new failure mode your fix introduces.

Check your understanding


Reading is free — your progress isn't being saved

Nothing you did on this page is recorded. Paid students get saved progress and verified completions, recorded quiz answers, the spaced-repetition review queue, labs, and a certificate for each course they finish.

30-day money-back guarantee. One-time payment — lifetime access and updates.