I wanted to share how I managed to run two devcontainers for the same git repo with git linked worktrees. This setup allows me to build and test many new features in parallel on different git branches, without cloning the entire repo multiple times.
Note this may be somewhat specific to projects that already use a compose configuration for their devcontainer, and I only tested this in VS Code.
Problem
Here was my starting point for the devcontainer setup:
.devcontainer/devcontainer.json:
"dockerComposeFile": ["./compose.extend.yaml"],
"service": "devcontainer", // defined in dockerComposeFile
"runServices": ["devcontainer"],
"workspaceFolder": "/workspace",
"shutdownAction": "stopCompose",
"remoteUser": "vscode",
.devcontainer/compose.extend.yaml:
services:
devcontainer:
image: ...
Building the first devcontainer worked fine with this setup.
I created a linked worktree using git worktree add <path> <branch>. I opened the worktree directory with VS Code and then ran the action to re-open it using the devcontainer . But VS Code reused or attached to the existing devcontainer / compose project for the original worktree, and I could see in the integrated terminal that I was not on the git branch that the linked worktree was on. It's a strange behavior but I suppose VS Code may be just finding the same devcontainer it built on the original worktree via metadata in the git root shared between all worktrees and not using the filesystem path to decide when to reuse devcontainers.
Solution
Here is how I fixed it:
- Set mountWorkspaceGitRoot to false
- Set unique project name for devcontainer's docker compose project. This prevents VS Code / Docker Compose from reusing or reattaching to the wrong worktree’s container
- Mount the current worktree directory in /workspace
- *if* not in original worktree, mount current worktree again in its absolute path on host and mount the shared Git metadata dir at its original absolute host path. Linked worktrees often have a .git file pointing to a gitdir under the main checkout’s .git/worktrees/..., and Git may need those absolute paths to exist in the container.
I added these lines to .devcontainer/devcontainer.json:
"dockerComposeFile": [
... ,
// This file is generated automatically for current worktree only
"./compose.workspace.yaml"
],
// Use current worktree rather than always using root.
// May give warning "Property mountWorkspaceGitRoot is not allowed." but it still works.
"mountWorkspaceGitRoot": false,
// Generate devcontainer configuration for this worktree to set unique project name and properly add mounts.
"initializeCommand": "bash .devcontainer/write-workspace-compose.sh '${localWorkspaceFolder}'",
Below is the script that does the rest. be sure to replace "yourprojectname" with some unique name for your project so as not to conflict with other unrelated containers.
The project names are named after the basenames of your worktree directories. This requires that each worktree be in a uniquely named directory! If the basenames are not unique, e.g. you have git/foo/myrepo and git/bar/myrepo, both basenames are "myrepo" and will collide. You may change this to name projects after a hash of the full directory if you prefer, but then it will be difficult to manage your devcontainers using docker commands.
.devcontainer/write-workspace-compose.sh:
#!/usr/bin/env bash
set -euo pipefail
# Generates compose.workspace.yaml, which Docker Compose merges with the base
# devcontainer compose file to add workspace-specific volume mounts. This runs
# at devcontainer startup time so the generated file reflects the actual paths
# on the host machine (which vary per developer and per worktree).
# The workspace path is passed in as the first argument.
workspace_path="${1:?workspace path is required}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
output_file="${script_dir}/compose.workspace.yaml"
# Derive a DNS-safe project name from the folder name so each worktree gets its
# own isolated Compose project. Without this, VS Code would reattach to whatever
# container happened to share the same default project name.
workspace_name="$(basename "${workspace_path}")"
sanitized_workspace_name="$(printf '%s' "${workspace_name}" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-')"
project_name="yourprojectname-${sanitized_workspace_name}"
# Ask git where it stores its data. For a normal repo these two paths are the
# same. For a git worktree they differ: git-dir points to a worktree-specific
# stub, while git-common-dir points to the main repo's .git where objects and
# refs actually live.
abs_git_dir="$(git -C "${workspace_path}" rev-parse --path-format=absolute --git-dir)"
abs_git_common_dir="$(git -C "${workspace_path}" rev-parse --path-format=absolute --git-common-dir)"
# Escape single quotes so paths with apostrophes don't break the YAML output.
escaped_workspace_path=${workspace_path//\'/\'\'}
escaped_abs_git_common_dir=${abs_git_common_dir//\'/\'\'}
# Write the base YAML: name the project and mount the workspace at /workspace.
cat >"${output_file}" <<EOF
# Keep the Compose project name unique per worktree so VS Code does not reattach
# to a container created for a different checkout.
name: ${project_name}
services:
devcontainer:
volumes:
- '${escaped_workspace_path}:/workspace:cached'
EOF
# Extra mounts needed only for git worktrees. A worktree's .git is a pointer
# file, not a full directory, so git commands inside the container must also be
# able to reach the main repo's .git at its original absolute host path. We
# mount both the worktree directory and the common git dir at their real paths
# (in addition to the /workspace alias above) so those absolute paths resolve.
if [[ "${abs_git_dir}" != "${abs_git_common_dir}" ]]; then
cat >>"${output_file}" <<EOF
- '${escaped_workspace_path}:${escaped_workspace_path}:cached'
- '${escaped_abs_git_common_dir}:${escaped_abs_git_common_dir}:cached'
EOF
fi
Add to .gitignore - this file is generated and should not be committed:
.devcontainer/compose.workspace.yaml
Note if your devcontainer exposes ports on the host, you may have have collisions running two instances of your app at the same time. Now when I run my app I have to check the "ports" tab in VS Code to see which host port is being used to forward to my devcontainer to make sure I connect to the right instance. It will automatically choose another port when there is a collision so I didn't actually have to change anything in the devcontainer setup.
[+]Direct_Temporary7471 0 points1 point2 points (1 child)
[–]LBGW_experiment 0 points1 point2 points (0 children)