Files
headquarter/openspec/designs/config-profile-multi-repo-mounts.md
T
Alex Blank 0e6521e433 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
2026-05-28 23:35:22 +02:00

5.5 KiB
Raw Blame History

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:
    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.

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:

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

  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.