One task, one worktree: isolating AI coding agents with git worktree
The cheapest way to shrink an agent's blast radius is to keep it out of your checkout. The details that make that actually safe.
Ler em português Markdown version

Why isolate coding agents
A coding agent working in the same directory as you creates three problems, and all of them get worse with more than one agent:
- Collisions. Two agents editing the same checkout overwrite each other, and
git statusbecomes a mix nobody can untangle. - Contamination. You're halfway through a change, the agent runs the tests, they fail because of your unfinished code, and it "fixes" something that wasn't its to fix.
- Blast radius. A careless
git checkout .orrmfrom the agent hits your uncommitted work.
A container solves all three, but costs an image, volumes and mounted credentials. A separate clone solves them too, but duplicates .git and loses shared objects. git worktree sits in the middle: independent working directories, one repository behind them.
The basics in one command
git worktree add -b feat/TASK-0042 .factory/worktrees/app/TASK-0042 main
That creates the directory, creates the branch from main, and checks it out. The agent gets that directory as its cwd and nothing else. When the task is done and the work is committed, git worktree remove takes the directory away.
If that were all, this post would end here. The rest is what goes wrong when a factory creates hundreds of these automatically.
The guards that make it safe
T25's WorkspaceManager is treated as security-sensitive code. Every item below exists because the case without it is plausible.
1. Git without a shell
Every git call goes through execFile with an argument list, never a shell string. The task ID and branch name are never interpolated into a command, so a task title containing ; rm -rf is just text. The process also has a hard timeout and is killed with SIGKILL, so a hung git doesn't leave the repo half-written while something else tries to clean up.
2. Validated IDs and branches
The task ID must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ before it becomes part of a path. The base branch goes through git check-ref-format --branch. Nothing containing .. or a slash makes it into the path.
3. The path has to stay inside the root
Before any worktree path is used, it's resolved with realpath and compared against the configured root. If the relative path starts with .., is absolute, or is empty, the operation fails. That catches the symlink case: a directory that looks like it's inside the root but points outside.
4. Recorded ownership, recorded twice
A directory existing in the right place doesn't prove the factory created it. Before reusing a worktree, T25 requires two things:
- a metadata file (
.factory-metadata/<TASK-ID>.json) with the task ID, branch and canonical path, all three matching; - the path listed in
git worktree list --porcelainwith the same branch.
Without both, the factory refuses to adopt the directory. That keeps it from "inheriting" a folder someone made by hand or another tool left behind.
5. A lock per task
Two processes preparing the same worktree at once is a classic race. The lock is a file created with the wx flag (fails if it already exists) holding the PID of whoever took it. If the lock exists and its owner is alive, the second attempt fails with the right message. If the owner died, the lock is treated as stale and taken over.
6. Sync the base before branching
This guard came from a real mistake. During a dogfooding round, a task was branched from a local main that was behind origin: a pull request had been merged directly on GitHub, outside the factory. The result was three rounds of QA and implementation chasing a test assertion that had already been fixed upstream.
Now, before creating a new worktree, T25 runs git fetch origin main followed by git merge --ff-only origin/main. The --ff-only is the important part: if the local clone diverged for any reason, the operation fails loudly instead of continuing from the wrong base.
7. Cleanup that doesn't delete work
cleanup() checks ownership again, runs git status --porcelain --untracked-files=all, and refuses to continue if anything changed, untracked files included. It never uses --force. Losing an agent's work to an automatic cleanup is worse than leaving a directory behind.
Branch names that pass your repo's rules
The branch uses the task type's prefix, Conventional Commits style: feat/, fix/, refactor/, docs/, chore/, followed by the ID. It's a practical choice: many repos have pre-push hooks or protection rules that reject non-standard names, and a factory/... branch would die on push.
Does a worktree solve everything?
No. A worktree isolates the repository's files, not the process. The agent still runs as your user, can see the rest of the machine, and uses its CLI's credentials. That's why T25 stacks another layer by risk: medium-risk tasks and above can run the agent inside a Docker sandbox, with a signed policy the worker validates before executing. Real network restriction for that sandbox is still on our list. A worktree is the first layer, not the only one.
| Option | Isolates repo files | Isolates the process | Setup cost | Shares git objects |
|---|---|---|---|---|
| Main checkout | No | No | None | Yes |
git worktree |
Yes | No | Low | Yes |
| Separate clone | Yes | No | Medium | No |
| Container + volume | Yes | Yes | High | Depends |
A checklist to do the same
If you're building isolation for your own agents:
- One worktree and one branch per task, created from a base synced with
--ff-only. - Git called without a shell, with a timeout and
SIGKILL. - Task IDs validated by regex before becoming a path;
realpathand a root check on every use. - Ownership written to metadata and confirmed in
git worktree list. - A per-task lock with a PID and stale-lock detection.
- A cleanup that refuses dirty directories and never forces.
FAQ
Can I run several agents in parallel on the same repo?
Yes, as long as each one has its own worktree. They share git's object store, but each has its own working directory, index and branch.
Is the worktree deleted when the task finishes?
Only if it's clean. T25's cleanup refuses to remove a worktree with uncommitted changes or untracked files, and never uses --force.
Why not use a container for everything?
A container isolates more, but costs an image, volumes and mounted credentials for every task. A worktree handles collisions and contamination at almost no cost. T25 layers both: a worktree always, a Docker sandbox from medium risk up.
Where does T25 keep its worktrees?
In a local, git-ignored folder, by default under .factory/worktrees/, split per project. None of it is committed.