git-worktreeでVite + Dockerのローカル環境を並列起動する
普段、Vite + DockerでWebアプリケーションを開発しています(AIが)。
git-worktreeを使ってAIエージェントに並列作業をさせている中で、DBアクセスの自動テストやブラウザでの動作確認までやらせたいと思い立ちました。
そこで、各worktreeに開発環境を割り当てるスクリプトを作ってみました(AIが)。
前提
以下がローカル開発環境のざっくり構成です。
FE
- Vite(React)
BE(Docker Compose)
- APIサーバー(Go)
- DBサーバー(MySQL)
- テスト用APIサーバー
- テスト用DBサーバー

目指す状態
複数のworktreeで、AIエージェントが独立して自動テストやブラウザでの動作確認まで完了させることを目指します。
留意点
ポート衝突回避
開発環境の並列起動を考えたときに、真っ先に思いつくめんどくさいポイントがポート衝突です。
メインのworktree(以下primary worktree)ではViteもDocker Composeも固定ポートのため、追加するworktree(以下linked worktree)ではポートをずらして衝突を回避しなければなりません。
また、linked worktreeで起動する開発環境の数は固定ではなく増やせるようにしたいので、ポートは自動で割り当てる必要があります。
依存関係のインストール・環境変数の設定
worktree毎に npm i 等依存パッケージのインストールが必要です。また、環境変数(.env)はprimary worktreeのものをコピーした上で、フロントエンドのURL等一部を割り当てられたポートに書き換える必要があります。
識別・クリーンアップ
環境を複数起動して作業していると、確実にどれがどれかわからなくなります。
また、作業後に不要になったリソースやポートが適切に解放されなければ、ローカル環境がめちゃめちゃになります。
そのため、作業中のworktreeにおける開発環境の状態を確認したり、手軽にクリーンアップできる仕組みが必要です。
スクリプト
開発環境を起動するスクリプトを作成しました。
ざっくり、以下の流れで起動します。
- FE・BE用に空いているポートを探して割り当てる
- envをコピーし、必要なキーだけをworktreeの状態に合わせて上書きする
- docker composeのproject名をworktreeごとに分離する
- フロントの依存関係が無ければ
npm installする
エントリポイント
3つのエントリポイント(とヘルプ)を用意しています。
- start:環境を起動する
- status:どの環境が動いているか分かる
- stop:対象の環境を止めてクリーンアップする
main() {
local cmd=${1:-}
[[ -n "$cmd" ]] || { usage; exit 1; }
shift || true
case "$cmd" in
start)
ensure_envs
resolve_fe_port
stop_fe
stop_be
start_be
start_fe
;;
stop)
stop_fe
stop_be
;;
status)
status_fe
status_be
;;
-h|--help)
usage
;;
*)
usage
exit 1
;;
esac
}
処理の内容
コア部分だけ抜粋して紹介します。
まず、worktreeごとに一意のIDを生成し、状態(ログ・PID・割り当てポート・override等)を置く場所を決めます。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
cd "$ROOT"
FE_DIR="$ROOT/frontend"
BE_DIR="$ROOT/backend"
WORKTREE_NAME="$(basename "$ROOT")"
WORKTREE_SLUG="$(echo "$WORKTREE_NAME" | tr -cs '[:alnum:]' '-')"
WORKTREE_HASH=$(ROOT="$ROOT" python3 - <<'PY'
import hashlib, os
root = os.environ["ROOT"]
print(hashlib.sha1(root.encode()).hexdigest()[:8])
PY
)
WORKTREE_ID="${WORKTREE_SLUG}-${WORKTREE_HASH}"
STATE_DIR="/tmp/sample-${WORKTREE_ID}"
LOG_FE="${STATE_DIR}/fe.log"
PID_FE="${STATE_DIR}/fe.pid"
FE_PORT_FILE="${STATE_DIR}/fe.port"
OVERRIDE_FILE="${STATE_DIR}/compose.override.yml"
primary worktreeからenvをコピーし、必要なキーだけ書き換えます。
resolve_main_worktree_root() {
local line wt_path
while IFS= read -r line; do
if [[ "$line" == worktree\ * ]]; then
wt_path=${line#worktree }
if [[ -d "$wt_path/.git" ]]; then
echo "$wt_path"
return
fi
fi
done < <(git -C "$ROOT" worktree list --porcelain 2>/dev/null || true)
echo "$ROOT"
}
ensure_envs() {
if [[ ! -f "$BE_DIR/.env.dev" ]]; then
local main_root main_env
main_root=$(resolve_main_worktree_root)
main_env="${main_root}/backend/.env.dev"
if [[ -f "$main_env" && "$main_env" != "$BE_DIR/.env.dev" ]]; then
cp "$main_env" "$BE_DIR/.env.dev"
else
cp "$BE_DIR/.env.example" "$BE_DIR/.env.dev"
fi
fi
[[ -f "$BE_DIR/.env.test" ]] || cp "$BE_DIR/.env.example" "$BE_DIR/.env.test"
[[ -f "$FE_DIR/.env" ]] || cp "$FE_DIR/.env.example" "$FE_DIR/.env"
}
set_env_var() {
local file=$1 key=$2 value=$3
python3 - "$file" "$key" "$value" <<'PY'
import sys, pathlib
path, key, value = sys.argv[1:]
p = pathlib.Path(path)
lines = p.read_text().splitlines() if p.exists() else []
updated = False
for i, line in enumerate(lines):
if line.startswith(f"{key}="):
lines[i] = f"{key}={value}"
updated = True
break
if not updated:
lines.append(f"{key}={value}")
if lines and lines[-1] != "":
lines.append("")
p.write_text("\n".join(lines))
PY
}
update_env_urls() {
local api_port=$1 fe_port=$2
set_env_var "$FE_DIR/.env" "VITE_API_URL" "http://localhost:${api_port}/api"
set_env_var "$BE_DIR/.env.dev" "FRONTEND_URL" "http://localhost:${fe_port}"
}
ポートは、基準ポートから空いているところを見つけるまで+1して探索します。
MAX_PORT_SEARCH=50
port_in_use() {
local port=$1
if command -v lsof >/dev/null 2>&1; then
lsof -iTCP -sTCP:LISTEN -n -P | awk '{print $9}' | grep -Eq "[:.]${port}$"
else
netstat -an 2>/dev/null | grep -Eq "[.:]${port}[[:space:]]" || false
fi
}
find_free_port() {
local port=$1
for _ in $(seq 1 "$MAX_PORT_SEARCH"); do
if ! port_in_use "$port"; then
echo "$port"; return
fi
port=$((port + 1))
done
echo "[ERROR] 空きポートを見つけられませんでした(探索上限=${MAX_PORT_SEARCH})" >&2
exit 1
}
docker composeのプロジェクト名をworktreeごとに分け、overrideを生成してポートを差し替えます。
compose_project() {
echo "sample-${WORKTREE_ID}"
}
generate_override_multi() {
local api_port=$1
local test_api_port=$2
local shared_network=$3
local api_db_host=$4
local api_db_port=$5
local test_db_host=$6
local test_db_port=$7
cat >"$OVERRIDE_FILE" <<EOF
services:
api-multi:
ports:
- "${api_port}:80"
environment:
DB_HOST: "${api_db_host}"
DB_PORT: "${api_db_port}"
test-api-multi:
ports:
- "${test_api_port}:80"
environment:
DB_HOST: "${test_db_host}"
DB_PORT: "${test_db_port}"
networks:
shared-mysql:
external: true
name: "${shared_network}"
EOF
}
このときlinked worktree用に新規で立ち上げるのは、
- APIサーバー
- テスト用APIサーバー
- テスト用DBサーバー
の3つで、テスト用でないDBサーバーは新規で起動せず、primary worktree側のものに接続します。
こうすることで、linked worktree環境で新たにデータ投入が不要になり、また同じデータを使った動作の比較もできるためです。
オブジェクトストレージ(例: RustFS)等を使うプロジェクトであれば、それらもprimary側で立ち上げたものを使い、できる限り不要なサービスは立ち上げないようにします。
最後に、node_modules が無い場合は npm install を挟みます。
ensure_fe_deps() {
if [[ ! -d "$FE_DIR/node_modules" ]]; then
echo "[INFO] frontend: node_modules が無いため npm i を実行します"
(cd "$FE_DIR" && npm install)
fi
}
変わったこと
API統合テストを実行できるようになった
これまでは、開発環境が起動していないworktree上ではAPI統合テストを実行できませんでした。
そのため、AIエージェントに並列で実装させる場合は
- テストせずにPR作成 → CIでテストが失敗したら修正指示
という手順を踏んでいましたが、テストサーバーを割り当てて使わせることで、全てのエージェントがテストまで通した成果物を出してくれるようになりました。
E2E確認できるようになった
Chrome DevTools MCPを使って、開発環境の画面にアクセスしてE2E確認と修正まで勝手に行ってくれます。
primary worktreeの開発環境はできるだけ作業に使わず、デフォルトブランチで調査や比較するために置いておきたいので、linked worktreeの環境でChrome DevTools MCPが動くようになってかなり快適になりました。
人間による動作確認が楽になった
地味に嬉しいポイントです。
これまでlinked worktree(あるいはCodex Web等)で実装させた成果物をprimary worktreeにチェックアウトして動作確認していました。
それぞれ開発環境を持つようになったことで、エージェントの作業後に立ち上がったままの開発環境でそのまま即確認できるようになりました。
課題
Chrome DevTools MCPに関しては、今の私の運用では複数worktreeから同時に使うのは難しく、現状ここだけは直列です。
ChromeのプロファイルやMCPサーバーを分ければ並列で動かせるっぽいですが...正直あまり詳しくないのでそこまでやれていないです。
ただそもそも私の脳みそ的に、同時に面倒を見れるタスクはせいぜい2〜3個なので、E2E確認を必要とするようなタスクは並列で走らせないという運用でカバーしています。
終わりに
プログラマー三大美徳の一つに「怠惰」という言葉がありますが、これまで私は怠惰であることそのものに怠慢になるという、ある種の矛盾に甘んじてきました。
しかし、AIが自動化や効率化のためのスクリプトを瞬く間に書き上げるようになった昨今、怠惰であることのハードルが劇的に下がったことを日々実感します。
これは裏を返せば、怠惰であることをサボれる大きな理由が失われたということであり、プログラマーがよりプログラマーであることが求められる時代になったのかもしれません。
このAI時代に置いていかれることのないよう、より上流のレベルでAIを活かすために何をすべきかを考え、行動し続けられる人間でありたいところです。
Discussion