Skip to content

Commit 7c7d1ca

Browse files
committed
Add extendsMergeMode for combine vs override extends merging
Optional extendsMergeMode on devcontainer.json selects image-metadata combine (default) or overlay-style override when resolving extends.
1 parent 2585113 commit 7c7d1ca

4 files changed

Lines changed: 47 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Notable changes.
44

55
## Unreleased
66

7-
- Add support for the `extends` keyword so one `devcontainer.json` can inherit another using the image metadata merge logic. (https://github.com/devcontainers/spec/issues/22, https://github.com/devcontainers/cli/pull/311)
7+
- Add support for the `extends` keyword so one `devcontainer.json` can inherit another, with optional `extendsMergeMode` (`combine` uses image metadata merge logic; `override` replaces arrays/scalars from the child). (https://github.com/devcontainers/spec/issues/22, https://github.com/devcontainers/cli/pull/311)
88

99
## August 2026
1010

‎src/spec-configuration/configuration.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type UserEnvProbe = 'none' | 'loginInteractiveShell' | 'interactiveShell'
2121

2222
export type DevContainerConfigCommand = 'initializeCommand' | 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand';
2323

24+
export type DevContainerExtendsMergeMode = 'combine' | 'override';
25+
2426
export interface HostGPURequirements {
2527
cores?: number;
2628
memory?: string;
@@ -74,6 +76,7 @@ export interface DevContainerFromImageConfig {
7476
overrideFeatureInstallOrder?: string[];
7577
hostRequirements?: HostRequirements;
7678
extends?: string;
79+
extendsMergeMode?: DevContainerExtendsMergeMode;
7780
customizations?: Record<string, any>;
7881
}
7982

@@ -112,6 +115,7 @@ export type DevContainerFromDockerfileConfig = {
112115
overrideFeatureInstallOrder?: string[];
113116
hostRequirements?: HostRequirements;
114117
extends?: string;
118+
extendsMergeMode?: DevContainerExtendsMergeMode;
115119
customizations?: Record<string, any>;
116120
} & (
117121
{
@@ -171,6 +175,7 @@ export interface DevContainerFromDockerComposeConfig {
171175
overrideFeatureInstallOrder?: string[];
172176
hostRequirements?: HostRequirements;
173177
extends?: string;
178+
extendsMergeMode?: DevContainerExtendsMergeMode;
174179
customizations?: Record<string, any>;
175180
}
176181

‎src/spec-node/configContainer.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,18 @@ async function readDevContainerConfigObject(cliHost: CLIHost, configUri: URI, se
9999
}
100100

101101
const extendsPath = updated.extends;
102+
const extendsMergeMode = updated.extendsMergeMode ?? 'combine';
102103
delete updated.extends;
104+
delete updated.extendsMergeMode;
103105
if (!extendsPath) {
104106
return updated;
105107
}
106108
if (typeof extendsPath !== 'string' || !extendsPath.trim()) {
107109
throw new ContainerError({ description: `"extends" in (${uriToFsPath(configUri, cliHost.platform)}) must be a relative path to a JSON or JSONC file.` });
108110
}
111+
if (extendsMergeMode !== 'combine' && extendsMergeMode !== 'override') {
112+
throw new ContainerError({ description: `"extendsMergeMode" in (${uriToFsPath(configUri, cliHost.platform)}) must be "combine" or "override".` });
113+
}
109114
if (cliHost.path.isAbsolute(extendsPath) || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(extendsPath)) {
110115
throw new ContainerError({ description: `"extends" in (${uriToFsPath(configUri, cliHost.platform)}) must be a relative path within the same repository.` });
111116
}
@@ -115,7 +120,7 @@ async function readDevContainerConfigObject(cliHost: CLIHost, configUri: URI, se
115120
if (!parent) {
116121
throw new ContainerError({ description: `Dev container config extended from (${uriToFsPath(configUri, cliHost.platform)}) was not found: ${uriToFsPath(parentUri, cliHost.platform)}.` });
117122
}
118-
return mergeDevContainerConfigs(parent, updated);
123+
return mergeDevContainerConfigs(parent, updated, extendsMergeMode);
119124
}
120125

121126
export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) {

‎src/spec-node/imageMetadata.ts‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { ContainerError } from '../spec-common/errors';
77
import { PlatformInfo } from '../spec-common/commonUtils';
88
import { LifecycleCommand, LifecycleHooksInstallMap } from '../spec-common/injectHeadless';
9-
import { DevContainerConfig, DevContainerConfigCommand, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostGPURequirements, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration';
9+
import { DevContainerConfig, DevContainerConfigCommand, DevContainerExtendsMergeMode, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostGPURequirements, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration';
1010
import { Feature, FeaturesConfig, Mount, parseMount, SchemaFeatureLifecycleHooks } from '../spec-configuration/containerFeaturesConfiguration';
1111
import { ContainerDetails, DockerCLIParameters, ImageDetails } from '../spec-shutdown/dockerUtils';
1212
import { Log, LogLevel } from '../spec-utils/log';
@@ -204,13 +204,18 @@ export function mergeConfiguration(config: DevContainerConfig, imageMetadata: Im
204204
* (https://containers.dev/implementors/spec/#merge-logic) so `extends` behaves the same as
205205
* combining a prebuilt image's metadata with a project's config.
206206
*/
207-
export function mergeDevContainerConfigs(base: DevContainerConfig, overlay: DevContainerConfig): DevContainerConfig {
207+
export function mergeDevContainerConfigs(base: DevContainerConfig, overlay: DevContainerConfig, extendsMergeMode: DevContainerExtendsMergeMode = 'combine'): DevContainerConfig {
208+
if (extendsMergeMode === 'override') {
209+
return mergeDevContainerConfigsOverride(base, overlay);
210+
}
211+
208212
const metadata: ImageMetadataEntry[] = [base, overlay];
209213
const merged = {
210214
...base,
211215
...overlay,
212216
} as DevContainerConfig;
213217
delete merged.extends;
218+
delete merged.extendsMergeMode;
214219

215220
if (base.init || overlay.init) {
216221
merged.init = true;
@@ -266,6 +271,34 @@ export function mergeDevContainerConfigs(base: DevContainerConfig, overlay: DevC
266271
return merged;
267272
}
268273

274+
/**
275+
* Overlay-style merge: scalars and arrays from the overlay replace the base when set;
276+
* object maps and `hostRequirements` are shallow-merged with overlay keys winning.
277+
*/
278+
function mergeDevContainerConfigsOverride(base: DevContainerConfig, overlay: DevContainerConfig): DevContainerConfig {
279+
const merged = {
280+
...base,
281+
...overlay,
282+
} as DevContainerConfig;
283+
delete merged.extends;
284+
delete merged.extendsMergeMode;
285+
286+
const remoteEnv = Object.assign({}, base.remoteEnv, overlay.remoteEnv);
287+
assignOrDelete(merged, 'remoteEnv', Object.keys(remoteEnv).length ? remoteEnv : undefined);
288+
const containerEnv = Object.assign({}, base.containerEnv, overlay.containerEnv);
289+
assignOrDelete(merged, 'containerEnv', Object.keys(containerEnv).length ? containerEnv : undefined);
290+
const portsAttributes = Object.assign({}, base.portsAttributes, overlay.portsAttributes);
291+
assignOrDelete(merged, 'portsAttributes', Object.keys(portsAttributes).length ? portsAttributes : undefined);
292+
const features = Object.assign({}, base.features, overlay.features);
293+
assignOrDelete(merged, 'features', Object.keys(features).length ? features : undefined);
294+
const customizations = Object.assign({}, base.customizations, overlay.customizations);
295+
assignOrDelete(merged, 'customizations', Object.keys(customizations).length ? customizations : undefined);
296+
const hostRequirements = Object.assign({}, base.hostRequirements, overlay.hostRequirements);
297+
assignOrDelete(merged, 'hostRequirements', Object.keys(hostRequirements).length ? hostRequirements : undefined);
298+
299+
return merged;
300+
}
301+
269302
function assignOrDelete<K extends keyof DevContainerConfig>(target: DevContainerConfig, key: K, value: DevContainerConfig[K] | undefined) {
270303
if (value !== undefined) {
271304
target[key] = value;

0 commit comments

Comments
 (0)