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 () => {