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:
@@ -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.
|
||||
Reference in New Issue
Block a user