APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

vscodeとdevcontainerでのgit worktreeの使い方を考えてみる。(AI並列開発検討時の副産物)

こんにちは。

先進サービス開発事業部でNEEDLEWORKの開発を担当している田中です。
今回は、AI並列開発にむけて調査していたところgit worktreeの使い方で
知見を得たのでここに残したいと思います。

誰かの助けになればとても嬉しいです。

gitのworktreeを兄弟ディレクトリとして定義するとdevcontainerの中から参照できない。

git worktreeの例として以下のようなモノが紹介されているかと思います。

path構造 
/worktree_master
 ├──────────/main_branch/ ← ここにgitの状態を管理する.gitディレクトリがある。
 └──────────/second_branch/ ←追加したworktree

この場合だと、main_branch、その上のworktree_masterをdevcontainerで開かないと
gitのディレクトリとして認識できません。

second_branchをdevcontainerから開いた場合は、 .gitディレクトリが存在しないように見えるので、commitなどの操作ができません。

また、worktree_masterを開く場合だと直下に.devcontainerディレクトリが必要になってしまい、
リポジトリの外でdevcontainerの設定を管理しなければなりません。

対処

メインで作業しているブランチの下にworktreeを作成します。

path構造
 /main_branch/ ← ここにgitの状態を管理する.gitディレクトリがある。
 └──────────/worktrees/ ← worktreeを保存するディレクトリ
              └──────────/second_branch/ ← 追加したworktree

この構造の場合でもvscodeやgitはworktreeを正常に認識し、
main_branchとsecond_branchを分離してgit addしたりコミットをしたりすることが可能です。

ホストOSで定義したgitのworktreeをdevcontainerから開くと認識しない。

①git worktreeは絶対パスで管理されている。

git worktreeはデフォルトで絶対パスで管理されています。

$ git worktree add -b backend worktrees/backend # <- 相対パスで追加しても
Preparing worktree (new branch 'backend')
HEAD is now at c718e4e devcontainer追加

$ git worktree list
/home/apc/develop/sample-worktree                     c718e4e [main]
/home/apc/develop/sample-worktree/worktrees/backend   c718e4e [backend] # <- この通り絶対パスで追加されます。
/home/apc/develop/sample-worktree/worktrees/frontend  c718e4e [frontend]

$ cat .git/worktrees/backend/gitdir # <- それっぽいファイルにも絶対パスで書かれている 
/home/apc/develop/sample-worktree/worktrees/backend/.git

なので、ホストOS側でgit worktreeを定義した場合、devcontainerで参照しようとすると、
パスが変わってしまうのでうまく認識されません。

②git worktreeの相対パスにvscodeは対応していない。

では、git worktreeを相対パスで定義すればいいのではとなりますが、
実際に設定してみると認識されません。
少し調べてみると、まだ対応していないようです。

参考URL

対処

devcontainerのパスとホストOSのパスを一致させます。

~devcontainer.json抜粋~
  "workspaceFolder": "${localWorkspaceFolder}",
  "mounts": [
    "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind"
  ],

vscodeで持っている${localWorkspaceFolder}をマウントし、
workspaceFolderも${localWorkspaceFolder}に合わせています。

サンプル

  • devcontainer.json
{
  "name": "SampleWorkspace",
  "build": {
    "dockerfile": "../Dockerfile",
    "context": ".."
  },
  "features": {
    "ghcr.io/devcontainers/features/docker-in-docker:2": {
      "version": "latest",
      "moby": true
    }
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "golang.go"
      ],
      "settings": {
        "go.toolsManagement.checkForUpdates": "local",
        "go.useLanguageServer": true,
        "go.gopath": "/go",
        "go.goroot": "/usr/local/go"
      }
    }
  },
  "remoteUser": "devuser",
  "containerEnv": {
    "HOST_PWD": "${localWorkspaceFolder}"
  },
  "workspaceFolder": "${localWorkspaceFolder}",
  "mounts": [
    "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind"
  ],
  "runArgs": ["--privileged"],
  "updateContentCommand": "sudo bash -c 'TARGET_UID=$(stat -c \"%u\" \"$HOST_PWD\") && TARGET_GID=$(stat -c \"%g\" \"$HOST_PWD\") && CURRENT_UID=$(id -u devuser) && CURRENT_GID=$(id -g devuser) && if [ \"$CURRENT_UID\" != \"$TARGET_UID\" ] || [ \"$CURRENT_GID\" != \"$TARGET_GID\" ]; then echo \"Remapping devuser: $CURRENT_UID:$CURRENT_GID -> $TARGET_UID:$TARGET_GID\" && groupmod -g $TARGET_GID devuser && usermod -u $TARGET_UID devuser && chown -R $TARGET_UID:$TARGET_GID /home/devuser; fi'",
  "postCreateCommand": "go version && docker --version"
}
  • Dockerfile
FROM ubuntu:24.04

# 環境変数の設定
ENV DEBIAN_FRONTEND=noninteractive \
    GO_VERSION=1.21.5 \
    NODE_VERSION=24 \
    PATH=/usr/local/go/bin:$PATH

# 必要なパッケージのインストール
RUN apt-get update && apt-get install -y \
    wget \
    curl \
    build-essential \
    ca-certificates \
    apt-transport-https \
    gnupg \
    lsb-release \
    sudo \
    python3 \
    python3-pip \
    software-properties-common \
    && rm -rf /var/lib/apt/lists/*

# Git最新版のインストール (公式PPA)
RUN add-apt-repository ppa:git-core/ppa -y \
    && apt-get update \
    && apt-get install -y git \
    && rm -rf /var/lib/apt/lists/*

# Dockerのインストール
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
    && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
    $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \
    && apt-get update \
    && apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
    && rm -rf /var/lib/apt/lists/*


# Goのインストール
RUN wget https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz && \
    tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz && \
    rm go${GO_VERSION}.linux-amd64.tar.gz


# Node.jsのインストール (LTS版)
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
    && apt-get install -y nodejs \
    && rm -rf /var/lib/apt/lists/*

# .local/binをPATHに追加(全ユーザー対応)
RUN echo 'export PATH="$HOME/.local/bin:$PATH"' >> /etc/bash.bashrc \
    && echo 'export PNPM_HOME="$HOME/.local/share/pnpm"' >> /etc/bash.bashrc \
    && echo 'export PATH="$PNPM_HOME:$PATH"' >> /etc/bash.bashrc

# gosuのインストール(軽量なsu-execツール)
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*

# 既存のubuntuユーザー(UID/GID 1000)をdevuserにリネーム
RUN usermod -l devuser -d /home/devuser -m ubuntu \
    && groupmod -n devuser ubuntu \
    && echo "devuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# devuserに切り替え
USER devuser

# pnpmのインストール (devuserのホームにインストール)
RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash -

# uvのインストール (devuserのホームにインストール)
RUN curl -LsSf https://astral.sh/uv/install.sh | sh

# Claude Code のインストール
RUN curl -fsSL https://claude.ai/install.sh | bash

# rootに戻る
USER root

# エントリーポイントスクリプトの作成(UID/GIDをremapする)
COPY <<'EOF' /usr/local/bin/entrypoint.sh
#!/bin/bash
set -e

# 環境変数から UID/GID を取得(デフォルトは1000)
TARGET_UID=${LOCAL_USER_ID:-1000}
TARGET_GID=${LOCAL_GROUP_ID:-1000}

# 現在のdevuserのUID/GIDを取得
CURRENT_UID=$(id -u devuser)
CURRENT_GID=$(id -g devuser)

# UID/GIDが異なる場合はremap
if [ "$CURRENT_UID" != "$TARGET_UID" ] || [ "$CURRENT_GID" != "$TARGET_GID" ]; then
    echo "Remapping devuser: $CURRENT_UID:$CURRENT_GID -> $TARGET_UID:$TARGET_GID"
    
    # グループIDを変更
    groupmod -g $TARGET_GID devuser
    
    # ユーザーIDを変更
    usermod -u $TARGET_UID devuser
    
    # ホームディレクトリの所有者を変更
    chown -R $TARGET_UID:$TARGET_GID /home/devuser
fi

# ホストのPWDと同じパス構造を作成
if [ -n "$HOST_PWD" ]; then
    echo "Creating path structure for: $HOST_PWD"
    
    # ホストのパスのディレクトリ構造を作成
    mkdir -p "$HOST_PWD"
    
    # 作業ディレクトリを設定
    cd "$HOST_PWD"
    echo "Working directory set to: $HOST_PWD"
else
    # デフォルトは/workspaceを使用
    mkdir -p /workspace
    cd /workspace
    echo "Working directory set to: /workspace (default)"
fi

# devuserとして実行
exec gosu devuser "$@"
EOF

RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/bin/bash"]
  • Makefile
# Makefile for Claude Container Management

# 動的変数の設定
PREFIX := sample-worktree
BRANCH_NAME := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null || basename "$$(pwd)")
IMAGE_NAME := $(PREFIX)-$(BRANCH_NAME)
CONTAINER_NAME := $(PREFIX)-$(BRANCH_NAME)

# ユーザーID/グループIDの取得
USER_ID := $(shell id -u)
GROUP_ID := $(shell id -g)
USER_NAME := $(shell id -un)

# Dockerfileのパス(ブランチごとのDockerfileを使用)
DOCKERFILE := ./Dockerfile
CONTEXT := .

# デフォルトターゲット
.PHONY: help
help:
    @echo "Usage: make [target]"
    @echo ""
    @echo "Targets:"
    @echo "  build         - Docker imageをビルド ($(IMAGE_NAME))"
    @echo "  run           - コンテナを起動 ($(CONTAINER_NAME))"
    @echo "  start         - 停止中のコンテナを開始"
    @echo "  stop          - コンテナを停止"
    @echo "  restart       - コンテナを再起動"
    @echo "  logs          - コンテナのログを表示"
    @echo "  exec          - コンテナ内でbashを起動"
    @echo "  ps            - コンテナの状態を確認"
    @echo "  rm            - コンテナを削除"
    @echo "  rmi           - イメージを削除"
    @echo "  clean         - コンテナとイメージを削除"
    @echo "  rebuild       - クリーンビルド (clean + build)"
    @echo "  info          - 現在の設定情報を表示"
    @echo "  debug         - デバッグ情報を詳細表示"
    @echo ""
    @echo "Current settings:"
    @echo "  Prefix:     $(PREFIX)"
    @echo "  Branch:     $(BRANCH_NAME)"
    @echo "  Image:      $(IMAGE_NAME)"
    @echo "  Container:  $(CONTAINER_NAME)"
    @echo "  User ID:    $(USER_ID)"
    @echo "  Group ID:   $(GROUP_ID)"
    @echo "  Username:   $(USER_NAME)"

# 設定情報の表示
.PHONY: info
info:
    @echo "Prefix:          $(PREFIX)"
    @echo "Branch Name:     $(BRANCH_NAME)"
    @echo "Image Name:      $(IMAGE_NAME)"
    @echo "Container Name:  $(CONTAINER_NAME)"
    @echo "Dockerfile:      $(DOCKERFILE)"
    @echo "Context:         $(CONTEXT)"

# デバッグ用: すべての変数の値を表示
.PHONY: debug
debug:
    @echo "=== Debug Information ==="
    @echo "PREFIX         = '$(PREFIX)'"
    @echo "BRANCH_NAME    = '$(BRANCH_NAME)'"
    @echo "IMAGE_NAME     = '$(IMAGE_NAME)'"
    @echo "CONTAINER_NAME = '$(CONTAINER_NAME)'"
    @echo "DOCKERFILE     = '$(DOCKERFILE)'"
    @echo "CONTEXT        = '$(CONTEXT)'"
    @echo "PWD            = '$(PWD)'"
    @echo ""
    @echo "=== Git Information ==="
    @echo "Git root:       $$(git rev-parse --show-toplevel 2>/dev/null || echo 'Not a git repository')"
    @echo "Current branch: $$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'N/A')"
    @echo ""
    @echo "=== File Check ==="
    @test -f $(DOCKERFILE) && echo "Dockerfile exists: $(DOCKERFILE)" || echo "Dockerfile NOT found: $(DOCKERFILE)"
    @test -d $(CONTEXT) && echo "Context exists: $(CONTEXT)" || echo "Context NOT found: $(CONTEXT)"
    @echo ""
    @echo "=== Docker Status ==="
    @docker --version 2>/dev/null || echo "Docker not available"
    @echo ""
    @echo "=== Existing Images/Containers for this branch ==="
    @docker images | grep "$(PREFIX)-$(BRANCH_NAME)" || echo "No images found"
    @docker ps -a | grep "$(PREFIX)-$(BRANCH_NAME)" || echo "No containers found"

# Docker imageのビルド
.PHONY: build
build:
    @echo "Building Docker image: $(IMAGE_NAME)"
    docker build -t $(IMAGE_NAME) -f $(DOCKERFILE) $(CONTEXT)
    @echo "Build complete: $(IMAGE_NAME)"

# コンテナの起動(新規作成)
.PHONY: run
run:
    @echo "Starting container: $(CONTAINER_NAME)"
    docker run -it \
        --name $(CONTAINER_NAME) \
        --privileged \
        -v $(PWD):$(PWD) \
        -e LOCAL_USER_ID=$(USER_ID) \
        -e LOCAL_GROUP_ID=$(GROUP_ID) \
        -e LOCAL_USERNAME=$(USER_NAME) \
        -e HOST_PWD=$(PWD) \
        -e GIT_AUTHOR_NAME="$(shell git config user.name 2>/dev/null || echo '')" \
        -e GIT_COMMITTER_NAME="$(shell git config user.name 2>/dev/null || echo '')" \
        -e GIT_AUTHOR_EMAIL="$(shell git config user.email 2>/dev/null || echo '')" \
        -e GIT_COMMITTER_EMAIL="$(shell git config user.email 2>/dev/null || echo '')" \
        $(IMAGE_NAME)

# 停止中のコンテナを開始
.PHONY: start
start:
    @echo "Starting container: $(CONTAINER_NAME)"
    docker start -i $(CONTAINER_NAME)

# コンテナの停止
.PHONY: stop
stop:
    @echo "Stopping container: $(CONTAINER_NAME)"
    docker stop $(CONTAINER_NAME)

# コンテナの再起動
.PHONY: restart
restart: stop start

# ログの表示
.PHONY: logs
logs:
    docker logs -f $(CONTAINER_NAME)

# コンテナ内でbashを起動
.PHONY: exec
exec:
    @echo "Entering container: $(CONTAINER_NAME)"
    docker exec -it -u devuser $(CONTAINER_NAME) /bin/bash

# コンテナの状態確認
.PHONY: ps
ps:
    @docker ps -a -f name=$(CONTAINER_NAME)

# コンテナの削除
.PHONY: rm
rm:
    @echo "Removing container: $(CONTAINER_NAME)"
    -docker stop $(CONTAINER_NAME) 2>/dev/null
    docker rm $(CONTAINER_NAME)

# イメージの削除
.PHONY: rmi
rmi:
    @echo "Removing image: $(IMAGE_NAME)"
    docker rmi $(IMAGE_NAME)

# コンテナとイメージの削除
.PHONY: clean
clean: rm rmi
    @echo "Cleanup complete"

# クリーンビルド
.PHONY: rebuild
rebuild: clean build

# ビルドして起動
.PHONY: up
up: build run
    @echo "Container is up and running: $(CONTAINER_NAME)"

# すべてのイメージを一覧表示
.PHONY: images
images:
    @docker images | grep "$(PREFIX)-" || echo "No images found with prefix: $(PREFIX)"

# すべてのコンテナを一覧表示
.PHONY: containers
containers:
    @docker ps -a | grep "$(PREFIX)-" || echo "No containers found with prefix: $(PREFIX)"

※今回紹介したもの以外にも、docker run経由で実行したいためにmakefileが入っていたり、
 実行ユーザーの権限を合わせる処理が入っています。

動作確認はしていますが、99%AI製です。

最後に

vscodeのdevcontainerで複数のブランチを開き、同時に編集が可能が下地ができたのかなと思っています。