こんにちは。
先進サービス開発事業部で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を相対パスで定義すればいいのではとなりますが、
実際に設定してみると認識されません。
少し調べてみると、まだ対応していないようです。
対処
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で複数のブランチを開き、同時に編集が可能が下地ができたのかなと思っています。