Files
headquarter/openspec/explorations/mount-file-level-overlays.md
Alex Blank ea006b68c2 fix: mount config profile files individually instead of replacing directories
The previous sorting fix exposed a deeper bug: ResolvedMount always
mounted its staging directory as a single bind mount. When a config
profile mount targeted /workspace/x/y and contained a single file
z.json, the staging directory (containing only z.json) replaced the
ENTIRE /workspace/x/y directory, hiding all sibling files from git
repo mounts.

- Change apply_resolved_profile to mount each file individually:
  - source: staging_dir/relative_path
  - target: expanded_target/relative_path
  - Sibling files from other mounts are preserved.
  - Empty mounts produce no volume entries.

- Keep volume sorting (parent paths before child paths) which is
  still necessary for directory mounts and ensures parent dirs exist
  before file mounts inside them.

- Add 4 unit tests for file-level mount behavior.

Quality gates: pytest (218 passed, 6 pre-existing), tsc --noEmit (clean)
2026-05-29 11:58:50 +02:00

2.5 KiB

Exploration: File-Level Mount Overlays

Follow-up to Mount Specificity Ordering

The sorting fix (parent paths before child paths) was correct for directory mounts, but it exposed a deeper bug: ResolvedMount always mounts its staging directory as a single bind mount. When a config profile mount targets /workspace/x/y and contains a single file z.json, the staging directory (containing only z.json) replaces the ENTIRE /workspace/x/y directory, hiding all sibling files from the git repo.

Root Cause

In apply_resolved_profile:

volume_mounts.append({
    "source": str(mount_dir),      # staging dir with ONLY z.json
    "target": expanded_target,     # /workspace/x/y
    "type": "bind",
})

This mounts a directory. Docker bind mounts at a directory path completely replace the target directory. There is no merge.

What the User Expects

Git repo mount: /repo/x/workspace/x (directory with many files) Config profile mount: z.json/workspace/x/y/z.json (single file overlay)

Expected: /workspace/x/y/ contains all repo files PLUS the overlaid z.json. Actual (before sorting): parent mount hides child mount (child never visible). Actual (after sorting): child directory mount replaces parent subdirectory (/workspace/x/y now contains ONLY z.json).

Solution

Mount each file individually instead of the staging directory.

for file_path, content in mount.files.items():
    full_path = mount_dir / file_path
    full_path.write_text(content)
    volume_mounts.append({
        "source": str(full_path),
        "target": os.path.join(expanded_target, file_path),
        "type": "bind",
    })

This creates file-level bind mounts. Docker mounts a single file without affecting sibling files in the parent directory.

Edge Cases

  • Empty files dict: skip, no mounts created.
  • Nested file paths (a/b/c.txt): mount staging/a/b/c.txttarget/a/b/c.txt. Docker creates parent directories as needed.
  • File target already exists in git repo: file mount wins (desired override behavior).
  • No git repo mount (directory doesn't exist in image): Docker creates parent dirs for the first file mount.

Sorting Fix Status

KEEP the sorting. It is still correct and necessary for cases where directory mounts genuinely override parent directories (e.g., a git mount to /workspace/x and another git mount to /workspace/x/sub). File-level mounts also benefit from sorting because the parent directory mount must exist before file mounts inside it.