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)
This commit is contained in:
Alex Blank
2026-05-29 11:58:50 +02:00
parent 202533fbb1
commit ea006b68c2
3 changed files with 156 additions and 7 deletions
@@ -0,0 +1,68 @@
# 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`:
```python
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.
```python
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.txt``target/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.