🌳

git worktree × 動的ポートで並列開発環境を作る

に公開

はじめに

本記事では Docker Composeを使った開発環境を前提としています。

開発中、複数のブランチを同時に動かしたくなる場面があります。

  • コードレビュー: レビュー対象のブランチを実際に動かしながら確認したい
  • 比較検証: 修正前後の挙動を並べて比較したい
  • 緊急対応: 機能開発中に本番バグ修正が入り、すぐ切り替えたい

git stashや git checkoutで都度切り替える方法もありますが、環境の再構築が発生したり、コンテキストスイッチのコストが高くなります。

本記事では、git worktreeとDocker Composeの動的ポート割り当てを組み合わせて、ポート競合なく複数ブランチを並列で動かす方法を紹介します。

git worktreeとは

git worktreeは、1つのリポジトリから複数の作業ディレクトリを作成する機能です。

# feature/new-ui ブランチ用の作業ディレクトリを作成
git worktree add ../project-new-ui feature/new-ui

通常1つのリポジトリには1つの作業ディレクトリしかありませんが、worktreeを使うとブランチごとに独立したディレクトリを持つことができます。

~/projects/
├── myapp/              # main ブランチ
├── myapp-feature-a/    # feature/a ブランチ
└── myapp-feature-b/    # feature/b ブランチ

それぞれのディレクトリで独立して開発でき、.git ディレクトリは共有されるためリポジトリ本体のディスク使用量は抑えられます。ただし、node_modulesなどの依存関係はworktreeごとに必要となる場合があります(後述の「node_modulesの扱い」で解説)。

ポート競合問題

worktreeで複数ブランチのディレクトリを作っても、各ディレクトリで開発サーバーを起動すると問題が発生します。

# myapp/ で起動
docker compose up  # → localhost:3000

# myapp-feature-a/ でも起動しようとすると...
docker compose up  # → Error: port 3000 is already in use

同じポートを使おうとして競合が発生します。手動でポート番号を変更すれば回避できますが、毎回設定ファイルを書き換えるのは面倒です。

gwqによるworktree管理

worktreeの操作を効率化するため、gwqというツールを使用します。

# インストール
go install github.com/d-kuro/gwq@latest

gwqは、ghqライクなインターフェースでworktreeを管理できます。

# 新しいworktreeを作成
gwq add -b feature/new-ui

# worktree一覧を表示
gwq list

# 全worktreeのステータス確認
gwq status

fzfと組み合わせると、worktree間の移動が快適になります。

# .zshrcに追加
function gw() {
  local selected=$(gwq list | fzf --preview 'git -C {} log --oneline -10')
  if [[ -n "$selected" ]]; then
    cd "$selected"
  fi
}

これで gw コマンドを実行すると、fzfでworktreeを選択して移動できます。

Docker Composeでの動的ポート割り当て

ポート競合を解決するため、Docker Composeの動的ポート割り当てを活用します。

ホストポートを指定しない

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000"  # ホストポートを指定しない

ホストポートを省略すると、Dockerがランダムな空きポートを割り当ててくれます。

docker compose up -d
docker compose port app 3000
# → 0.0.0.0:55001

環境変数でポートを動的に決定

worktreeのパスからハッシュ値を生成し、ポート番号に変換する方法もあります。

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "${HOST_PORT:-3000}:3000"
# .envrc (direnv 用)
# worktreeのパスからmd5ハッシュを生成し、10000〜19999の範囲でポート番号を決定します
# 同じパスからは常に同じポートが生成されるため、再起動しても変わりません
export HOST_PORT=$(echo "$PWD" | md5sum | tr -d -c '0-9' | head -c 4 | sed 's/^0*//' | awk '{print ($1 % 10000) + 10000}')

このワンライナーは以下の処理を行っています。

  1. $PWD(現在のディレクトリパス)をmd5ハッシュ化
  2. 数字のみを抽出し、先頭4桁を取得
  3. 10000〜19999の範囲に収まるよう計算

これにより worktreeごとに異なるポートが自動的に割り当てられます。

worktree名をベースにしたポート割り当て

また、worktreeの名前からポート番号を決定することもできます。

#!/bin/bash
# scripts/get-port.sh

BASE_PORT=3000
WORKTREE_NAME=$(basename "$PWD")

# ブランチ名からオフセットを計算
case "$WORKTREE_NAME" in
  *main* | *master*) OFFSET=0 ;;
  *feature-a*) OFFSET=1 ;;
  *feature-b*) OFFSET=2 ;;
  *) OFFSET=$(echo "$WORKTREE_NAME" | cksum | cut -d' ' -f1 | awk "{print \$1 % 100}") ;;
esac

echo $((BASE_PORT + OFFSET))
# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "${HOST_PORT}:3000"
    environment:
      - PORT=3000
# 起動スクリプト
HOST_PORT=$(./scripts/get-port.sh) docker compose up

fzf連携で快適に操作

gwqとfzfを組み合わせてworktreeの選択と起動を一括で行う関数を作成してみました。
コマンド一発で、コンテキストごとに独立した作業場所に移動できてとても楽になりました。

# .zshrc

# worktreeを選択して移動 & 開発サーバー起動
function gwd() {
  local selected=$(gwq list | fzf --preview 'git -C {} log --oneline -5')
  if [[ -n "$selected" ]]; then
    cd "$selected"
    if [[ -f "docker-compose.yml" ]]; then
      HOST_PORT=$(./scripts/get-port.sh 2>/dev/null || echo 3000)
      echo "Starting on port $HOST_PORT..."
      HOST_PORT=$HOST_PORT docker compose up -d
      echo "Access: http://localhost:$HOST_PORT"
    fi
  fi
}

# 起動中のworktree環境を一覧表示
function gwps() {
  for dir in $(gwq list); do
    if docker compose -f "$dir/docker-compose.yml" ps --quiet 2>/dev/null | grep -q .; then
      local port=$(docker compose -f "$dir/docker-compose.yml" port app 3000 2>/dev/null | cut -d: -f2)
      echo "$dir → localhost:$port"
    fi
  done
}

実践での学び

node_modulesの扱い

worktreeごとにnode_modulesが生成されるため、ディスク使用量が増加します。

対策として、Named Volumeを使う方法があります。実際に私が関わっているプロジェクトでは以下のように設定しています。

# docker-compose.yml
services:
  frontend:
    volumes:
      - ./frontend:/app
      - node_modules:/app/node_modules  # Named volumeでホストと分離

volumes:
  node_modules:  # Named volumeとして定義

この設定により、node_modulesはコンテナ内の Named Volumeに保存され、ホストのディスクを圧迫しません。worktreeごとに異なるNamed Volumeが使われるため、依存関係の競合も起きません。

起動中の環境を忘れる問題

複数のworktreeで開発サーバーを起動していると、どこで何が動いているか分からなくなることがあります。

gwpsのような一覧表示関数を用意するか、tmuxのステータスラインに表示するなどの工夫が必要になります。

worktreeの掃除

マージ済みブランチのworktreeが残り続けると、管理が煩雑になります。
不要なWorktreeを一掃するコマンドを備えています。

# マージ済みブランチの worktreeを削除
function gwclean() {
  local merged=$(git branch --merged main | grep -v main)
  for branch in $merged; do
    local worktree=$(gwq get "$branch" 2>/dev/null)
    if [[ -n "$worktree" ]]; then
      echo "Removing worktree for $branch..."
      gwq remove "$branch"
    fi
  done
}

Docker リソースの定期クリーンアップ

worktreeを多用すると、Dockerのイメージやボリュームも増えていきます。私は~/.zshrcに以下の設定を追加して、週次で自動クリーンアップしています。

# .zshrc
DOCKER_PRUNE_MARKER="$HOME/.docker_last_prune"
if [[ ! -f "$DOCKER_PRUNE_MARKER" ]] || [[ $(find "$DOCKER_PRUNE_MARKER" -mtime +7 2>/dev/null) ]]; then
  if docker info &>/dev/null; then
    echo "🐳 Docker cleanup (7+ days old resources)..."
    docker system prune -f --filter "until=168h" 2>/dev/null
    touch "$DOCKER_PRUNE_MARKER"
  fi
fi

alias docker-clean='docker system prune -f --filter "until=168h"'

ターミナル起動時に前回のクリーンアップから7日以上経っていれば、7日以上前のリソースを自動削除します。手動でも実行できるようにdocker-cleanエイリアスも用意しています。

まとめ

git worktreeとDocker Composeの動的ポート割り当てを組み合わせることで、ポート競合なく複数ブランチを並列で動かせる環境を構築できます。

  • gwq でworktreeを効率的に管理できます
  • 動的ポート割り当て でポート競合を回避できます
  • fzf 連携 でworktree 間の移動が快適になります

複数のコンテキストを同時に持てることで、レビューや比較検証の効率が上がります。環境構築の初期コストはありますが、日常的に複数ブランチを扱う場面が多い方には有効なアプローチかと思います。

GitHubで編集を提案
TOKIUMプロダクトチーム テックブログ
設定によりコメント欄が無効化されています