feat: config profile multi-repo mounts
- Add mappings array support to git_mount entries - Clone repository once per git_mount entry, mount multiple subdirectories - Normalize legacy source_path+target_path to mappings on read - Update _merge_git_mounts to dedup by (remote_url, branch) and concatenate mappings - Add _normalize_git_mount, _clone_git_repo, _resolve_git_mount_mappings helpers - Update GitMountItem Pydantic model with GitMountMapping and model_validator - Update frontend GitMountEditor component with mappings UI - Auto-convert legacy git mount entries to mappings format on load - Add 15 backend unit tests for normalization, resolution, and glob expansion - Update existing config profile resolver tests for new merge behavior Quality gates: pytest 167 passed, frontend typecheck clean Addresses: config-profile-multi-repo-mounts
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
# Design: Config Profile Multi-Repo Mounts
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Model
|
||||
|
||||
No database changes. The `git_mounts` JSONB column already stores arbitrary JSON.
|
||||
|
||||
#### Normalized git mount schema (in memory)
|
||||
|
||||
After validation/normalization, every git mount entry is converted to the unified form:
|
||||
|
||||
```python
|
||||
{
|
||||
"remote_url": str,
|
||||
"branch": str | None,
|
||||
"mappings": [
|
||||
{"source_path": str, "target_path": str},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The normalization step converts legacy `source_path` + `target_path` into a single-entry `mappings` array.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
#### 1. `_resolve_single_git_mount` refactor
|
||||
|
||||
Split into two functions:
|
||||
|
||||
**`_clone_git_repo(remote_url, branch, clone_parent) -> repo_path`**
|
||||
- Clones or pulls the repo
|
||||
- Returns the path to `repo-clone`
|
||||
- Same as before, but extracts the clone logic
|
||||
|
||||
**`_resolve_git_mount_mappings(repo_path, mappings, working_directory) -> list[dict]`**
|
||||
- Takes the already-cloned repo path
|
||||
- For each mapping:
|
||||
1. Build source path: `os.path.join(repo_path, mapping["source_path"])`
|
||||
2. Expand globs via `_expand_glob_source`
|
||||
3. Resolve target path (absolute or relative to working_directory)
|
||||
4. Build volume mount entries
|
||||
- Returns list of volume mount dicts
|
||||
|
||||
**`_resolve_single_git_mount` new flow:**
|
||||
1. Validate entry (remote_url, mappings or source_path+target_path)
|
||||
2. Normalize legacy format to `mappings` array
|
||||
3. Compute clone directory (same as before: `git-mounts/{repo_name}-{hash}/`)
|
||||
4. Clone/pull repo
|
||||
5. Resolve all mappings from the cloned repo
|
||||
6. Return flat list of volume mounts
|
||||
|
||||
#### 2. `_merge_git_mounts` update
|
||||
|
||||
The merge key changes from `(remote_url, target_path)` to `(remote_url, branch)`.
|
||||
|
||||
When two entries have the same `remote_url` and `branch`, their `mappings` arrays are concatenated. When different, they are kept as separate entries.
|
||||
|
||||
```python
|
||||
def _merge_git_mounts(base, overlay, source_name):
|
||||
result = list(base)
|
||||
seen = {}
|
||||
for i, m in enumerate(result):
|
||||
key = (m["remote_url"], m.get("branch"))
|
||||
seen[key] = i
|
||||
|
||||
for mount in overlay:
|
||||
key = (mount["remote_url"], mount.get("branch"))
|
||||
if key in seen:
|
||||
# Same repo+branch: concatenate mappings
|
||||
result[seen[key]]["mappings"].extend(mount.get("mappings", []))
|
||||
else:
|
||||
seen[key] = len(result)
|
||||
result.append(dict(mount))
|
||||
return result
|
||||
```
|
||||
|
||||
#### 3. Validation on save
|
||||
|
||||
In the Config Profile API (create/update), validate `git_mounts`:
|
||||
- Each entry must have `remote_url`
|
||||
- Each entry must have either `mappings` OR (`source_path` AND `target_path`)
|
||||
- Each mapping must have `source_path` and `target_path`
|
||||
- `mappings` must be a non-empty array
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
#### GitMountEditor component
|
||||
|
||||
New or updated component for editing a single git mount entry:
|
||||
|
||||
```
|
||||
Remote URL: [____________________]
|
||||
Branch: [main________________]
|
||||
|
||||
Mappings:
|
||||
Source Path → Target Path
|
||||
[packages/api ] [/app/api ] [×]
|
||||
[packages/web ] [/app/web ] [×]
|
||||
[ ] [ ] [+ Add]
|
||||
```
|
||||
|
||||
**State shape:**
|
||||
```typescript
|
||||
interface GitMountMapping {
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
}
|
||||
|
||||
interface GitMountEntry {
|
||||
remote_url: string;
|
||||
branch?: string;
|
||||
mappings: GitMountMapping[];
|
||||
// Legacy fields (read-only for old data)
|
||||
source_path?: string;
|
||||
target_path?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Migration on load:** If an entry has `source_path` and `target_path` but no `mappings`, auto-convert:
|
||||
```typescript
|
||||
if (!entry.mappings && entry.source_path && entry.target_path) {
|
||||
entry.mappings = [{ source_path: entry.source_path, target_path: entry.target_path }];
|
||||
}
|
||||
```
|
||||
|
||||
### File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/api/src/api/tool_instances.py` | Refactor `_resolve_single_git_mount` to support mappings |
|
||||
| `apps/api/src/services/config_profile_resolver.py` | Update `_merge_git_mounts` merge key |
|
||||
| `apps/api/src/api/config_profiles.py` | Add validation for git_mounts schema |
|
||||
| `apps/web/src/components/config-profile-editor.tsx` | Add mappings UI for git mounts |
|
||||
| `apps/web/src/api/config_profiles.ts` | Update types for GitMountEntry |
|
||||
| `apps/api/tests/unit/test_git_mounts.py` | New unit tests for multi-mapping resolution |
|
||||
| `apps/api/tests/unit/test_config_profile_resolver.py` | Update merge tests |
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
#### Backend unit tests
|
||||
|
||||
1. `_resolve_single_git_mount` with 3 mappings → single clone, 3 mounts
|
||||
2. `_resolve_single_git_mount` legacy format → single clone, 1 mount
|
||||
3. `_merge_git_mounts` same repo+branch → mappings concatenated
|
||||
4. `_merge_git_mounts` different repos → separate entries
|
||||
5. Validation: entry with neither mappings nor source_path → error
|
||||
6. Validation: mapping missing target_path → error
|
||||
|
||||
#### Integration tests
|
||||
|
||||
1. Create profile with 2 mappings from same repo → start instance → verify single clone directory
|
||||
2. Create profile with legacy format → start instance → verify backward compatibility
|
||||
|
||||
#### Frontend tests
|
||||
|
||||
1. GitMountEditor renders mappings list
|
||||
2. Adding a mapping updates state correctly
|
||||
3. Legacy entry auto-converts on load
|
||||
4. Save sends correct JSON shape
|
||||
|
||||
### Migration Plan
|
||||
|
||||
No database migration. Existing `git_mounts` JSON continues to work because:
|
||||
- The code normalizes legacy `source_path` + `target_path` to `mappings` on read
|
||||
- The frontend auto-converts on load
|
||||
- New saves use the `mappings` format
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
Since there is no schema change, rollback is just reverting the code. Existing profiles with the new `mappings` format will still parse correctly even with old code if we keep the normalization shim.
|
||||
Reference in New Issue
Block a user