{"slug": "git-worktrees-are-not-an-isolation-boundary-for-coding-agents", "title": "Git worktrees are not an isolation boundary for coding agents", "summary": "Git worktrees are not an isolation boundary for coding agents, according to a technical analysis of Git internals. A worktree shares refs, config, stash, and hooks with the parent repository, allowing an agent to execute code on the host via hooks, rewrite commit identities, and interfere with other agents' stashes. Proper isolation requires a full clone, which benchmarks show costs the same as a worktree.", "body_md": "# Git worktrees are not an isolation boundary for coding agents\n\nMost tools that run AI coding agents in parallel give each one a git worktree. A worktree shares refs, config, stash and hooks with your real repository. Paste-and-run repros showing an agent can execute code in your main repo and rewrite your commit identity, plus benchmarks showing a properly isolated clone costs the same.\n\nGive a coding agent its own git worktree, which is how most tools for running agents in parallel do it, and that agent can install a hook that runs on your machine the next time *you* commit in your real repository. It can rewrite the email your own commits are attributed to. It can pop another agent’s stash into its own tree.\n\nNone of that needs a bug or an exotic command. A worktree was never a boundary. It is a second working directory attached to one `.git`\n\n, and everything interesting lives in that `.git`\n\n.\n\nWorktrees became the default here because they are the obvious answer. One command, no duplicated history, a second checkout in about a second. The pitch is isolation at nearly zero cost, and both halves of that turn out to be wrong: the isolation is much thinner than the phrase “isolated worktree” suggests, and the cost of doing it properly is the same. I had the cost part wrong in an earlier version of this post, until I measured it.\n\n## What a linked worktree actually shares\n\nInside a linked worktree, `.git`\n\nis not a directory. It is a file:\n\nEverything follows from that path. Git splits repository state into per-worktree state and common state, and it will tell you which is which:\n\nPer-worktree state is a short list: `HEAD`\n\n, the index, `ORIG_HEAD`\n\n, and a few bisect and rebase files. Everything else resolves through `--git-common-dir`\n\nback into the original repository:\n\n- The object store (\n`.git/objects`\n\n), which is the point, and is safe to share. - Refs and branches (\n`refs/heads`\n\n,`packed-refs`\n\n). One namespace for every worktree. - The config (\n`.git/config`\n\n). - The stash (\n`refs/stash`\n\n). **Hooks**(`.git/hooks`\n\n), which is where a shared directory becomes arbitrary code execution.\n\nSo the isolation is real, and it is narrow: your working directory, `HEAD`\n\n, and the index. Those are useful. They are not a boundary. A boundary would mean a process confined to the worktree cannot reach state outside it, and the second list is a catalogue of the ways it can. “Isolated worktree” gets used constantly, in tool descriptions and in advice threads, without ever saying which of the two lists is meant.\n\n## What that lets an agent do\n\nWorst first. Each block creates its own repository, so you can paste them into one empty directory and run them in any order. Every line of output below is real, captured on git 2.50.1.\n\n### Execute code on your host\n\n`.git/hooks`\n\nlives in the common directory. A hook installed from inside a worktree is therefore installed for the parent repository, and it runs as you, in the parent, the next time you run the triggering command.\n\nThis is also why a worktree cannot be the isolation layer for a containerised agent. Handing a container a linked worktree means mounting the parent’s real `.git`\n\n, because that is where the worktree’s own state lives. A writable `.git/hooks`\n\non the host side of that mount runs on the host, and the container stops meaning anything.\n\n### Rewrite who your commits are from\n\nThe config is shared, so `git config`\n\ninside a worktree writes the parent’s `.git/config`\n\n:\n\nSit with the last two lines. That is not the agent’s commit. It is yours, in your repository, carrying an author line something else picked.\n\n### Take another worktree’s stash\n\nThere is one `refs/stash`\n\nper repository, and it behaves the way a single stack shared between uncoordinated writers would:\n\nOne detail is load-bearing, and it is why some versions of this repro floating around do not work: `file.txt`\n\nis committed *before* the branches are created, so it is tracked in both worktrees. Plain `git stash`\n\nignores untracked files, so on a brand-new file it prints `No local changes to save`\n\n, stashes nothing, and the `pop`\n\nfails with `No stash entries found`\n\n. Use a tracked file, or `git stash -u`\n\n.\n\n### Rewrite refs under a sibling\n\nEvery worktree writes into one `refs/heads`\n\nand one object store. An agent that runs `git gc`\n\nruns it for the whole repository, parent included. An agent that force-updates a branch, rebases, or reaches for `git reset --hard`\n\nis mutating refs that other worktrees resolve against, and a commit one agent orphans can go unreferenced under another’s feet. These are not exotic commands. They are what something reaches for when it gets confused and decides to tidy up.\n\n### Collide on a branch name\n\nShared refs also mean git refuses the same branch in two worktrees:\n\nHarmless next to the rest, but it is the first wall people hit, and it is what pushes them into generating a throwaway branch per worktree purely to satisfy git.\n\n## The fixes that do not fix it\n\nTwo mitigations come up every time this is discussed. Neither holds.\n\n**Per-worktree config.** `extensions.worktreeConfig`\n\nis off by default, and switching it on only helps if the writer opts into `git config --worktree`\n\n. A plain `git config`\n\n, which is what anything not specifically being careful will run, still writes the shared file:\n\n**Moving hooks out of .git.** This one is circular.\n\n`core.hooksPath`\n\nis config, and config is shared, so the worktree points it wherever it likes:`--separate-git-dir`\n\nfails the same way. It relocates `.git`\n\n, and every worktree still shares whatever it was relocated to.\n\nAll three constrain a writer that is trying to behave. None of them reduce what a worktree is *able* to write.\n\n## Then use a clone. It costs the same.\n\nThe standard objection is that a clone per worker means copying the history, and for a local source that is simply not what happens. This is the part I got wrong before, so here are numbers.\n\nMeasured against a full clone of [git/git](https://github.com/git/git): 81,772 commits, 4,828 tracked files, a 318 MB `.git`\n\n, a 58 MB working tree. Each row creates one new checkout from that local source.\n\nA note on method, because it changes the answer. Disk is measured as a cumulative delta over the whole tree. `du`\n\ncounts an inode once per invocation, so measuring a hardlinked clone on its own would credit it with bytes it never actually added.\n\n| Creating one checkout | Wall time | Disk added | `.git` apparent size |\nPackfiles copied |\n|---|---|---|---|---|\n`git clone --no-hardlinks` |\n1791 ms | 373 MB | 315 MB | 1 |\n`git clone` (local) |\n982 ms | 58.9 MB | 318 MB | 1 (hardlinked) |\n`git clone --shared` |\n870 ms | 58.9 MB | 660 KB | 0 |\n`git worktree add` |\n826 ms | 58.7 MB | 4 KB | 0 |\n\nLook at the bottom three rows. 58.7 to 58.9 MB, 826 to 982 ms. They are the same operation as far as your disk is concerned, and that number is the working-tree checkout, matching the source’s 58 MB tree almost exactly. Whichever mechanism you pick, you are paying for one copy of your files and nothing else that matters.\n\nA local `git clone`\n\ndoes not copy objects, it hardlinks them, which is why its `.git`\n\nreports 318 MB while adding 59 MB of real disk:\n\nOnly `--no-hardlinks`\n\npays for the copy, and nobody runs that by accident. If you have been avoiding local clones because you assumed they duplicate history, they do not.\n\n`--shared`\n\ngoes further and copies nothing at all. It writes one file:\n\nHence a 660 KB `.git`\n\nagainst the plain clone’s hardlinked 318 MB. The real property that buys you is independence from history size: `--shared`\n\nis O(1) in how much history the source has and O(n) in how many files you check out. The checkout dominates, which is what the table shows.\n\nI have described this before, in this post, as costing “kilobytes and milliseconds.” That is true of the metadata and false of the operation. It costs kilobytes of `.git`\n\nplus one working tree, in under a second.\n\nFor that same 59 MB, here is what you get:\n\n| State | Worktree | `--shared` clone |\n|---|---|---|\n| Object store | shared | shared, via alternates |\n| Refs / branches | shared | isolated |\n| Config | shared | isolated |\n| Stash | shared | isolated |\n| Hooks | shared | isolated |\n| Index | isolated | isolated |\n| HEAD | isolated | isolated |\n\nBoth share the one thing that is expensive to copy and safe to share. The clone also isolates the five that turn into footguns as soon as there is more than one writer.\n\n## What `--shared`\n\ncosts you\n\nIt borrows objects, so it depends on the source object store staying where it is. Do not delete the source underneath it, and do not run an aggressive `git gc`\n\non the source while clones are live, or you can prune objects a clone still references. `git clone --dissociate`\n\ncopies the borrowed objects in and cuts the dependency if you want out later.\n\nA plain hardlinked clone is the more robust of the two, and the reason is worth knowing. A hardlink keeps the inode alive by itself: if the source deletes or repacks that packfile, the clone’s link holds the data open anyway. `--shared`\n\nhas no such protection, because it holds a path rather than a reference. What you give up is the O(1) property, permanent deduplication, and the ability to cross filesystems, which starts to matter the moment workspaces live on another volume or get bind-mounted into a container.\n\nNeither is strictly better. They fail differently, and both isolate refs, config, stash and hooks.\n\n## When a worktree is the right call\n\nWorktrees share a `.git`\n\ndeliberately, and for the job they were built for that sharing is the whole point:\n\n- One person moving between two or three branches. You\n*want*branches, stash and config visible everywhere. - A quick build or test of another branch without disturbing your index.\n- Anything where you are the only writer and you know what each command will do before you run it.\n\nEven with agents, one or two of them supervised by someone reading each diff is fine on a worktree and a shell alias. What does not survive is uncoordinated writers. “Share a `.git`\n\n” and “run several processes that act without asking” are in direct tension, and no amount of care in the driving program changes what the shared directory permits.\n\nSo: worktree when a person is driving, clone when something else is. The cost is the same either way, which means the only thing you are really choosing is how much damage a confused process can do.\n\n*Disclosure: I build Fletch, a macOS app that runs coding agents, so I had to pick one of these. It gives each agent a git clone --shared, for the reasons measured above. Its worktree mode is behind a developer flag, because under a container it would mean mounting the real .git, and the hook in the first repro would run on the host.*", "url": "https://wpnews.pro/news/git-worktrees-are-not-an-isolation-boundary-for-coding-agents", "canonical_source": "https://fletch.sh/blog/git-worktrees-vs-clones-for-ai-agents/", "published_at": "2026-07-30 14:18:52+00:00", "updated_at": "2026-07-30 15:35:29.529712+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Git"], "alternates": {"html": "https://wpnews.pro/news/git-worktrees-are-not-an-isolation-boundary-for-coding-agents", "markdown": "https://wpnews.pro/news/git-worktrees-are-not-an-isolation-boundary-for-coding-agents.md", "text": "https://wpnews.pro/news/git-worktrees-are-not-an-isolation-boundary-for-coding-agents.txt", "jsonld": "https://wpnews.pro/news/git-worktrees-are-not-an-isolation-boundary-for-coding-agents.jsonld"}}