DEV Community

Cover image for Git worktrees as agent isolation: what broke, with the code
Serhii
Serhii

Posted on

Git worktrees as agent isolation: what broke, with the code

Giving a coding agent its own git worktree has become the standard advice for letting it work without trampling your checkout. It's cheap, it's native to git, and it gives every task its own branch. I build VADD, a local dashboard that wraps Claude Code and Codex, and every objective there runs in its own worktree under ~/.vadd/worktrees/<projectId>/<objectiveId>.

The advice is right. But nobody mentions the part where it goes wrong, which is the part that matters when you automate it. Here's what broke for me, roughly in the order I hit it.

1. git worktree remove can succeed while the files stay on disk

This was the worst one because it lied.

My original teardown looked like the obvious thing:

await git(repo, ['worktree', 'remove', '--force', path])
await git(repo, ['worktree', 'prune'])

// post-condition: is it gone?
if ((await listWorktrees(repo)).includes(path)) throw new Error('still registered')
Enter fullscreen mode Exit fullscreen mode

One day I checked disk usage and found about 1.7 GB across five directories under the worktrees root. Two of them came from a cleanup run that had reported 26/26 success.

What happened:

  1. git worktree remove --force deletes the worktree's .git link file first, then recursively deletes the rest.
  2. The recursive delete failed partway. The repo was a Laravel app, and an in-container composer install had written backend/vendor as root:root. My user can't unlink files in a directory it doesn't own.
  3. git had already removed its admin entry. And the missing .git link file is exactly what causes git worktree prune to deregister a worktree.
  4. So my post-condition asked git "is this worktree still registered?", git truthfully said no, and the function returned success while the directory stayed on disk.

Git-level success and filesystem-level success are two different facts. Check both:

const target = resolve(worktreePath)
if ((await listWorktrees(repoPath)).some((w) => resolve(w) === target)) {
  throw new GitError(`Worktree still registered after removal: ${worktreePath}`, 'EGIT')
}
if (existsSync(target)) {
  throw new GitError(
    `Worktree directory still on disk after removal: ${worktreePath}. ` +
      'Git has deregistered it, so nothing will retry this. Most likely it ' +
      'holds files this user cannot unlink (a root-owned vendor/ or ' +
      'node_modules/ written by an in-container install).',
    'EGIT',
  )
}
Enter fullscreen mode Exit fullscreen mode

You can reproduce it without root. Unlinking a file is authorised by the directory that contains it, not by the file itself:

mkdir -p wt/locked && touch wt/locked/f && chmod 555 wt/locked
# now `git worktree remove --force` on wt fails halfway, and prune hides it
Enter fullscreen mode Exit fullscreen mode

A follow-up lesson: git worktree list can't show you this kind of leak at all, because git no longer knows the directory exists. My UI listed worktrees from git and couldn't display the leak it was meant to prevent. The fix was to also readdir the worktrees root and report anything no database row or git entry claims.

2. A fresh worktree can't run the project's tests

An agent that's asked to work test-first needs to run the tests. A new worktree has no node_modules, no vendor, no .env: only tracked files.

Symlinking the root node_modules isn't enough with pnpm workspaces. I tried it on my own repo and still got Cannot find package 'zod', because pnpm resolves through per-package node_modules too.

What I ended up with is a per-repo setup step in .vadd/config.json that runs once, when the worktree is created. It used to run before the first verification, and that was too late, since the agent needs to run tests in its very first task.

{
  "verify": {
    "setup": [{ "id": "deps", "run": "pnpm install --frozen-lockfile" }],
    "commands": [{ "id": "test", "run": "pnpm test", "required": true }]
  }
}
Enter fullscreen mode Exit fullscreen mode

If setup fails, the objective stops in setup_failed with the log kept as evidence, instead of letting the agent try to work in a worktree that can't build.

3. Hard-linking dependencies silently skips files

For big PHP vendor/ directories I tried cp -al (hard-link copy) to avoid reinstalling. It looked like it worked. It hadn't.

With fs.protected_hardlinks=1, which is the default on most distros, the kernel refuses to hard-link a file you don't own. cp -al doesn't fail loudly when that happens. About 1,800 root-owned files in vendor/ were just missing, and the worktree looked complete.

If you do this, follow the link pass with a copy pass that fills in the gaps:

cp -al  "$MAIN/backend/vendor" "$WT/backend/vendor"
cp -a --no-clobber "$MAIN/backend/vendor/." "$WT/backend/vendor/"
Enter fullscreen mode Exit fullscreen mode

A broader point: hard-linking root-owned trees into every worktree is also how you create the un-removable directories from trap #1. Link only what the commands actually need, and prove the need by running the commands without it first.

4. docker compose exec works on the wrong checkout

Many projects bind-mount the main checkout into their containers. An agent working in a worktree that runs docker compose exec app php artisan test is testing code it didn't write, from the main checkout, and gets a result that has nothing to do with its changes.

There's no clever fix for this inside git. What worked for me was a setup step that points a copied .env at the containers' host-exposed ports (database, cache), so the test runner runs on the host inside the worktree, and Docker only provides services, not the code.

5. Protected files leaked into the squash commit

That setup step had a side effect. It rewrote a tracked file (backend/.env.testing) to remap ports. The agent's checkpoint commits use git add -A, so the change went into every checkpoint, and the final squash would have carried it onto the branch and broken every other developer's Docker setup.

I already had a protectedGlobs config field. It was resolved, merged, persisted, and read by nothing. The lesson: a config field with no reader isn't a policy, it's a comment that costs a migration.

The enforcement point ended up in the squash, not in setup. "Arbitrary shell must never touch tracked files" is a rule I can't enforce. "This squash contains no protected path" is a rule I can check at exactly the right moment:

git add -A
git reset --soft "$BASE_SHA"
# staged paths matching a protected glob, from `git diff --cached --name-only -z`:
git reset -q "$BASE_SHA" -- "${excluded[@]}"
Enter fullscreen mode Exit fullscreen mode

That last reset puts each protected path's index entry back to how it was at base. A protected file the agent created didn't exist at base, so it drops out of the index entirely.

VADD then emits an event naming every path it excluded. A squash that silently leaves out a file the agent believed it changed is the same kind of lie as a dropped log line.

One more ordering bug turned up here. The first version refused to integrate when it couldn't read the policy, but it made that check after reset --soft had already moved the branch. A refusal that happens after the first mutating step isn't a refusal. Read everything a guard needs before you touch the worktree.

6. Two worktrees, one ID

The last one isn't really git's fault. My plan-task table used the task's ordinal ('0', '1', '2') as a global primary key. That's unique within one objective and not unique anywhere else. The second objective to reach planning hit a UNIQUE violation, the error was swallowed, and the user was shown a plan with no rows behind it.

Every test used one objective at a time. A fixture with one of the thing under test can't show you a collision between two of them. If you're isolating parallel work, make sure your tests run parallel work.

The checklist

If you're building on worktrees for agents:

  • After removal, assert both that git no longer lists the path and that it's gone from disk.
  • Scan the worktree root yourself. git worktree list can't see a directory git has already forgotten.
  • Run dependency setup at worktree creation, and treat a setup failure as a hard stop.
  • Don't trust cp -al with files owned by another user.
  • Check whether anything in the project is bind-mounted to the main checkout.
  • Enforce "don't commit this" where the commit happens, and report what you excluded.
  • Test with two or more concurrent worktrees.

All of this is in VADD's repo under packages/server/src/git/, and the full list of traps is in CLAUDE.md. Next post: the process-management side of running an agent's test suite, where a 1-second timeout took 5 seconds and left the process running.

Top comments (0)