きっかけ
普段の業務では、複数のブランチを同時に触る場面が多くあります。自分の作業ブランチで開発しながら、チームメンバーのプルリクエストをローカルで動作確認したり、ステージング用のブランチと挙動を比較したりといったことを日常的にやっています。
git worktree はこうした並行作業に便利です。ブランチごとにディレクトリを分けられるので、stash や checkout を繰り返す必要がありません。
git worktree add ../myapp-feature-auth feature/auth
git worktree add ../myapp-fix-header fix/header
ただ、問題はポートです。
モノレポで複数のサービスを動かしている場合、それぞれのサービスに決まったポートが割り当てられていることが多いと思います。ブランチが1つなら問題ありません。しかし、3つの worktree で同時にサービスを起動しようとすると、同じポートを取り合ってしまいます。
手動でポートをずらすこともできますが、どのポートがどのブランチだったか、すぐにわからなくなります。バックエンドも同様で、環境変数の書き換えやサービス間の接続先変更を、ブランチを切り替えるたびにやり直す必要があります。
レビューのために別ブランチの動作確認をしたいだけなのに、毎回ポート周りの設定を手作業でやるのはさすがに面倒でした。これは仕組みで解決すべき問題だと考えました。
同じ悩みを持つ人はやはりいるようで、こんなツイートも見かけました。
you should use worktrees
you just have to..
- npm install in the worktree
- reinstall the pre-commit hooks
- copy the env files
- not use the same ports
or realize this is not the right solution
portree の概要
そこで作ったのが portree です。Git Worktree Server Manager という位置づけで、名前は port + tree に由来しています。Go で実装しました。
# 初期化
portree init
# 全 worktree のサービスを一括起動
portree up --all
# ブラウザで開く
portree open
設計上の目標は3つあります。
1. ポートの衝突を仕組みで防ぐ
ブランチ名とサービス名から FNV32 ハッシュでポートを決定的に割り当てます。
FNV32("main:frontend") % range_size + min_port → 3100
FNV32("feature/auth:frontend") % range_size + min_port → 3117
同じブランチ・同じサービスであれば、何度再起動しても同じポートになります。衝突が起きた場合は、linear probing で次の空きポートを自動的に探します。ポートを覚える必要も、手動でずらす必要もありません。
2. サーバーのライフサイクルを一元管理する
.portree.toml に一度定義すれば、全 worktree で同じ構成が動きます。
[services.frontend]
command = "pnpm run dev"
dir = "frontend"
port_range = { min = 3100, max = 3199 }
proxy_port = 3000
[services.backend]
command = "python manage.py runserver 0.0.0.0:$PORT"
dir = "backend"
port_range = { min = 8100, max = 8199 }
proxy_port = 8000
portree up --all で全 worktree の全サービスが起動し、portree down --all でまとめて停止できます。プロセスグループ単位で管理しているため、子プロセスの取りこぼしもありません。SIGTERM を送り、タイムアウト後に SIGKILL で確実に終了させます。
レビュー対象のブランチを worktree として追加して portree up するだけで、ポート設定を気にせずすぐに動作確認に入れます。
3. ブランチ名でアクセスできるようにする
ポートが自動で割り当てられても、localhost:3117 のような番号を毎回確認するのは手間です。portree proxy start でリバースプロキシを起動すると、Host ヘッダのサブドメインを使ってブランチ名でアクセスできるようになります。
http://main.localhost:3000 → frontend (main, :3100)
http://feature-auth.localhost:3000 → frontend (feature/auth, :3117)
http://main.localhost:8000 → backend (main, :8100)
http://feature-auth.localhost:8000 → backend (feature/auth, :8104)
プロキシポート(上の例では :3000 や :8000)は全ブランチで共通です。サブドメイン部分(main や feature-auth)でどのブランチに振り分けるかを判定し、実際のサービスが listen しているポートに転送します。ブランチ名に / が含まれる場合はスラッグ化されます(feature/auth → feature-auth)。
*.localhost は RFC 6761 によりモダンブラウザが 127.0.0.1 に自動解決するため、/etc/hosts の編集や DNS の設定は不要です。
ブラウザのタブで main.localhost:3000 と feature-auth.localhost:3000 を並べて開けば、本番ブランチと開発ブランチの挙動をそのまま見比べられます。レビュー時に「この PR で UI がどう変わったか」を確認するのに便利です。
補足: ポート番号を名前付き
.localhostURL に置き換えるアプローチは、Vercel Labs の portless と共通しています。portless は汎用的なポート名前解決ツールですが、portree はさらに worktree 単位でのプロセス管理やポートの決定論的割り当てまでカバーしています。詳しくは後述の「portless との比較」を参照してください。
セットアップ
# インストール
brew install fairy-pitta/tap/portree
# プロジェクトで初期化
cd your-project
portree init
portree init を実行すると .portree.toml が生成されます。サービスのコマンドやポート範囲を定義したら、あとは portree up で起動するだけです。
実装上のポイント
ポート割り当ての TOCTOU 問題
「ポートが空いているか確認 → 割り当て → サービスが bind」の間にタイムラグがあり、別のプロセスがその間にポートを使う可能性があります(Time-of-Check-Time-of-Use)。
完全な排除は難しいですが、ファイルレベルの排他ロック(flock)により portree の複数インスタンス間での競合は防いでいます。外部プロセスとの競合が発生した場合は、原因がわかるようなエラーメッセージを返すようにしました。
プロセスグループの扱い
開発サーバーは子プロセスを spawn することが多いです(Next.js の SWC コンパイラなど)。メインプロセスだけを kill しても子プロセスが残ってしまいます。
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
Setpgid: true でプロセスグループを作り、停止時は syscall.Kill(-pgid, syscall.SIGTERM) でグループごと終了させることで、子プロセスの残留を防いでいます。
リバースプロキシの WriteTimeout
Go の http.Server では WriteTimeout を設定するのが一般的ですが、dev server のプロキシでは意図的に 0(無制限)にしています。Vite や webpack の HMR は SSE(Server-Sent Events)で接続を維持するため、固定の write deadline を設定するとストリームが切断されてしまいます。
srv := &http.Server{
ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
// WriteTimeout は意図的に 0: HMR の SSE ストリームを維持するため
}
セキュリティのベストプラクティスに一律に従うのではなく、ユースケースに応じて設定を選ぶ必要があります。ローカル開発ツールならではの判断でした。
TUI ダッシュボード
全 worktree・全サービスの状態を一覧で確認し、vim ライクなキーバインドで操作できる TUI も実装しました。Bubble Tea と Lip Gloss を使用しています。
portree dash で起動し、s で起動、x で停止、o でブラウザを開けます。複数ブランチのサービスをまとめて見渡せるので、レビュー中にどのブランチが起動しているかを把握するのに便利です。
portless との比較
portree をほぼ作り終えてから、Vercel Labs の portless を知りました。
ポート番号を myapp.localhost:1355 のような名前付き URL に置き換えるツールで、ローカル開発の DX を改善するというアプローチは近いものがあります。ただ、解決している問題のスコープが異なります。
| portless | portree | |
|---|---|---|
| 思想 | ポートを名前に置き換える | worktree 単位で開発環境を管理する |
| プロセス管理 | なし(プロキシのみ) | 起動・停止・ライフサイクル全体 |
| ポート割当 | ランダム | FNV32 ハッシュで決定論的 |
| 名前付き URL | あり | あり(branch-name.localhost) |
| worktree 対応 | なし | コア機能 |
| HTTPS | あり(自動証明書) | あり(自動証明書) |
| TUI | なし | あり |
| 言語 | TypeScript | Go(シングルバイナリ) |
portless は「ポートに名前をつける」汎用ツールで、すでに起動しているサーバーに対するプロキシです。サーバーの起動・停止やライフサイクル管理は範囲外で、git worktree 単位での管理という概念もありません。
portree は worktree を追加したら全サービスが正しいポートで起動してブランチ名でアクセスできる、という体験を目指しています。ポートの割り当て、プロセスの起動・停止、サービス間のディスカバリまで一括で管理するツールです。
git worktree で並行開発をしている方には portree が合うと思います。worktree を使っていなくても、モノレポで複数サービスを管理する用途でも使えます。
インストール
# Homebrew
brew install fairy-pitta/tap/portree
# Go
go install github.com/fairy-pitta/portree@latest
GitHub: fairy-pitta/portree
フィードバックや Issue、Star をいただけると励みになります。


