- 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
5.5 KiB
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:
{
"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:
- Build source path:
os.path.join(repo_path, mapping["source_path"]) - Expand globs via
_expand_glob_source - Resolve target path (absolute or relative to working_directory)
- Build volume mount entries
- Build source path:
- Returns list of volume mount dicts
_resolve_single_git_mount new flow:
- Validate entry (remote_url, mappings or source_path+target_path)
- Normalize legacy format to
mappingsarray - Compute clone directory (same as before:
git-mounts/{repo_name}-{hash}/) - Clone/pull repo
- Resolve all mappings from the cloned repo
- 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.
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
mappingsOR (source_pathANDtarget_path) - Each mapping must have
source_pathandtarget_path mappingsmust 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:
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:
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
_resolve_single_git_mountwith 3 mappings → single clone, 3 mounts_resolve_single_git_mountlegacy format → single clone, 1 mount_merge_git_mountssame repo+branch → mappings concatenated_merge_git_mountsdifferent repos → separate entries- Validation: entry with neither mappings nor source_path → error
- Validation: mapping missing target_path → error
Integration tests
- Create profile with 2 mappings from same repo → start instance → verify single clone directory
- Create profile with legacy format → start instance → verify backward compatibility
Frontend tests
- GitMountEditor renders mappings list
- Adding a mapping updates state correctly
- Legacy entry auto-converts on load
- 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_pathtomappingson read - The frontend auto-converts on load
- New saves use the
mappingsformat
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.