Skip to content
HN On Hacker News ↗

Git worktrees are not an isolation boundary for coding agents

▲ 32 points 35 comments by alchaplinsky 3w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this entire text is AI.

99 %

AI likelihood · overall

AI
0% human-written 100% AI-generated
SEGMENTS · HUMAN 0 of 1
SEGMENTS · AI 1 of 1
WORD COUNT 1,428
PEAK AI % 99% · §1
Analyzed
Jul 30
backend: pangram/v3.3
Segments scanned
1 windows
avg 1428 words each
Distribution
0 / 100%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,428 words · 1 segments analyzed

Human AI-generated
§1 AI · 99%

All posts gitMost 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.Alex ChaplinskyJul 30, 2026·12 min readGive 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. None 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, and everything interesting lives in that .git. Worktrees 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.

Inside a linked worktree, .git is not a directory. It is a file: $ cat ../my-worktree/.gitgitdir: /path/to/repo/.git/worktrees/my-worktree Everything follows from that path. Git splits repository state into per-worktree state and common state, and it will tell you which is which: $ cd ../my-worktree$ git rev-parse --git-dir # per-worktree/path/to/repo/.git/worktrees/my-worktree$ git rev-parse --git-common-dir # shared with the parent and every sibling/path/to/repo/.git Per-worktree state is a short list: HEAD, the index, ORIG_HEAD, and a few bisect and rebase files. Everything else resolves through --git-common-dir back into the original repository:

The object store (.git/objects), which is the point, and is safe to share. Refs and branches (refs/heads, packed-refs). One namespace for every worktree. The config (.git/config). The stash (refs/stash). Hooks (.git/hooks), which is where a shared directory becomes arbitrary code execution.

So the isolation is real, and it is narrow: your working directory, HEAD, 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. What that lets an agent do Worst 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. Execute code on your host .git/hooks lives 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. git init -q --initial-branch=main hook-democd hook-demogit commit -q --allow-empty -m initgit branch agent-workgit worktree add -q ../hook-agent agent-work# The "isolated" worktree writes a hook into the parent's .gitcd ../hook-agentcat > "$(git rev-parse --git-common-dir)/hooks/pre-commit" <<'EOF'#!/bin/shecho "*** hook running as $(whoami) in $(pwd) ***"EOFchmod +x "$(git rev-parse --git-common-dir)/hooks/pre-commit"# Now you, in your own repository, make an ordinary commitcd ../hook-demogit commit -q --allow-empty -m "an ordinary commit"# -> *** hook running as you in /path/to/hook-demo *** This 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, because that is where the worktree’s own state lives. A writable .git/hooks on the host side of that mount runs on the host, and the container stops meaning anything. Rewrite who your commits are from The config is shared, so git config inside a worktree writes the parent’s .git/config: git init -q --initial-branch=main id-democd id-demogit commit -q --allow-empty -m initgit branch agent-workgit worktree add -q ../id-agent agent-workcd ../id-agentgit config user.email "agent@example.com"cd ../id-demogit config user.email# -> agent@example.comgit config --show-origin user.email# -> file:.git/config agent@example.comgit commit -q --allow-empty -m "a commit you made yourself"git log -1 --format='%an <%ae>'# -> Your Name <agent@example.com> Sit 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. Take another worktree’s stash There is one refs/stash per repository, and it behaves the way a single stack shared between uncoordinated writers would: git init -q --initial-branch=main stash-democd stash-demoecho original > file.txt && git add file.txt && git commit -q -m "add file"git branch branch-agit branch branch-bgit worktree add -q ../stash-a branch-agit worktree add -q ../stash-b branch-bcd ../stash-aecho "AGENT A PRECIOUS WORK" > file.txtgit stash -q # A parks its workcd ../stash-bgit stash pop # B pops it, without ever asking for itcat file.txt# -> AGENT A PRECIOUS WORKcd ../stash-agit stash list # -> emptycat file.txt # -> original. A's work is now in B's tree. One detail is load-bearing, and it is why some versions of this repro floating around do not work: file.txt is committed before the branches are created, so it is tracked in both worktrees. Plain git stash ignores untracked files, so on a brand-new file it prints No local changes to save, stashes nothing, and the pop fails with No stash entries found. Use a tracked file, or git stash -u. Rewrite refs under a sibling Every worktree writes into one refs/heads and one object store. An agent that runs git gc runs it for the whole repository, parent included. An agent that force-updates a branch, rebases, or reaches for git reset --hard is 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. Collide on a branch name Shared refs also mean git refuses the same branch in two worktrees: $ git worktree add ../probe mainPreparing worktree (checking out 'main')fatal: 'main' is already used by worktree at '/path/to/repo' Harmless 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. The fixes that do not fix it Two mitigations come up every time this is discussed. Neither holds. Per-worktree config. extensions.worktreeConfig is off by default, and switching it on only helps if the writer opts into git config --worktree. A plain git config, which is what anything not specifically being careful will run, still writes the shared file: git config extensions.worktreeConfig true # you opt in, in the parentcd ../my-worktreegit config --worktree user.email scoped@example.comgit -C ../repo config user.email # -> you@example.com, unaffectedgit config user.email leaked@example.comgit -C ../repo config user.email # -> leaked@example.com Moving hooks out of .git. This one is circular. core.hooksPath is config, and config is shared, so the worktree points it wherever it likes: cd repo && git config core.hooksPath ../safe-hooks # your mitigationcd ../my-worktreemkdir -p evil-hooks && printf '#!/bin/sh\necho pwned\n' > evil-hooks/pre-commitchmod +x evil-hooks/pre-commitgit config core.hooksPath "$(pwd)/evil-hooks" # config is sharedcd ../repo && git commit --allow-empty -m "an ordinary commit"# -> pwned --separate-git-dir fails the same way. It relocates .git, and every worktree still shares whatever it was relocated to. All three constrain a writer that is trying to behave. None of them reduce what a worktree is able to write. Then use a clone. It costs the same. The 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. Measured against a full clone of git/git: 81,772 commits, 4,828 tracked files, a 318 MB .git, a 58 MB working tree. Each row creates one new checkout from that local source. A note on method, because it changes the answer. Disk is measured as a cumulative delta over the whole tree. du counts an inode once per invocation, so measuring a hardlinked clone on its own would credit it with bytes it never actually added.

Creating one checkout Wall time Disk added .git apparent size Packfiles copied

git clone --no-hardlinks 1791 ms 373 MB 315 MB 1

git clone (local) 982 ms 58.9 MB 318 MB 1 (hardlinked)

git clone --shared 870 ms 58.9 MB 660 KB 0

git worktree add 826 ms 58.7 MB 4 KB 0