diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d3b7caf..58e94c9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Notable changes. +## January 2026 + +### [0.81.0] +- Add option to mount a worktree's common folder. (https://github.com/devcontainers/cli/pull/1127) + ## December 2025 ### [0.80.3] diff --git a/package.json b/package.json index af8e375dd..9678bfb52 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@devcontainers/cli", "description": "Dev Containers CLI", - "version": "0.80.3", + "version": "0.81.0", "bin": { "devcontainer": "devcontainer.js" }, diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index ee6b93cb3..b4e159657 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -46,7 +46,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, workspaceMountConsistencyDefault, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, workspaceMountConsistencyDefault, overrideConfigFile) || undefined; if (!configs) { if (configPath || workspace) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configPath || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); @@ -79,7 +79,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu return result; } -export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) { +export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) { const documents = createDocuments(cliHost); const content = await documents.readDocument(overrideConfigFile ?? configFile); if (!content) { @@ -90,7 +90,7 @@ export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Wo if (!updated || typeof updated !== 'object' || Array.isArray(updated)) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) must contain a JSON object literal.` }); } - const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, output, consistency); + const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, output, consistency); const substitute0: SubstituteConfig = value => substitute({ platform: cliHost.platform, localWorkspaceFolder: workspace?.rootFolderPath, diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index a6d546566..8a2e6b042 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -30,6 +30,7 @@ export interface ProvisionOptions { workspaceMountConsistency?: BindMountConsistency; gpuAvailability?: GPUAvailability; mountWorkspaceGitRoot: boolean; + mountGitWorktreeCommonDir: boolean; configFile: URI | undefined; overrideConfigFile: URI | undefined; logLevel: LogLevel; @@ -102,7 +103,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string } export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise | undefined)[]): Promise { - const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, omitLoggerHeader, secretsP } = options; + const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, omitLoggerHeader, secretsP } = options; let parsedAuthority: DevContainerAuthority | undefined; if (options.workspaceFolder) { parsedAuthority = { hostPath: options.workspaceFolder } as DevContainerAuthority; @@ -225,6 +226,7 @@ export async function createDockerParams(options: ProvisionOptions, disposables: workspaceMountConsistencyDefault: workspaceMountConsistency, gpuAvailability: gpuAvailability || 'detect', mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, updateRemoteUserUIDOnMacOS: false, cacheMount: 'bind', removeOnStartup: options.removeExistingContainer, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 59136695d..5d0bafd91 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -107,6 +107,7 @@ function provisionOptions(y: Argv) { 'workspace-mount-consistency': { choices: ['consistent' as 'consistent', 'cached' as 'cached', 'delegated' as 'delegated'], default: 'cached' as 'cached', description: 'Workspace mount consistency.' }, 'gpu-availability': { choices: ['all' as 'all', 'detect' as 'detect', 'none' as 'none'], default: 'detect' as 'detect', description: 'Availability of GPUs in case the dev container requires any. `all` expects a GPU to be available.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. These will be set on the container and used to query for an existing container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, @@ -182,6 +183,7 @@ async function provision({ 'workspace-mount-consistency': workspaceMountConsistency, 'gpu-availability': gpuAvailability, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, 'id-label': idLabel, config, 'override-config': overrideConfig, @@ -237,6 +239,7 @@ async function provision({ workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, configFile: config ? URI.file(path.resolve(process.cwd(), config)) : undefined, overrideConfigFile: overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined, logLevel: mapLogLevel(logLevel), @@ -420,6 +423,7 @@ async function doSetUp({ containerSystemDataFolder, workspaceFolder: undefined, mountWorkspaceGitRoot: false, + mountGitWorktreeCommonDir: false, configFile, overrideConfigFile: undefined, logLevel: mapLogLevel(logLevel), @@ -456,7 +460,7 @@ async function doSetUp({ const { common } = params; const { cliHost, output } = common; - const configs = configFile && await readDevContainerConfigFile(cliHost, undefined, configFile, params.mountWorkspaceGitRoot, output, undefined, undefined); + const configs = configFile && await readDevContainerConfigFile(cliHost, undefined, configFile, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, undefined); if (configFile && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) not found.` }); } @@ -586,6 +590,7 @@ async function doBuild({ containerSystemDataFolder: undefined, workspaceFolder, mountWorkspaceGitRoot: false, + mountGitWorktreeCommonDir: false, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -626,7 +631,7 @@ async function doBuild({ ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if (!configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -754,6 +759,7 @@ function runUserCommandsOptions(y: Argv) { 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, @@ -814,6 +820,7 @@ async function doRunUserCommands({ 'container-system-data-folder': containerSystemDataFolder, 'workspace-folder': workspaceFolderArg, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, 'container-id': containerId, 'id-label': idLabel, config: configParam, @@ -857,6 +864,7 @@ async function doRunUserCommands({ containerSystemDataFolder, workspaceFolder, mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -900,7 +908,7 @@ async function doRunUserCommands({ ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -956,6 +964,7 @@ function readConfigurationOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, @@ -993,6 +1002,7 @@ async function readConfiguration({ 'docker-compose-path': dockerComposePath, 'workspace-folder': workspaceFolderArg, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, config: configParam, 'override-config': overrideConfig, 'container-id': containerId, @@ -1033,7 +1043,7 @@ async function readConfiguration({ ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -1154,7 +1164,7 @@ async function outdated({ const workspace = workspaceFromPath(cliHost.path, workspaceFolder); const configPath = configFile ? configFile : await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath); - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, output) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, false, output) || undefined; if (!configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -1211,6 +1221,7 @@ function execOptions(y: Argv) { 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, @@ -1272,6 +1283,7 @@ export async function doExec({ 'container-system-data-folder': containerSystemDataFolder, 'workspace-folder': workspaceFolderArg, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, 'container-id': containerId, 'id-label': idLabel, config: configParam, @@ -1304,6 +1316,7 @@ export async function doExec({ containerSystemDataFolder, workspaceFolder, mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -1344,7 +1357,7 @@ export async function doExec({ ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } diff --git a/src/spec-node/featuresCLI/resolveDependencies.ts b/src/spec-node/featuresCLI/resolveDependencies.ts index 93e569ff6..887cac6ae 100644 --- a/src/spec-node/featuresCLI/resolveDependencies.ts +++ b/src/spec-node/featuresCLI/resolveDependencies.ts @@ -77,7 +77,7 @@ async function featuresResolveDependencies({ const cliHost = await getCLIHost(cwd, loadNativeModule, true); const workspace = workspaceFromPath(cliHost.path, workspaceFolder); const configFile: URI = URI.file(path.resolve(process.cwd(), configPath)); - const configs = await readDevContainerConfigFile(cliHost, workspace, configFile, false, output, undefined, undefined); + const configs = await readDevContainerConfigFile(cliHost, workspace, configFile, false, false, output, undefined, undefined); if (configFile && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) not found.` }); diff --git a/src/spec-node/featuresCLI/testCommandImpl.ts b/src/spec-node/featuresCLI/testCommandImpl.ts index 558162443..fc9f092fb 100644 --- a/src/spec-node/featuresCLI/testCommandImpl.ts +++ b/src/spec-node/featuresCLI/testCommandImpl.ts @@ -553,6 +553,7 @@ async function launchProject(params: DockerResolverParameters, workspaceFolder: additionalLabels: [], logLevel: common.getLogLevel(), mountWorkspaceGitRoot: true, + mountGitWorktreeCommonDir: false, remoteEnv: common.remoteEnv, skipFeatureAutoMapping: common.skipFeatureAutoMapping, skipPersistingCustomizationsFromFeatures: common.skipPersistingCustomizationsFromFeatures, @@ -632,6 +633,7 @@ async function generateDockerParams(workspaceFolder: string, args: FeaturesTestC containerDataFolder: undefined, containerSystemDataFolder: undefined, mountWorkspaceGitRoot: false, + mountGitWorktreeCommonDir: false, configFile: undefined, overrideConfigFile: undefined, logLevel, diff --git a/src/spec-node/featuresCLI/utils.ts b/src/spec-node/featuresCLI/utils.ts index d72cb1705..c981a4c2e 100644 --- a/src/spec-node/featuresCLI/utils.ts +++ b/src/spec-node/featuresCLI/utils.ts @@ -40,6 +40,7 @@ export const staticExecParams = { 'terminal-columns': undefined, 'container-id': undefined, 'mount-workspace-git-root': true, + 'mount-git-worktree-common-dir': false, 'log-level': 'info' as 'info', 'log-format': 'text' as 'text', 'default-user-env-probe': 'loginInteractiveShell' as 'loginInteractiveShell', diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 07d111cc2..2f4a8a3fc 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -51,7 +51,7 @@ export async function openDockerfileDevContainer(params: DockerResolverParameter // collapsedFeaturesConfig = async () => res.collapsedFeaturesConfig; try { - await spawnDevContainer(params, config, mergedConfig, updatedImageName, idLabels, workspaceConfig.workspaceMount, res.imageDetails, containerUser, res.labels || {}); + await spawnDevContainer(params, config, mergedConfig, updatedImageName, idLabels, workspaceConfig.workspaceMount, workspaceConfig.additionalMountString, res.imageDetails, containerUser, res.labels || {}); } finally { // In 'finally' because 'docker run' can fail after creating the container. // Trying to get it here, so we can offer 'Rebuild Container' as an action later. @@ -348,7 +348,7 @@ export async function extraRunArgs(common: ResolverParameters, params: DockerRes return extraArguments; } -export async function spawnDevContainer(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageName: string, labels: string[], workspaceMount: string | undefined, imageDetails: () => Promise, containerUser: string | undefined, extraLabels: Record) { +export async function spawnDevContainer(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageName: string, labels: string[], workspaceMount: string | undefined, additionalMountString: string | undefined, imageDetails: () => Promise, containerUser: string | undefined, extraLabels: Record) { const { common } = params; common.progress(ResolverProgress.StartingContainer); @@ -357,6 +357,7 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const exposed = ([]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port])); const cwdMount = workspaceMount ? ['--mount', workspaceMount] : []; + const additionalMount = additionalMountString ? ['--mount', additionalMountString] : []; const envObj = mergedConfig.containerEnv || {}; const containerEnv = Object.keys(envObj) @@ -409,6 +410,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t '-a', 'STDERR', ...exposed, ...cwdMount, + ...additionalMount, ...featureMounts, ...getLabels(labels), ...containerEnv, diff --git a/src/spec-node/upgradeCommand.ts b/src/spec-node/upgradeCommand.ts index 3336087c7..8e660136f 100644 --- a/src/spec-node/upgradeCommand.ts +++ b/src/spec-node/upgradeCommand.ts @@ -193,7 +193,7 @@ function upgradeFeatureKeyInConfig(configText: string, current: string, updated: } async function getConfig(configPath: URI | undefined, cliHost: CLIHost, workspace: Workspace, output: Log, configFile: URI | undefined): Promise { - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, output) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, false, output) || undefined; if (!configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 902888866..1fb1ec1cc 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -107,6 +107,7 @@ export interface DockerResolverParameters { workspaceMountConsistencyDefault: BindMountConsistency; gpuAvailability: GPUAvailability; mountWorkspaceGitRoot: boolean; + mountGitWorktreeCommonDir: boolean; updateRemoteUserUIDOnMacOS: boolean; cacheMount: 'volume' | 'bind' | 'none'; removeOnStartup?: boolean | string; @@ -347,24 +348,58 @@ export async function getHostMountFolder(cliHost: CLIHost, folderPath: string, m export interface WorkspaceConfiguration { workspaceMount: string | undefined; workspaceFolder: string | undefined; + additionalMountString: string | undefined; } -export async function getWorkspaceConfiguration(cliHost: CLIHost, workspace: Workspace | undefined, config: DevContainerConfig, mountWorkspaceGitRoot: boolean, output: Log, consistency?: BindMountConsistency): Promise { +export async function getWorkspaceConfiguration(cliHost: CLIHost, workspace: Workspace | undefined, config: DevContainerConfig, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency): Promise { if ('dockerComposeFile' in config) { return { workspaceFolder: getRemoteWorkspaceFolder(config), workspaceMount: undefined, + additionalMountString: undefined, }; } let { workspaceFolder, workspaceMount } = config; + let additionalMountString: string | undefined; if (workspace && (!workspaceFolder || !('workspaceMount' in config))) { const hostMountFolder = await getHostMountFolder(cliHost, workspace.rootFolderPath, mountWorkspaceGitRoot, output); + + // Check if .git is a file (worktree) with a relative gitdir path + let containerMountFolder = path.posix.join('/workspaces', cliHost.path.basename(hostMountFolder)); + if (mountWorkspaceGitRoot && mountGitWorktreeCommonDir) { + const dotGitPath = cliHost.path.join(hostMountFolder, '.git'); + if (await cliHost.isFile(dotGitPath)) { + const dotGitContent = (await cliHost.readFile(dotGitPath)).toString(); + const match = /^gitdir:\s*(.+)$/m.exec(dotGitContent); + if (match) { + const gitdir = match[1]; + // Only handle if gitdir is a relative path + if (!cliHost.path.isAbsolute(gitdir)) { + // gitdir points to .git/worktrees//, common dir is .git/ (two levels up) + const gitCommonDir = cliHost.path.resolve(hostMountFolder, gitdir, '..', '..'); + // Collect path segments from hostMountFolder up to the parent of gitCommonDir + const segments: string[] = []; + for (let current = hostMountFolder; !gitCommonDir.startsWith(current + cliHost.path.sep) && current !== cliHost.path.dirname(current); current = cliHost.path.dirname(current)) { + segments.unshift(cliHost.path.basename(current)); + } + containerMountFolder = path.posix.join('/workspaces', ...segments); + // 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 cons = cliHost.platform !== 'linux' ? `,consistency=${consistency || 'consistent'}` : ''; + const srcQuote = gitCommonDir.indexOf(',') !== -1 ? '"' : ''; + const tgtQuote = containerGitCommonDir.indexOf(',') !== -1 ? '"' : ''; + additionalMountString = `type=bind,${srcQuote}source=${gitCommonDir}${srcQuote},${tgtQuote}target=${containerGitCommonDir}${tgtQuote}${cons}`; + } + } + } + } + if (!workspaceFolder) { - const rel = cliHost.path.relative(cliHost.path.dirname(hostMountFolder), workspace.rootFolderPath); - workspaceFolder = `/workspaces/${cliHost.platform === 'win32' ? rel.replace(/\\/g, '/') : rel}`; + const rel = cliHost.path.relative(hostMountFolder, workspace.rootFolderPath); + workspaceFolder = path.posix.join(containerMountFolder, cliHost.platform === 'win32' ? rel.replace(/\\/g, '/') : rel); } if (!('workspaceMount' in config)) { - const containerMountFolder = `/workspaces/${cliHost.path.basename(hostMountFolder)}`; const cons = cliHost.platform !== 'linux' ? `,consistency=${consistency || 'consistent'}` : ''; // Podman does not tolerate consistency= const srcQuote = hostMountFolder.indexOf(',') !== -1 ? '"' : ''; const tgtQuote = containerMountFolder.indexOf(',') !== -1 ? '"' : ''; @@ -374,6 +409,7 @@ export async function getWorkspaceConfiguration(cliHost: CLIHost, workspace: Wor return { workspaceFolder, workspaceMount, + additionalMountString, }; } diff --git a/src/test/workspaceConfiguration.test.ts b/src/test/workspaceConfiguration.test.ts new file mode 100644 index 000000000..1e0b7e992 --- /dev/null +++ b/src/test/workspaceConfiguration.test.ts @@ -0,0 +1,506 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import * as path from 'path'; +import { getWorkspaceConfiguration } from '../spec-node/utils'; +import { CLIHost } from '../spec-common/cliHost'; +import { Workspace } from '../spec-utils/workspaces'; +import { nullLog } from '../spec-utils/log'; + +function createMockCLIHost(options: { + platform: NodeJS.Platform; + files?: Record; + useFileHost?: boolean; // Use FileHost path in findGitRootFolder (for testing parent folder git root) +}): CLIHost { + const { platform, files = {}, useFileHost = false } = options; + const pathModule = platform === 'win32' ? path.win32 : path.posix; + const baseHost = { + type: 'local' as const, + platform, + arch: 'x64' as const, + path: pathModule, + cwd: platform === 'win32' ? 'C:\\' : '/', + env: {}, + ptyExec: () => { throw new Error('Not implemented'); }, + homedir: async () => platform === 'win32' ? 'C:\\Users\\test' : '/home/test', + tmpdir: async () => platform === 'win32' ? 'C:\\tmp' : '/tmp', + isFile: async (filepath: string) => filepath in files, + isFolder: async () => false, + readFile: async (filepath: string) => { + if (filepath in files) { + return Buffer.from(files[filepath]); + } + throw new Error(`File not found: ${filepath}`); + }, + writeFile: async () => { }, + rename: async () => { }, + mkdirp: async () => { }, + readDir: async () => [], + getUsername: async () => 'test', + toCommonURI: async () => undefined, + connect: () => { throw new Error('Not implemented'); }, + }; + // If useFileHost is true, don't include exec so findGitRootFolder uses the FileHost code path + if (useFileHost) { + return baseHost as unknown as CLIHost; + } + return { + ...baseHost, + exec: () => { throw new Error('Not implemented'); }, + } as CLIHost; +} + +function createWorkspace(rootFolderPath: string, configFolderPath?: string): Workspace { + return { + isWorkspaceFile: false, + workspaceOrFolderPath: rootFolderPath, + rootFolderPath, + configFolderPath: configFolderPath || rootFolderPath, + }; +} + +type TestPlatform = 'linux' | 'darwin' | 'win32'; +const platforms: TestPlatform[] = ['linux', 'darwin', 'win32']; + +describe('getWorkspaceConfiguration', function () { + + for (const platform of platforms) { + describe(`platform: ${platform}`, function () { + + describe('basic workspace mounting', function () { + + it('should mount workspace at /workspaces/', async () => { + const p = { + linux: { projectPath: '/home/user/project', consistency: '' }, + darwin: { projectPath: '/Users/user/project', consistency: ',consistency=consistent' }, + win32: { projectPath: 'C:\\Users\\user\\project', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ platform }); + const workspace = createWorkspace(p.projectPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + false, + false, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/project'); + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.projectPath},target=/workspaces/project${p.consistency}`); + assert.isUndefined(result.additionalMountString); + }); + + }); + + describe('git worktree handling', function () { + + it('should not add additional mount when .git is not a 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, + {}, + true, + true, + nullLog + ); + + assert.isUndefined(result.additionalMountString); + }); + + it('should not add additional mount when gitdir is an absolute path', async () => { + const p = { + linux: { projectPath: '/home/user/project', gitFile: '/home/user/project/.git', absoluteGitdir: 'gitdir: /absolute/path/to/.git/worktrees/project' }, + darwin: { projectPath: '/Users/user/project', gitFile: '/Users/user/project/.git', absoluteGitdir: 'gitdir: /absolute/path/to/.git/worktrees/project' }, + win32: { projectPath: 'C:\\Users\\user\\project', gitFile: 'C:\\Users\\user\\project\\.git', absoluteGitdir: 'gitdir: C:/absolute/path/to/.git/worktrees/project' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitFile]: p.absoluteGitdir + } + }); + const workspace = createWorkspace(p.projectPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + true, + true, + nullLog + ); + + assert.isUndefined(result.additionalMountString); + }); + + it('should not add additional mount when mountGitWorktreeCommonDir is false', async () => { + const p = { + linux: { worktreePath: '/home/user/worktrees/feature', gitFile: '/home/user/worktrees/feature/.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', consistency: '' }, + darwin: { worktreePath: '/Users/user/worktrees/feature', gitFile: '/Users/user/worktrees/feature/.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\worktrees\\feature', gitFile: 'C:\\Users\\user\\worktrees\\feature\\.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitFile]: p.gitdir + } + }); + const workspace = createWorkspace(p.worktreePath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + true, + false, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/feature'); + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.worktreePath},target=/workspaces/feature${p.consistency}`); + assert.isUndefined(result.additionalMountString); + }); + + it('should not add additional mount when mountGitWorktreeCommonDir is false with workspace in subfolder', async () => { + const p = { + linux: { worktreePath: '/home/user/worktrees/feature', gitConfigFile: '/home/user/worktrees/feature/.git/config', gitFile: '/home/user/worktrees/feature/.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', subfolderPath: '/home/user/worktrees/feature/packages/app', consistency: '' }, + darwin: { worktreePath: '/Users/user/worktrees/feature', gitConfigFile: '/Users/user/worktrees/feature/.git/config', gitFile: '/Users/user/worktrees/feature/.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', subfolderPath: '/Users/user/worktrees/feature/packages/app', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\worktrees\\feature', gitConfigFile: 'C:\\Users\\user\\worktrees\\feature\\.git\\config', gitFile: 'C:\\Users\\user\\worktrees\\feature\\.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', subfolderPath: 'C:\\Users\\user\\worktrees\\feature\\packages\\app', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitConfigFile]: '[core]', + [p.gitFile]: p.gitdir + }, + useFileHost: true + }); + const workspace = createWorkspace(p.subfolderPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + true, + false, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/feature/packages/app'); + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.worktreePath},target=/workspaces/feature${p.consistency}`); + assert.isUndefined(result.additionalMountString); + }); + + it('should add additional mount when gitdir is a relative path', async () => { + const p = { + linux: { worktreePath: '/home/user/worktrees/feature', gitFile: '/home/user/worktrees/feature/.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', repoGitPath: '/home/user/repo/.git', consistency: '' }, + darwin: { worktreePath: '/Users/user/worktrees/feature', gitFile: '/Users/user/worktrees/feature/.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', repoGitPath: '/Users/user/repo/.git', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\worktrees\\feature', gitFile: 'C:\\Users\\user\\worktrees\\feature\\.git', gitdir: 'gitdir: ../../repo/.git/worktrees/feature', repoGitPath: 'C:\\Users\\user\\repo\\.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, + {}, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/worktrees/feature'); + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.worktreePath},target=/workspaces/worktrees/feature${p.consistency}`); + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.repoGitPath},target=/workspaces/repo/.git${p.consistency}`); + }); + + it('should handle gitdir with single level up', async () => { + const p = { + linux: { worktreePath: '/home/user/repo-worktree', gitFile: '/home/user/repo-worktree/.git', gitdir: 'gitdir: ../repo/.git/worktrees/worktree', repoGitPath: '/home/user/repo/.git', consistency: '' }, + darwin: { worktreePath: '/Users/user/repo-worktree', gitFile: '/Users/user/repo-worktree/.git', gitdir: 'gitdir: ../repo/.git/worktrees/worktree', repoGitPath: '/Users/user/repo/.git', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\repo-worktree', gitFile: 'C:\\Users\\user\\repo-worktree\\.git', gitdir: 'gitdir: ../repo/.git/worktrees/worktree', repoGitPath: 'C:\\Users\\user\\repo\\.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, + {}, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/repo-worktree'); + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.repoGitPath},target=/workspaces/repo/.git${p.consistency}`); + }); + + it('should handle worktree two levels deep from common parent with main repo', async () => { + const p = { + linux: { worktreePath: '/home/user/projects/worktrees/feature', gitFile: '/home/user/projects/worktrees/feature/.git', gitdir: 'gitdir: ../../repos/main/.git/worktrees/feature', repoGitPath: '/home/user/projects/repos/main/.git', consistency: '' }, + darwin: { worktreePath: '/Users/user/projects/worktrees/feature', gitFile: '/Users/user/projects/worktrees/feature/.git', gitdir: 'gitdir: ../../repos/main/.git/worktrees/feature', repoGitPath: '/Users/user/projects/repos/main/.git', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\projects\\worktrees\\feature', gitFile: 'C:\\Users\\user\\projects\\worktrees\\feature\\.git', gitdir: 'gitdir: ../../repos/main/.git/worktrees/feature', repoGitPath: 'C:\\Users\\user\\projects\\repos\\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, + {}, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/worktrees/feature'); + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.worktreePath},target=/workspaces/worktrees/feature${p.consistency}`); + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.repoGitPath},target=/workspaces/repos/main/.git${p.consistency}`); + }); + + it('should handle worktree two levels deep with workspace in subfolder', async () => { + const p = { + linux: { worktreePath: '/home/user/projects/worktrees/feature', gitConfigFile: '/home/user/projects/worktrees/feature/.git/config', gitFile: '/home/user/projects/worktrees/feature/.git', gitdir: 'gitdir: ../../repos/main/.git/worktrees/feature', subfolderPath: '/home/user/projects/worktrees/feature/packages/app', repoGitPath: '/home/user/projects/repos/main/.git', consistency: '' }, + darwin: { worktreePath: '/Users/user/projects/worktrees/feature', gitConfigFile: '/Users/user/projects/worktrees/feature/.git/config', gitFile: '/Users/user/projects/worktrees/feature/.git', gitdir: 'gitdir: ../../repos/main/.git/worktrees/feature', subfolderPath: '/Users/user/projects/worktrees/feature/packages/app', repoGitPath: '/Users/user/projects/repos/main/.git', consistency: ',consistency=consistent' }, + win32: { worktreePath: 'C:\\Users\\user\\projects\\worktrees\\feature', gitConfigFile: 'C:\\Users\\user\\projects\\worktrees\\feature\\.git\\config', gitFile: 'C:\\Users\\user\\projects\\worktrees\\feature\\.git', gitdir: 'gitdir: ../../repos/main/.git/worktrees/feature', subfolderPath: 'C:\\Users\\user\\projects\\worktrees\\feature\\packages\\app', repoGitPath: 'C:\\Users\\user\\projects\\repos\\main\\.git', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitConfigFile]: '[core]', + [p.gitFile]: p.gitdir + }, + useFileHost: true + }); + const workspace = createWorkspace(p.subfolderPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/worktrees/feature/packages/app'); + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.worktreePath},target=/workspaces/worktrees/feature${p.consistency}`); + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.repoGitPath},target=/workspaces/repos/main/.git${p.consistency}`); + }); + + }); + + describe('git root in parent folder', function () { + + it('should mount from git root when .git/config is in parent folder', async () => { + const p = { + linux: { repoPath: '/home/user/repo', gitConfigFile: '/home/user/repo/.git/config', subfolderPath: '/home/user/repo/packages/frontend', consistency: '' }, + darwin: { repoPath: '/Users/user/repo', gitConfigFile: '/Users/user/repo/.git/config', subfolderPath: '/Users/user/repo/packages/frontend', consistency: ',consistency=consistent' }, + win32: { repoPath: 'C:\\Users\\user\\repo', gitConfigFile: 'C:\\Users\\user\\repo\\.git\\config', subfolderPath: 'C:\\Users\\user\\repo\\packages\\frontend', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitConfigFile]: '[core]' + }, + useFileHost: true + }); + const workspace = createWorkspace(p.subfolderPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.repoPath},target=/workspaces/repo${p.consistency}`); + assert.strictEqual(result.workspaceFolder, '/workspaces/repo/packages/frontend'); + assert.isUndefined(result.additionalMountString); + }); + + it('should mount workspace folder when mountWorkspaceGitRoot is false even with .git in parent', async () => { + const p = { + linux: { gitConfigFile: '/home/user/repo/.git/config', subfolderPath: '/home/user/repo/packages/frontend', consistency: '' }, + darwin: { gitConfigFile: '/Users/user/repo/.git/config', subfolderPath: '/Users/user/repo/packages/frontend', consistency: ',consistency=consistent' }, + win32: { gitConfigFile: 'C:\\Users\\user\\repo\\.git\\config', subfolderPath: 'C:\\Users\\user\\repo\\packages\\frontend', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitConfigFile]: '[core]' + }, + useFileHost: true + }); + const workspace = createWorkspace(p.subfolderPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + false, + false, + nullLog + ); + + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.subfolderPath},target=/workspaces/frontend${p.consistency}`); + assert.strictEqual(result.workspaceFolder, '/workspaces/frontend'); + }); + + it('should handle deeply nested workspace in git repo', async () => { + const p = { + linux: { monorepoPath: '/home/user/monorepo', gitConfigFile: '/home/user/monorepo/.git/config', subfolderPath: '/home/user/monorepo/packages/apps/web', consistency: '' }, + darwin: { monorepoPath: '/Users/user/monorepo', gitConfigFile: '/Users/user/monorepo/.git/config', subfolderPath: '/Users/user/monorepo/packages/apps/web', consistency: ',consistency=consistent' }, + win32: { monorepoPath: 'C:\\Users\\user\\monorepo', gitConfigFile: 'C:\\Users\\user\\monorepo\\.git\\config', subfolderPath: 'C:\\Users\\user\\monorepo\\packages\\apps\\web', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitConfigFile]: '[core]' + }, + useFileHost: true + }); + const workspace = createWorkspace(p.subfolderPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceMount, `type=bind,source=${p.monorepoPath},target=/workspaces/monorepo${p.consistency}`); + assert.strictEqual(result.workspaceFolder, '/workspaces/monorepo/packages/apps/web'); + }); + + it('should handle worktree with git root in parent folder', async () => { + const p = { + linux: { repoPath: '/home/user/repo', gitConfigFile: '/home/user/repo/.git/config', gitFile: '/home/user/repo/.git', gitdir: 'gitdir: ../main-repo/.git/worktrees/repo', mainRepoGitPath: '/home/user/main-repo/.git', subfolderPath: '/home/user/repo/packages/lib', consistency: '' }, + darwin: { repoPath: '/Users/user/repo', gitConfigFile: '/Users/user/repo/.git/config', gitFile: '/Users/user/repo/.git', gitdir: 'gitdir: ../main-repo/.git/worktrees/repo', mainRepoGitPath: '/Users/user/main-repo/.git', subfolderPath: '/Users/user/repo/packages/lib', consistency: ',consistency=consistent' }, + win32: { repoPath: 'C:\\Users\\user\\repo', gitConfigFile: 'C:\\Users\\user\\repo\\.git\\config', gitFile: 'C:\\Users\\user\\repo\\.git', gitdir: 'gitdir: ../main-repo/.git/worktrees/repo', mainRepoGitPath: 'C:\\Users\\user\\main-repo\\.git', subfolderPath: 'C:\\Users\\user\\repo\\packages\\lib', consistency: ',consistency=consistent' }, + }[platform]; + + const cliHost = createMockCLIHost({ + platform, + files: { + [p.gitConfigFile]: '[core]', + [p.gitFile]: p.gitdir + }, + useFileHost: true + }); + const workspace = createWorkspace(p.subfolderPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + {}, + true, + true, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/workspaces/repo/packages/lib'); + assert.strictEqual(result.additionalMountString, `type=bind,source=${p.mainRepoGitPath},target=/workspaces/main-repo/.git${p.consistency}`); + }); + + }); + + describe('config overrides', function () { + + it('should use workspaceFolder from config when provided', async () => { + const p = { + linux: { projectPath: '/home/user/project' }, + darwin: { projectPath: '/Users/user/project' }, + win32: { projectPath: 'C:\\Users\\user\\project' }, + }[platform]; + + const cliHost = createMockCLIHost({ platform }); + const workspace = createWorkspace(p.projectPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + { workspaceFolder: '/custom/path' }, + false, + false, + nullLog + ); + + assert.strictEqual(result.workspaceFolder, '/custom/path'); + }); + + it('should use workspaceMount from config when provided', async () => { + const p = { + linux: { projectPath: '/home/user/project' }, + darwin: { projectPath: '/Users/user/project' }, + win32: { projectPath: 'C:\\Users\\user\\project' }, + }[platform]; + + const cliHost = createMockCLIHost({ platform }); + const workspace = createWorkspace(p.projectPath); + + const result = await getWorkspaceConfiguration( + cliHost, + workspace, + { workspaceMount: 'type=bind,source=/custom,target=/workspace' }, + false, + false, + nullLog + ); + + assert.strictEqual(result.workspaceMount, 'type=bind,source=/custom,target=/workspace'); + }); + + }); + + }); + } + +});