From 1dc5cfdd6035efdfcfa9587e0bd4f246e472e174 Mon Sep 17 00:00:00 2001 From: avishayhirsh Date: Thu, 2 Jul 2026 13:06:05 +0300 Subject: [PATCH] Mount worktree common dir when a custom workspaceMount is set The --mount-git-worktree-common-dir logic was nested inside the guard that skips workspace mount computation when devcontainer.json sets both workspaceFolder and workspaceMount. As a result the worktree common dir was never mounted and git operations failed in the container ("fatal: not a git repository"). Decouple the additive common dir mount from that guard and resolve its target relative to the custom (substituted) workspaceMount target so the relative gitdir resolves correctly inside the container. --- src/spec-node/utils.ts | 15 ++- src/test/workspaceConfiguration.test.ts | 148 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index e6cf6980f..c530aa2b6 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -19,11 +19,12 @@ import { ShellServer } from '../spec-common/shellServer'; import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { getRemoteWorkspaceFolder } from './dockerCompose'; import { findGitRootFolder } from '../spec-common/git'; +import { substitute } from '../spec-common/variableSubstitution'; import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; import { DevContainerConfig, DevContainerFromDockerfileConfig, getConfigFilePath, getDockerfilePath } from '../spec-configuration/configuration'; import { StringDecoder } from 'string_decoder'; import { Event } from '../spec-utils/event'; -import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; +import { Mount, parseMount } from '../spec-configuration/containerFeaturesConfiguration'; import { PackageConfiguration } from '../spec-utils/product'; import { ImageMetadataEntry, MergedDevContainerConfig } from './imageMetadata'; import { getImageIndexEntryForPlatform, getManifest, getRef } from '../spec-configuration/containerCollectionsOCI'; @@ -417,7 +418,7 @@ export async function getWorkspaceConfiguration(cliHost: CLIHost, workspace: Wor } let { workspaceFolder, workspaceMount } = config; let additionalMountString: string | undefined; - if (workspace && (!workspaceFolder || !('workspaceMount' in config))) { + if (workspace && (!workspaceFolder || !('workspaceMount' in config) || (mountWorkspaceGitRoot && mountGitWorktreeCommonDir))) { const hostMountFolder = await getHostMountFolder(cliHost, workspace.rootFolderPath, mountWorkspaceGitRoot, output); // Check if .git is a file (worktree) with a relative gitdir path @@ -439,9 +440,17 @@ export async function getWorkspaceConfiguration(cliHost: CLIHost, workspace: Wor segments.unshift(cliHost.path.basename(current)); } containerMountFolder = path.posix.join('/workspaces', ...segments); + // A custom workspaceMount defines where the worktree is mounted in the container; resolve the + // common dir relative to that (substituted) target so the relative gitdir resolves correctly. + // additionalMountString is not part of the later substitution pass, so substitute here. + // Otherwise fall back to the computed containerMountFolder. + const customMountTarget = 'workspaceMount' in config && config.workspaceMount ? + substitute({ platform: cliHost.platform, localWorkspaceFolder: workspace.rootFolderPath, env: cliHost.env }, parseMount(config.workspaceMount)).target : + undefined; + const worktreeContainerFolder = customMountTarget || containerMountFolder; // Calculate where the common dir should be mounted in the container const containerGitdir = cliHost.platform === 'win32' ? gitdir.replace(/\\/g, '/') : gitdir; - const containerGitCommonDir = path.posix.resolve(containerMountFolder, containerGitdir, '..', '..'); + const containerGitCommonDir = path.posix.resolve(worktreeContainerFolder, containerGitdir, '..', '..'); const cons = cliHost.platform !== 'linux' ? `,consistency=${consistency || 'consistent'}` : ''; const srcQuote = gitCommonDir.indexOf(',') !== -1 ? '"' : ''; const tgtQuote = containerGitCommonDir.indexOf(',') !== -1 ? '"' : ''; diff --git a/src/test/workspaceConfiguration.test.ts b/src/test/workspaceConfiguration.test.ts index 1e0b7e992..550775519 100644 --- a/src/test/workspaceConfiguration.test.ts +++ b/src/test/workspaceConfiguration.test.ts @@ -330,6 +330,154 @@ describe('getWorkspaceConfiguration', function () { }); + describe('git worktree with custom workspaceMount', function () { + + it('should add common dir mount when a custom workspaceMount and workspaceFolder are set', async () => { + const p = { + linux: { worktreePath: '/home/user/worktrees/feature', gitFile: '/home/user/worktrees/feature/.git', gitdir: 'gitdir: ../main/.git/worktrees/feature', mainGitPath: '/home/user/worktrees/main/.git', consistency: '' }, + darwin: { worktreePath: '/Users/user/worktrees/feature', gitFile: '/Users/user/worktrees/feature/.git', gitdir: 'gitdir: ../main/.git/worktrees/feature', mainGitPath: '/Users/user/worktrees/main/.git', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\worktrees\\feature', gitFile: 'C:\\Users\\user\\worktrees\\feature\\.git', gitdir: 'gitdir: ../main/.git/worktrees/feature', mainGitPath: 'C:\\Users\\user\\worktrees\\main\\.git', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitFile]: p.gitdir + } + }); + const workspace = createWorkspace(p.worktreePath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + { workspaceMount: 'type=bind,source=/host/wt,target=/workspace', workspaceFolder: '/workspace' }, + true, + true, + nullLog + ); + + // The custom workspaceMount/workspaceFolder are respected unchanged, and the common dir is + // still mounted, with its target resolved relative to the custom mount target (/workspace). + assert.strictEqual(result.workspaceMount, 'type=bind,source=/host/wt,target=/workspace'); + assert.strictEqual(result.workspaceFolder, '/workspace'); + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.mainGitPath},target=/main/.git${p.consistency}`); + }); + + it('should substitute variables in the custom workspaceMount target', async () => { + const p = { + linux: { worktreePath: '/home/user/worktrees/feature', gitFile: '/home/user/worktrees/feature/.git', gitdir: 'gitdir: ../main/.git/worktrees/feature', mainGitPath: '/home/user/worktrees/main/.git', consistency: '' }, + darwin: { worktreePath: '/Users/user/worktrees/feature', gitFile: '/Users/user/worktrees/feature/.git', gitdir: 'gitdir: ../main/.git/worktrees/feature', mainGitPath: '/Users/user/worktrees/main/.git', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\worktrees\\feature', gitFile: 'C:\\Users\\user\\worktrees\\feature\\.git', gitdir: 'gitdir: ../main/.git/worktrees/feature', mainGitPath: 'C:\\Users\\user\\worktrees\\main\\.git', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitFile]: p.gitdir + } + }); + const workspace = createWorkspace(p.worktreePath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + { workspaceMount: 'type=bind,source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename}', workspaceFolder: '/workspaces/feature' }, + true, + true, + nullLog + ); + + // ${localWorkspaceFolderBasename} in the target is substituted before resolving the common dir. + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.mainGitPath},target=/workspaces/main/.git${p.consistency}`); + }); + + it('should not add common dir mount when mountGitWorktreeCommonDir is false', async () => { + const p = { + linux: { worktreePath: '/home/user/worktrees/feature', gitFile: '/home/user/worktrees/feature/.git', gitdir: 'gitdir: ../main/.git/worktrees/feature' }, + darwin: { worktreePath: '/Users/user/worktrees/feature', gitFile: '/Users/user/worktrees/feature/.git', gitdir: 'gitdir: ../main/.git/worktrees/feature' }, + win32: { worktreePath: 'C:\\Users\\user\\worktrees\\feature', gitFile: 'C:\\Users\\user\\worktrees\\feature\\.git', gitdir: 'gitdir: ../main/.git/worktrees/feature' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitFile]: p.gitdir + } + }); + const workspace = createWorkspace(p.worktreePath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + { workspaceMount: 'type=bind,source=/host/wt,target=/workspace', workspaceFolder: '/workspace' }, + true, + false, + nullLog + ); + + assert.strictEqual(result.workspaceMount, 'type=bind,source=/host/wt,target=/workspace'); + assert.strictEqual(result.workspaceFolder, '/workspace'); + assert.isUndefined(result.additionalMountString); + }); + + it('should not add common dir mount when .git is not a worktree file', async () => { + const p = { + linux: { projectPath: '/home/user/project' }, + darwin: { projectPath: '/Users/user/project' }, + win32: { projectPath: 'C:\\Users\\user\\project' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: {} + }); + const workspace = createWorkspace(p.projectPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + { workspaceMount: 'type=bind,source=/host/wt,target=/workspace', workspaceFolder: '/workspace' }, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceMount, 'type=bind,source=/host/wt,target=/workspace'); + assert.strictEqual(result.workspaceFolder, '/workspace'); + assert.isUndefined(result.additionalMountString); + }); + + it('should add common dir mount for a worktree nested inside the main repo', async () => { + const p = { + linux: { worktreePath: '/home/user/main/wt', gitFile: '/home/user/main/wt/.git', gitdir: 'gitdir: ../.git/worktrees/wt', mainGitPath: '/home/user/main/.git', consistency: '' }, + darwin: { worktreePath: '/Users/user/main/wt', gitFile: '/Users/user/main/wt/.git', gitdir: 'gitdir: ../.git/worktrees/wt', mainGitPath: '/Users/user/main/.git', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\main\\wt', gitFile: 'C:\\Users\\user\\main\\wt\\.git', gitdir: 'gitdir: ../.git/worktrees/wt', mainGitPath: 'C:\\Users\\user\\main\\.git', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitFile]: p.gitdir + } + }); + const workspace = createWorkspace(p.worktreePath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + { workspaceMount: 'type=bind,source=/host/wt,target=/workspace', workspaceFolder: '/workspace' }, + true, + true, + nullLog + ); + + // Worktree nested under the main repo: gitdir points up one level; the common dir resolves to + // a root-level target inside the container, consistent with how git resolves it there. + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.mainGitPath},target=/.git${p.consistency}`); + }); + + }); + describe('git root in parent folder', function () { it('should mount from git root when .git/config is in parent folder', async () => {