iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

そのテスト、別のコードで通ってます — git worktree × Docker 並列開発の静かな罠

はじめに

株式会社iimonでエンジニアをしている「みよちゃん」です。

8月に入り暑さが本格化してきましたね。最近自分は、通勤すれば汗だく・リモートワークをすればエアコンで電気代が高くつくという、ジレンマに陥っています。少しでも暑さを和らげるために、今日は怖い話をしようと思います。

これは、実際に自分が遭遇した出来事です。

その日も、いつものように AI エージェントにタスクを任せてテストを流していました。

docker compose exec backend pytest

— 結果は、全てグリーン。安心して次へ進もうとしましたが、理由のない違和感が残ります。違和感をもとに実行されたテストを確認してみると、あることに気づいてしまいました。テストされていたのは、修正が 1 行も入っていない、別のディレクトリのコードだったことに。そして隣では、AI エージェントがその「全部グリーン」を信じて、先へ、先へと進んでいました——。

何が起きていたのか。順を追ってお話しします。

背景: 実装が速くなると、ボトルネックが移動する

AI コーディングエージェント(Claude Code 等)を開発フローに組み込むと、実装そのもののスピードは大きく上がります。実装速度が上がることによるボトルネックの移動は様々語られていますよね。

  • エージェントが実装している間、人間はレビュー待ち・CI 待ちで手が空く
  • 別のタスクに着手したいが、作業ディレクトリが 1 つしかない(ブランチを切り替えるとエージェントの作業が止まる)

といった「作業場所の枯渇」に関するボトルネックが今回の事件の背景にありました。

そんな「作業場所の枯渇」を解決する手段の一つがgit worktree です。1 つのリポジトリから複数の作業ディレクトリを生やし、タスクごとにエージェントを並走させることができます。worktree でエージェントを並走させる話は増えてきましたが、そこに Docker Compose が絡んだ瞬間の罠はあまり語られていません。本記事は、冒頭のような「静かに間違う」事故を実際に踏んだ記録と対策です。

想定読者: Docker Compose で開発していて、AI エージェントの並走(あるいは単に複数ブランチの並行作業)を考えている人。worktree 自体の入門は次の 1 節だけです。

git worktree のおさらい

git worktree add ../myapp-worktrees/feat-xxx -b feat/xxx

これで ../myapp-worktrees/feat-xxx に独立した作業ディレクトリができます。通常のリポジトリでは .git はディレクトリですが、worktree に置かれる .git中身が 1 行だけのテキストファイルで、本体リポジトリの .git/worktrees/<名前> に作られる worktree 専用領域(HEAD や index はここに独立して持つ)へのパスが書かれています。オブジェクトやブランチはリポジトリ全体で共有されるので、clone より軽量で fetch も一度で済みます。なお、同じブランチを 2 つの worktree で同時にチェックアウトすることはできません(タスク = ブランチ = worktree を 1:1:1 にする運用が自然です)。

知っている方はここだけ: 「gitdir の実体が本体側にある」「worktree のパスは本体と別」— この 2 点が罠 1・罠 2 の根っこです。

罠 1: 「テストが通った」が嘘になる

一番危険だったのがこれです。

Docker Compose で開発しているプロジェクトなら、backend コンテナの定義はだいたい次のような形になっているのではないでしょうか。

services:
  backend:
    volumes:
      - ./backend:/app   # ホストのコードを bind mount

この相対パスは compose ファイルのあるプロジェクトディレクトリ基準で、up した時点で解決されて固定されます。つまり本体リポジトリで up したスタックは、どこから操作しようと永遠に本体側の backend/ をマウントしています。

worktree を使い始めた直後は、ポートが衝突して worktree 用のスタックを別に立てられないため、本体のスタックを共有する運用になりがちです。この状態で docker compose exec backend pytest を叩くと——テストは正常に走り、正常に結果が返ってきます。ただしテストされているのは、worktree の変更が一切入っていない本体側のコードです。

補足すると、worktree のディレクトリから素朴に exec した場合は、Compose がプロジェクト名(デフォルトはフォルダ名)でコンテナを探すため「service "backend" is not running」といった明示エラーになります。エラーになる分にはまだ安全です。本当に危険なのは、エラーにならず静かに本体へ繋がるパターンで、

  • エージェント(や人間)のカレントディレクトリが本体側のまま exec を叩く — 冒頭の事故はこのパターンです。CLAUDE.md 等の指示書に docker compose exec backend pytest と書いてあれば、エージェントは今いる場所でそれを実行します
  • 本体の .env を丸ごとコピーして COMPOSE_PROJECT_NAME まで継承してしまった(worktree から本体スタックが「見えて」しまう)

の 2 つの経路で成立します。そしてポート衝突を放置したスタック共有運用では、前者が「事故」ではなく「日常」になります——毎回のテストが本体コードを対象に走るのですから。「修正したのにテストが落ち続ける」「何も書いてないのに通る」——人間が手を動かしていれば違和感で気づくズレも、エージェントは疑わずに突き進みます。AI 並列開発では「環境が正しく分離されていること」の重要度が一段上がる、というのが最初の学びでした。

対策: 1 worktree = 1 スタック

worktree ごとに独立した Compose スタックを起動する方針にしました。素朴にやるとポートとプロジェクト名が衝突するので、2 段構えです。

(1) ホストポートを環境変数化する

services:
  db:
    ports:
      - "127.0.0.1:${MYAPP_DB_PORT:-3306}:3306"
  backend:
    ports:
      - "127.0.0.1:${MYAPP_BACKEND_PORT:-8000}:8000"

デフォルト値付きなので、本体リポジトリでは従来どおり動きます。127.0.0.1 の明示バインドは重要です。開発用 DB は root/root のような弱い認証で動いていることが多く、スタック分離はホストの listen ポートを何倍にも増やすので、同一 Wi-Fi への露出を構造的に防いでおきます。

(2) worktree ごとに .env でポートとプロジェクト名を上書きする

セットアップスクリプトで、ディレクトリパスのハッシュから決定論的にポート帯を割り当てます。

if ! grep -q '^MYAPP_DB_PORT=' .env 2>/dev/null; then  # 追記済みなら何もしない(冪等)
  # パスの cksum から 10001〜14992 のポート帯を決定論的に割当
  offset=$(( ( $(pwd | cksum | awk '{print $1}') % 500 ) * 10 + 10000 ))
  # Compose プロジェクト名は小文字英数字とハイフン等のみ許容なので正規化する
  name=$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g')
  cat >> .env <<EOF
COMPOSE_PROJECT_NAME=myapp-${name}-${offset}
MYAPP_DB_PORT=$((offset + 1))
MYAPP_BACKEND_PORT=$((offset + 2))
EOF
fi

決定論的にしたのは、いつ何度実行しても同じ worktree には同じポートが割り当たり、冪等に作れるからです。

COMPOSE_PROJECT_NAME の分離が特に重要で、これによりコンテナ・ネットワーク・DB ボリュームが worktree 単位で完全に独立します。テスト用 DB も衝突しません。

対策の対策: プロジェクト名の衝突は「静かな事故」になる

運用してみて追加で 2 つ直しました。どちらも「静かに壊れる」系です。

  • プロジェクト名にパスハッシュのサフィックスを付ける — フォルダ名だけでプロジェクト名を作ると、親ディレクトリ違いの同名フォルダで衝突します。衝突すると既存スタックを静かに乗っ取り、worktree 削除時の docker compose down -v他人(他タスク)のボリュームを消します。
  • down -v にガードを付ける — worktree 削除フックで無条件に down -v すると、スタック未作成の worktree を消したときにデフォルトプロジェクト名(= フォルダ名)で down -v が走り、偶然同名の別スタックを破壊し得ます。「スクリプトがスタックを作った証跡(.envCOMPOSE_PROJECT_NAME)がある場合のみ実行」にしました。

down -v は不可逆なので、「実行しない条件」を構造的に作るのが大事でした。

罠 2: pre-push フックが worktree 上でだけ壊れる

このリポジトリでは lefthook の pre-push フックで CI と同等のチェック(コード生成物の差分検査など)を走らせています。これが worktree 上でだけ、常に失敗するようになりました。

原因は git の仕様でした。通常のクローンでは、pre-push フックに GIT_DIR は基本的に渡されません。しかしリンクされた worktree では、git がフックに環境変数 GIT_DIR(worktree 専用 gitdir .git/worktrees/<名前> への絶対パス)をセットして起動します。そして git には「GIT_DIR がセットされていて GIT_WORK_TREE が無い場合、カレントディレクトリを作業ツリーのトップとみなす」という文書化された仕様があります。

この 2 つが組み合わさると、フック内で cd web のようにサブディレクトリへ移動して git status を叩いた瞬間、git は web/ を作業ツリーのルートだと誤認します。結果、追跡ファイル全体が「削除された」扱いになり、生成コードのディレクトリ全体が「未追跡」として誤検出され、push が永遠に通らない。通常クローンで問題が出ないのは「セットされないから」——つまりこれは worktree 上でだけ発症する罠です。

対策はシンプルで、フックの先頭で GIT_DIR を unset するだけです。

# lefthook.yml
pre-push:
  commands:
    check:
      run: unset GIT_DIR && cd web && git status --porcelain src/api/generated/

unset すれば git は通常どおりカレントディレクトリから .git を探索するので、通常クローンでも worktree でも正しく動きます。「worktree でフックだけ挙動が変わったら GIT_DIR を疑う」は覚えておいて損がないです(lefthook 固有ではなく husky 等でも同じです。なお pre-commit 系フックでは GIT_INDEX_FILE もセットされますが、こちらは通常クローンでも渡され、コミット中のステージ内容を指す変数なのでうかつに unset しないこと)。

罠 3: bind mount が静かに切れて「過去のコード」がテストされる

これは worktree 固有ではないのですが、並列開発でスタック数が増えると遭遇率が上がった問題です。

Docker Desktop(macOS / Windows)の bind mount は、VM へのファイル共有層(macOS では VirtioFS / gRPC-FUSE)を経由しています。Docker Desktop の再起動などを挟むと、この共有が壊れることがあります。壊れ方は一通りではなく、ホストの変更がコンテナに伝播しなくなったり、mount が実質外れてイメージビルド時に焼き込まれたコード(Dockerfile で COPY . . している場合。ビルド時点の未コミット変更を含む)が見えたりします。Linux ネイティブのカーネル bind mount では基本的に起きない、Desktop 系特有の問題です。

症状が怪奇現象じみています。

  • 書いた覚えのないモデルが「未生成マイグレーション」として検出される
  • 直したはずのテストが落ち続ける
  • 別タスクの未コミットコードがなぜか存在する

実際、1 週間前に作られたコンテナが、当時の別タスクの未コミットコードを保持したままテストに使われていたことがありました。

診断は 10 秒でできます。ホストでファイルを touch して、コンテナから見えるか確認するだけ。どの壊れ方であっても、この方法で検出できます。

touch backend/.mount-check && docker compose exec backend ls /app/.mount-check
# 見えなければ mount 不通。復旧(コンテナ再作成で mount を張り直す):
docker compose up -d --force-recreate backend
rm backend/.mount-check  # 未追跡ファイル検査のノイズにならないよう消しておく

これも罠 1 と同型で、「テスト結果は正常に返ってくるが、テスト対象が違う」パターンです。不可解なテスト失敗を見たら、コードを疑う前に「今どのコードがテストされているのか」を疑う——この診断手順を AI エージェントへの指示書(CLAUDE.md 等)に書いておくと、エージェント自身が mount 切れを検出して復旧できるようになります

まだある: 30 秒で読める 4 つの罠

  • CORS がポート個別許可だと並列時に落ちる — Vite / Expo の dev server はポート使用中だと自動で繰り上がる(5173 → 5174…)。worktree 並列だと必ず繰り上がるので、CORS 許可を個別列挙からポートレンジの正規表現(例: ^http://localhost:517[3-9]$ で 5173〜5179)に変更。regex は必ず ^$ でアンカーすること(localhost:5173.evil.com を通さない。Python の re では $ より \Z がさらに厳密)。これは開発設定限定で、本番は厳格な列挙のまま。
  • フロントの接続先ポート書き換え — worktree のフロントが本体スタックの API を向いたままだと「変更が反映されない」。セットアップスクリプトで .env の API URL のポート部分だけ書き換える(ホスト部は Android エミュレータ用の 10.0.2.2 等、個人設定があるので触らない)。
  • .env の継承 — worktree の .env が空だと、API キー必須の機能が fail-secure で全拒否になり原因が分かりにくい。本体の .env を引き継ぎつつ、worktree 固有キー(プロジェクト名・ポート)は除外して継承する(罠 1 の「静かに本体へ繋がる」事故の予防でもある)。なお .env 継承は秘匿値の複製なので、信頼できないブランチの worktree では行わないこと(後述)。
  • メモリとディスクは有限 — DB コンテナはスタックごとに立つので 1 スタック 500MB 程度食う。同時起動は 2〜3 個まで、使わないスタックは docker compose stop。ビルドイメージとボリュームも worktree ごとに増殖するので、片付けは docker compose down -v --rmi local、棚卸しは docker system df で。

セットアップの自動化 — ただしフックは任意コード実行の入口になる

ここまでの対策(.env 生成 → スタック起動 → マイグレーション適用)はすべて 1 本のセットアップスクリプトに集約し、worktree 管理ツール(gtr = git-worktree-runner)のフックから worktree 作成時に自動実行しています。worktree を作れば数分後にはテスト可能な独立環境が立ち上がり、削除すればスタックごと片付く状態です。

一点だけ注意が必要です。フック定義自体は本体リポジトリ側にあっても、フックが実行する処理——npm install(ライフサイクルスクリプト)、docker compose up --build(ブランチの Dockerfile)、マイグレーション適用(ブランチのコード)——はチェックアウトしたブランチ由来のコンテンツを実行します。外部コントリビュータの PR など信頼できないブランチで worktree を作ると、そのまま任意コード実行の入口になります。フック承認機能(trust)はフック定義の改変対策にはなりますが、悪性ブランチ対策にはなりません。信頼できないブランチはフック無効(--no-hooks や素の git worktree add)で作り、install / build / migrate を安易に走らせない、というルールをセットで敷いています。

まとめ

  • worktree × Docker の罠の多くはエラーにならず、静かに間違う(別のコードがテストされる、別のボリュームが消える)。人間なら違和感で気づくズレを、エージェントは信じて突き進む
  • だからこそ AI 並列開発では、環境分離の設計・診断手順の整備・不可逆操作のガードが人間の仕事として一段重要になる
  • 具体解は「1 worktree = 1 スタック」: ポートの環境変数化 + パスハッシュによる決定論的割当 + COMPOSE_PROJECT_NAME 分離。フックの罠には unset GIT_DIR、mount 切れには touch 診断

AI 時代の環境構築では、「静かに間違わないこと」により一層注意が必要だと改めて思いました。

おわりに

怖い話、いかがでしたか。少しは涼しくなったでしょうか。

お化けと違って、この話の怖いところはあなたの手元でも普通に再現することです。もし今 worktree で並列開発をしているなら、ふとした時に pwd を叩いてみてください。あなたの隣の AI エージェントは、本当に「そこ」のコードをテストしているでしょうか——。

とはいえ、正体さえ分かってしまえば幽霊の正体は枯れ尾花。1 worktree = 1 スタックの分離と 10 秒の touch 診断さえ仕込んでおけば、静かな事故はちゃんと「うるさい事故」に変えられます。この記事が、皆さんの夏の並列開発のお守りになれば幸いです。

最後になりますが、現在弊社ではエンジニアを募集しています! 少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!

iimon採用サイト / Wantedly / Green

最後までお読みいただき、ありがとうございました。