プログラミング
Git worktreesはコーディングエージェントの隔離境界ではない
Git worktrees are not an isolation boundary for coding agents (fletch.sh)
要約
この記事は、Gitのworktreeがコーディングエージェントの隔離境界として機能しない理由を解説しています。worktreeは単一の.gitディレクトリに紐づくため、エージェントがフックをインストールしたり、コミットのメールアドレスを変更したり、他のworktreeのstashを操作したりすることが可能です。worktreeは作業ディレクトリ、HEAD、インデックスのみを隔離しますが、それ以外のリポジトリの状態(オブジェクトストア、参照、フック、設定など)は共有されるため、エージェントが意図しない操作を行うリスクがあります。エージェントの実行には、worktreeではなくクローンを使用することが推奨されています。
全文翻訳
コーディングエージェントに独自のGit worktreeを与えると、ほとんどの並列実行ツールがそうするように、そのエージェントは次回あなたが実際のレポジトリでコミットする際にあなたのマシンで実行されるフックをインストールできます。それはあなたのコミットのメールアドレスを書き換えることができます。それは別のアージェントのstashを自身のツリーにポップすることができます。これらのどれもバグやエキゾチックなコマンドを必要としません。worktreeは境界になったことはありません。それは1つの.gitに接続された2番目の作業ディレクトリであり、興味深いものはすべてその.gitにあります。worktreeは、それらが明白な答えであるため、ここでデフォルトになりました。1つのコマンド、重複する履歴なし、約1秒で2番目のチェックアウト。ピッチはほぼゼロコストでの隔離であり、その両方が間違っていることが判明します。隔離は「隔離されたworktree」というフレーズが示唆するよりもはるかに薄く、それを適切に行うコストは同じです。測定するまで、以前のバージョンのこの記事でコストの部分を間違えていました。
リンクされたworktreeが実際に共有するものは何ですか
リンクされたworktree内では、.gitはディレクトリではありません。それはファイルです。
ターミナルウィンドウ
$ cat ../my-worktree/.git
gitdir: /path/to/repo/.git/worktrees/my-worktree
すべてはそのパスから導かれます。Gitはレポジトリの状態をworktreeごとの状態と共通の状態に分割し、どちらがどちらであるかを教えてくれます。
ターミナルウィンドウ
$ 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
worktreeごとの状態は短いリストです。HEAD、インデックス、ORIG_HEAD、およびいくつかのbisectとrebaseファイル。他のすべては--git-common-dirを通じて元のレポジトリに解決されます。
オブジェクトストア (.git/objects)、これがポイントであり、共有しても安全です。
参照とブランチ (refs/heads, packed-refs)。すべてのworktreeに1つの名前空間。
設定 (.git/config)。
stash (refs/stash)。
フック (.git/hooks)、ここで共有ディレクトリが任意のコード実行になります。
したがって、隔離は現実であり、狭いです。あなたの作業ディレクトリ、HEAD、インデックスです。それらは有用です。それらは境界ではありません。境界とは、worktreeに閉じ込められたプロセスがその外部の状態に到達できないことを意味し、2番目のリストはそれができる方法のカタログです。「隔離されたworktree」は、ツール説明やアドバイススレッドで、どちらのリストが意図されているかを言わずに、常に使用されています。
それがエージェントに何ができるか
最悪から始めます。各ブロックは独自のレポジトリを作成するため、それらを空のディレクトリに貼り付けて任意の順序で実行できます。以下の各出力行は本物であり、git 2.50.1でキャプチャされました。
あなたのホストでコードを実行する
.git/hooksは共通ディレクトリにあります。worktree内からインストールされたフックは、したがって親レポジトリにインストールされ、トリガーコマンドを実行するたびに、親としてあなたが実行します。
ターミナルウィンドウ
git init -q --initial-branch=main hook-demo
cd hook-demo
git commit -q --allow-empty -m init
git branch agent-work
git worktree add -q ../hook-agent agent-work
# 「隔離された」worktreeが親の.gitにフックを書き込む
cd ../hook-agent
cat > "$(git rev-parse --git-common-dir)/hooks/pre-commit" <<'EOF'
#!/bin/shecho "*** hook running as $(whoami) in $(pwd) ***"EOF
chmod +x "$(git rev-parse --git-common-dir)/hooks/pre-commit"
# 今、あなた自身のレポジトリで、通常のコミットを行います
cd ../hook-demo
git commit -q --allow-empty -m "an ordinary commit"
# -> *** hook running as you in /path/to/hook-demo ***
これは、worktreeがコンテナ化されたエージェントの隔離レイヤーになれない理由でもあります。コンテナにリンクされたworktreeを渡すということは、親の実際の.gitをマウントすることを意味します。なぜなら、worktree自身の状態はそこにあり、ホスト側の書き込み可能な.git/hooksはそのホストで実行され、コンテナは意味をなさなくなります。
あなたのコミットの送信者を書き換える
設定は共有されているため、worktree内のgit configは親の.git/configに書き込みます。
ターミナルウィンドウ
git init -q --initial-branch=main id-demo
cd id-demo
git commit -q --allow-empty -m init
git branch agent-work
git worktree add -q ../id-agent agent-work
cd ../id-agent
git config user.email "agent@example.com"
cd ../id-demo
git config user.email
# -> agent@example.com
git config --show-origin user.email
# -> file:.git/config agent@example.com
git commit -q --allow-empty -m "a commit you made yourself"
git log -1 --format='%an <%ae>'
# -> Your Name <agent@example.com>
最後の2行をよく見てください。それはエージェントのコミットではありません。それはあなたのレポジトリでのあなたのコミットであり、他の誰かが選んだ作成者行を持っています。
別worktreeのstashを取る
レポジトリごとに1つのrefs/stashがあり、調整されていないライター間で共有される単一のスタックのように動作します。
ターミナルウィンドウ
git init -q --initial-branch=main stash-demo
cd stash-demo
echo original > file.txt && git add file.txt && git commit -q -m "add file"
git branch branch-a
git branch branch-b
git worktree add -q ../stash-a branch-a
git worktree add -q ../stash-b branch-b
cd ../stash-a
echo "AGENT A PRECIOUS WORK" > file.txt
git stash -q
# Aは作業を保存します
cd ../stash-b
git stash pop
# Bはそれを尋ねることなくポップします
cat file.txt
# -> AGENT A PRECIOUS WORK
cd ../stash-a
git stash list
# -> empty
cat file.txt
# -> original. Aの作業は現在Bのツリーにあります。
1つの詳細は重要であり、一部のバージョンのこのリポジトリが機能しない理由です。file.txtはブランチが作成される前にコミットされているため、両方のworktreeで追跡されます。通常のgit stashは追跡されていないファイルを無視するため、新しいファイルでは「No local changes to save」と表示され、何もstashせず、popは「No stash entries found」で失敗します。追跡されたファイルを使用するか、git stash -uを使用してください。
兄弟の下の参照を書き換える
すべてのworktreeは1つのrefs/headsと1つのオブジェクトストアに書き込みます。git gcを実行するエージェントは、親を含むリポジトリ全体に対してそれを実行します。ブランチを強制的に更新したり、リベースしたり、git reset --hardに手を伸ばしたりするエージェントは、他のworktreeが解決する参照を操作しており、1つのエージェントが孤立させたコミットは、他のエージェントの足元で参照されずに残る可能性があります。これらはエキゾチックなコマンドではありません。混乱して整理しようとするときに何か手が伸びるものです。
ブランチ名で衝突する
共有参照は、gitが2つのworktreeで同じブランチを拒否することも意味します。
ターミナルウィンドウ
$ git worktree add ../probe main
Preparing worktree (checking out 'main')
fatal: 'main' is already used by worktree at '/path/to/repo'
残りのものに比べて無害ですが、人々が最初にぶつかる壁であり、worktreeごとに使い捨てブランチを生成してgitを満たすように促すものです。
修正されない修正
この問題が議論されるたびに、2つの緩和策が出てきます。どちらも効果がありません。
worktreeごとの設定。
extensions.worktreeConfigはデフォルトでオフになっており、それをオンにしても、書き込み側がgit config --worktreeをオプトインした場合にのみ役立ちます。通常のgit config(注意深く行っていないほとんどのものが実行するもの)は、共有ファイルに書き込み続けます。
ターミナルウィンドウ
git config extensions.worktreeConfig true
# 親でオプトインします
cd ../my-worktree
git config --worktree user.email scoped@example.com
git -C ../repo config user.email
# -> you@example.com, unaffected
git config user.email leaked@example.com
git -C ../repo config user.email
# -> leaked@example.com
フックを.gitの外に移動する。これは循環的です。core.hooksPathは設定であり、設定は共有されているため、worktreeはそれを好きな場所にポイントします。
ターミナルウィンドウ
cd repo && git config core.hooksPath ../safe-hooks
# あなたの緩和策
cd ../my-worktree
mkdir -p evil-hooks && printf '#!/bin/sh\necho pwned\n' > evil-hooks/pre-commit
chmod +x evil-hooks/pre-commit
git config core.hooksPath "$(pwd)/evil-hooks"
# 設定は共有されています
cd ../repo && git commit --allow-empty -m "an ordinary commit"
# -> pwned
--separate-git-dirも同様に失敗します。それは.gitを再配置し、すべてのworktreeはそれが再配置されたものを共有します。これら3つすべてが、振る舞おうとしている書き込み側を制約します。それらのどれも、worktreeが書き込めるものを減らしません。
それからクローンを使用してください。コストは同じです。標準的な反対意見は、ワーカーごとのクローンはhiをコピーすることを意味します