電通総研 テックブログ

電通総研が運営する技術ブログ

Claude Codeのworktreeとportlessで並行開発できる環境を作る

はじめに

こんにちは、XI本部リーディングエッジテクノロジーセンターの佐藤太一です。

このエントリでは、複数のワークツリーでClaude Codeを並行稼働させる開発環境の作り方を紹介します。

Claude Codeには -w--worktree)というフラグがあり、
git worktreeを使って1つのリポジトリから複数の作業ディレクトリを切り出せます。
これを使うと、あるブランチでClaude Codeに実装を任せている間に、
別のブランチで別の機能を開発するという並行作業ができるのです。

ところが同じアプリケーションを複数同時に起動するには、それぞれポート番号を分ける必要があります。
各ポート番号ごとに動作確認すべきことが違うのですから、混乱しがちになります。

そこで、この記事ではportlessというツールを使って、複数のワークツリーを単一のポートでアクセスできるようにします。

端的に言うと、 claude -w feature-a というコマンドで起動した開発環境に
https://feature-a.myapp.localhost:1355 でアクセスできる環境を作ります。

完成イメージ

最終的にできあがる環境は以下のようなものになります。

DevContainer
├── portless proxy (:1355)
│   ├── myapp.localhost:1355            → worktree: main
│   ├── feature-a.myapp.localhost:1355  → worktree: feature-a
│   └── feature-b.myapp.localhost:1355  → worktree: feature-b
│
├── /workspaces/myapp/                       (メインリポジトリ)
├── /workspaces/myapp/.worktrees/feature-a/  (worktree)
└── /workspaces/myapp/.worktrees/feature-b/  (worktree)

portlessが1つのポート(1355)で待ち受けていて、Hostヘッダのサブドメインを見て対応するworktreeのdevサーバーに振り分けます。

DevContainerのセットアップ

この記事では再現可能な開発環境としてDevContainerを使います。事前にvscodeをインストールしておいてください。

.devcontainer/devcontainer.json

WORKSPACE_FOLDER という環境変数は、コンテナ内においてコマンドを実行すべきパスを確定するために定義しています。
PORTLESS_HTTPS1 に設定すると、portlessが常にHTTPSモードで動作します。
portlessは自前でCA証明書とサーバー証明書を自動生成するので、mkcertなどの外部ツールは不要です。
PORTLESS_STATE_DIR でportlessの状態ディレクトリを明示的に指定しています。

加えて、いくつかの名前付きボリュームを設定しています。

claude-config-dir は複数回のコンテナ作成をまたがってClaude Codeの設定を永続化するために使っています。

portless はportlessの状態ディレクトリを永続化するためのボリュームです。
ここにCA証明書やルーティング情報が格納されます。

npm-cachemyapp-node_modules も同じように永続化されるものですが、どちらかというとパフォーマンス改善とトラブル回避のために設定しています。

最後に forwardPorts でportlessが使うポート1355番を設定しています。
portlessを使わずに動作確認をするためのポートとして3000番を使います。
portlessが動くようになったら削除してください。

{
  "name": "myapp",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/devcontainers/features/node:1": {}
  },
  "postCreateCommand": "/bin/bash .devcontainer/postCreateCommand.sh",
  "waitFor": "postCreateCommand",
  "containerEnv": {
    "WORKSPACE_FOLDER": "${containerWorkspaceFolder}",
    "PORTLESS_HTTPS": "1",
    "PORTLESS_STATE_DIR": "/home/vscode/.portless"
  },
  "mounts": [
    {
      "type": "volume",
      "source": "portless",
      "target": "/home/vscode/.portless"
    },
    {
      "type": "volume",
      "source": "claude-config-dir",
      "target": "/home/vscode/.claude"
    },
    {
      "type": "volume",
      "source": "npm-cache",
      "target": "/home/vscode/.npm"
    },
    {
      "type": "volume",
      "source": "myapp-node_modules",
      "target": "${containerWorkspaceFolder}/node_modules"
    }
  ],
  "forwardPorts": [1355, 3000]
}

.devcontainer/postCreateCommand.sh

コンテナ作成後に実行される初期化スクリプトです。

ここでは、名前付きボリュームの権限を調整しつつ、jqとClaude CLIのインストールを行っています。

Node.jsは devcontainer.jsonfeatures で追加しているため、このスクリプトでのインストールは不要です。

#!/bin/bash
set -eu

sudo apt-get update
sudo apt-get install -y jq

# ボリュームの権限を修正
sudo chown -R "$(id -gn):$(whoami)" /home/vscode
sudo chown -R "$(id -gn):$(whoami)" "${WORKSPACE_FOLDER}/node_modules"

# git safe directory
git config --global --add safe.directory "$WORKSPACE_FOLDER"

# Claude CLI
curl -fsSL https://claude.ai/install.sh | bash

最小限のWebアプリケーション

ここからは、動作確認用として node:http だけの最小限のWebアプリケーションを用意します。

package.json

{
  "name": "myapp",
  "type": "module"
}

src/index.ts

ここで実行するサンプルのアプリケーションでは、環境変数として渡された PORT を使ってHTTPサーバをホストします。

import { createServer } from "node:http";

const port = process.env.PORT ? Number(process.env.PORT) : 3000;

const server = createServer((req, res) => {
  res.writeHead(200, { "content-type": "text/plain" });
  res.end(`Hello from ${port}`);
});

server.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});
node ./src/index.ts

でサーバを起動した後、 http://localhost:3000 にアクセスすれば応答が得られます。

git リポジトリとして初期化

いくつかリソースを作成したので、git リポジトリとして初期化してきます。

まず、.gitignore には以下を追加しておきましょう。

.claude/worktrees
node_modules/
.portless/

次はリポジトリを初期化して、最初のコミットを作ります。

  • git init
  • git add .gitignore .devcontainer package.json src/
  • git commit -m "initial commit"

portless で単一ポート・複数サブドメインルーティング

portless はローカル開発用のリバースプロキシです。

portless を使ってサーバを起動する

では、アプリケーションをportless経由で起動できるようにスクリプトを追加しましょう。

{
  "name": "myapp",
  "type": "module",
  "scripts": {
    "dev": "npx portless run node ./src/index.ts"
  }
}

portless run を実行すると、portlessは以下のことを行います。

  1. プロキシが未起動なら自動でバックグラウンド起動する
  2. CA証明書がなければ、PORTLESS_STATE_DIR に証明書を生成する
  3. package.jsonname からサブドメイン名を推論する
  4. git worktreeを検知したらブランチ名をプレフィックスに付与する
  5. 空いているポートを見つけて $PORT 環境変数に設定する
  6. 指定されたコマンドをそのポートで起動する
  7. サブドメインからアプリケーションへのルーティングを登録する

まずはメインリポジトリでサーバを起動してみましょう。

# メインリポジトリ
npm run dev

初回は証明書のエラーが出力されます。

Starting proxy...
Ensuring TLS certificates...
Adding CA to system trust store...
Could not add CA to system trust store.
Permission denied. Try: sudo portless trust

これは、SSL証明書をOSに登録できるのはrootユーザのみだからです。

以下のコマンドでコンテナ内のOSに証明書を登録します。

sudo env PATH="$PATH" npx portless trust

ホストOSのブラウザでもHTTPS警告が出ないように、CA証明書をホストOS側にインストールしましょう。
portlessの状態ディレクトリは名前付きボリューム上にあるので、ワークスペースにコピーします。

cp -r ~/.portless .

ホストOSでもMacやLinuxなら以下のコマンドを実行してください。

PORTLESS_STATE_DIR=".portless" npx portless trust

Windowsなら、PowerShellを管理者権限で起動して以下のコマンドを実行します。

$env:PORTLESS_STATE_DIR = "$($PWD.Path)\.portless"
npx portless trust


Windows では、証明書をインストールする際に確認ダイアログが出力されます。

気を取り直して、もう一度サーバを起動するコマンドを実行します。
その後、ブラウザで https://myapp.localhost:1355 にアクセスしてみましょう。

次は、ワークツリーを作成してから、そのディレクトリ内でサーバを起動するコマンドを実行しています。
もし、まだgitリポジトリになっていないようなら、 git init で初期化してください。

# linked worktree(ブランチ feature-a)
git worktree add .worktrees/feature-a
cd .worktrees/feature-a
npm run dev
# → https://feature-a.myapp.localhost:1355

サーバが起動したら、ブラウザで https://feature-a.myapp.localhost:1355 にアクセスしてみましょう。

ホスト側のブラウザからアクセスするポート番号は同じままですが、画面に表示されるポート番号は新しいものに変わっているはずです。

Claude Code 用の環境を整える

まずは、claude コマンドを実行して、テーマの設定やログインを実施しておいてください。

Claude Codeにはhooksという仕組みがあり、
特定のイベントが発生したときにシェルスクリプトを実行できます。
今回は SessionStart フックを使います。
これは、Claude Codeのセッションが開始されるときに実行されます。

.claude/hooks/session-init.sh

このフックの役割は、セッション内で必要になる資源を整えることです。

今回は説明を簡易化するために npm install だけを実施しています。

例えば、.envrc を作成したり、DBの初期データを投入すると便利ですよ。

#!/bin/bash
set -eu

cd "${CLAUDE_PROJECT_DIR:-$(pwd)}"
npm install >&2

.claude/settings.json

フックの登録はsettings.jsonで行います。

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh"
          }
        ]
      }
    ]
  }
}

claude -w を実行してワークツリーが作成されるか確認してください。

statusline にホストURLを表示する

最後の仕上げとして、Claude Codeのプロンプト下部に常時表示される情報行を追加します。

現在のworktreeに対応するURLを常時表示しておくと、セッションと紐づくサーバがすぐに分かるので便利です。

.claude/statusline.sh

#!/bin/bash
input=$(cat)

_url=$(NO_COLOR=1 npx --yes portless get myapp 2>/dev/null)
[ -z "$_url" ] && exit 0

_routes="${PORTLESS_STATE_DIR:-$HOME/.portless}/routes.json"
_host=$(echo "$_url" | sed 's|.*://||; s|:.*||')

if [ -f "$_routes" ] \
   && jq -e --arg h "$_host" \
      'any(.[]; .hostname == $h)' "$_routes" \
      > /dev/null 2>&1; then
  printf '\033[38;5;75m%s\033[0m' "$_url"  # blue
else
  printf '\033[38;5;245m%s\033[0m' "$_url"  # gray
fi
printf '\n'

サーバが停止中ならグレー、起動中なら青色で表示されるようにしてみました。


これを組み込んだ後の settings.json です。

{
  "hooks": {
    "SessionStart": [
      {
        "type": "command",
        "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh"
      }
    ]
  },
  "statusLine": {
    "type": "command",
    "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/statusline.sh",
    "padding": 0
  }
}

Claudeのセッション中に

!npm run dev

とすることでサーバを起動できます。

まとめ

この記事では、Claude Codeのworktreeを使った並行開発を快適にするための環境を構築しました。

構成要素をまとめると以下のとおりです。

コンポーネント 役割
DevContainer 再現可能な開発環境
claude -w worktreeの作成と自動初期化
portless 単一ポートのリバースプロキシ
statusline 現在のworktreeのURLを常時表示

この構成では、claude -w feature-a とセッションを開始すると、
https://feature-a.myapp.localhost:1355 でアクセスできます。

引数を渡さなければランダムなワークツリー名が付与されますが、portlessが適切なホスト名になるよう丸めてくれます。

また、statuslineにURLを表示しているので、もう迷うこともありません。

実際の開発では、ここに認証サーバやS3モックなどのサービスを追加していくことになるでしょう。

基本的な部分は実現できているので、追加のサーバ導入はAIがうまくやってくれますので、お任せしていきましょう。

執筆:@sato.taichi
レビュー:@handa.kenta
Shodoで執筆されました