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:
Alex Blank
2026-05-28 23:35:22 +02:00
parent e20d94d6ba
commit 0e6521e433
13 changed files with 1354 additions and 289 deletions
@@ -0,0 +1,8 @@
name: config-profile-multi-repo-mounts
status: completed
phase: verify
parent: null
type: feature
description: Enable multiple source/target mappings per git mount entry in Config Profiles, cloning the repository only once per entry.
created_at: 2026-05-28
updated_at: 2026-05-28
@@ -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.
@@ -0,0 +1,120 @@
# Exploration: Config Profile Multi-Repo Mounts
## Problem
Config Profiles support `git_mounts` — cloning repositories and mounting them into containers. However, each `git_mount` entry clones **one** source path from **one** repo. If a user wants to mount multiple directories from the same repository (e.g., a monorepo), they must add multiple `git_mount` entries, which results in **cloning the same repository multiple times**.
### Current git_mount schema (one mapping per entry)
```json
{
"remote_url": "https://github.com/user/monorepo.git",
"source_path": "packages/backend",
"target_path": "/app/backend",
"branch": "main"
}
```
To mount 3 directories from the same monorepo, the profile needs 3 entries, each triggering a separate clone of the full repository.
## Pain Points
1. **Redundant clones**: Cloning the same repo N times wastes time and disk space.
2. **Slow instance startup**: Each clone adds 5-30 seconds depending on repo size.
3. **Inconsistent branch state**: Each entry independently checks out the branch — they could drift if the branch moves between clones.
4. **Poor monorepo support**: Monorepos are common; users expect to mount multiple packages.
## Scenarios
### Scenario A: Monorepo with multiple packages
User has a monorepo `corp/monorepo` with:
- `packages/api` → needs to be at `/app/api`
- `packages/web` → needs to be at `/app/web`
- `packages/shared` → needs to be at `/app/shared`
They want to mount all three into a single tool instance.
### Scenario B: Docs + Code sidecar
User wants to mount both:
- `src/``/workspace/src`
- `docs/``/workspace/docs`
from the same repo.
### Scenario C: Backward compatibility
Existing profiles with single `source_path`/`target_path` should continue working without migration.
## Design Directions
### Direction A: `mappings` array on git_mount entry
Add a `mappings` array to each git_mount entry. The repo is cloned once, and each mapping creates a separate bind mount.
```json
{
"remote_url": "https://github.com/user/monorepo.git",
"branch": "main",
"mappings": [
{"source_path": "packages/api", "target_path": "/app/api"},
{"source_path": "packages/web", "target_path": "/app/web"}
]
}
```
**Pros**:
- Clean, explicit grouping
- Single clone per `remote_url + branch` combo
- Easy to understand
- Backward-compatible: legacy `source_path` + `target_path` can be treated as a single-entry `mappings` array
**Cons**:
- Slightly more verbose JSON
- Frontend form needs a nested list UI
### Direction B: Auto-dedup by remote_url + branch
Keep the flat list format, but internally group entries by `remote_url + branch` and clone once.
```json
[
{"remote_url": "...", "source_path": "a", "target_path": "/a", "branch": "main"},
{"remote_url": "...", "source_path": "b", "target_path": "/b", "branch": "main"}
]
```
**Pros**:
- No schema change
- Transparent to users
**Cons**:
- Magic behavior (not obvious why clones are shared)
- Harder to reason about branch conflicts (what if same repo, different branches?)
- Frontend doesn't show the grouping
### Direction C: Repo references + mount definitions split
Split into two concepts:
1. `git_repos` — list of repos to clone (with branch)
2. `git_mounts` — reference a repo by name and specify source/target
**Pros**:
- Very explicit
- Supports advanced scenarios (SSH keys per repo)
**Cons**:
- Breaking schema change
- Overkill for the current use case
- Heavy migration burden
## Recommendation
**Direction A (mappings array)** with backward-compatibility shim:
- Add optional `mappings` field to git_mount entries
- If `mappings` is absent, treat `source_path` + `target_path` as a single mapping
- Clone once per `remote_url + branch`, apply all mappings from the same entry
- No migration needed for existing data
This balances clarity, functionality, and backward compatibility.
@@ -0,0 +1,50 @@
# Proposal: Config Profile Multi-Repo Mounts
## Context
Config Profiles allow users to mount external Git repositories into tool instances via `git_mounts`. Currently, each `git_mount` entry supports only **one** source_path → target_path mapping. If a user wants to mount multiple directories from the same repository (e.g., a monorepo), they must add multiple entries, each cloning the repository independently.
## Goal
Enable a single `git_mount` entry to declare **multiple** source/target mappings from the same repository, while cloning the repository only once per entry.
## Direction
**Direction A: `mappings` array with backward compatibility**
Add an optional `mappings` array to each `git_mount` entry. The repository is cloned once, and each mapping creates a separate bind mount from a subdirectory of the cloned repo.
If `mappings` is absent, the existing `source_path` + `target_path` fields are treated as a single mapping (backward-compatible).
## Acceptance Criteria
1. A `git_mount` entry can specify `mappings: [{"source_path": "...", "target_path": "..."}, ...]`
2. The repository is cloned **exactly once** per `git_mount` entry
3. Each mapping creates a separate Docker bind mount from the cloned repo subdirectory
4. Existing profiles with `source_path`/`target_path` continue working without migration
5. The Config Profile editor UI supports adding/removing mappings per git mount
6. Glob patterns are supported in `source_path` within mappings
7. Relative `target_path` values are resolved against `working_directory` as before
## Out of Scope
- Cross-entry repo deduplication (two separate git_mount entries with the same `remote_url` still clone twice)
- SSH key per-repo configuration (can be added later)
- Sparse checkout / partial clone optimization
- Mounting from non-Git sources
## Risks
| Risk | Mitigation |
|------|------------|
| Schema migration complexity | No migration needed — backward-compatible |
| Frontend UI complexity | Nested form with add/remove mapping buttons |
| Clone directory sharing race condition | Each entry gets its own clone directory (url_hash based) |
| Large monorepo clone time | Out of scope — full clone is existing behavior |
## Related Artifacts
- Exploration: `openspec/explorations/config-profile-multi-repo-mounts.md`
- Spec: `openspec/specs/config-profile-multi-repo-mounts.md`
- Design: `openspec/designs/config-profile-multi-repo-mounts.md`
- Tasks: `openspec/tasks/config-profile-multi-repo-mounts.md`
@@ -0,0 +1,119 @@
# Spec: Config Profile Multi-Repo Mounts
## Requirements
### Functional
1. **FR-1**: A `git_mount` entry MAY include a `mappings` array.
2. **FR-2**: Each item in `mappings` MUST have `source_path` and `target_path`.
3. **FR-3**: If `mappings` is absent, `source_path` and `target_path` at the entry level MUST be treated as a single mapping (backward compatibility).
4. **FR-4**: The repository MUST be cloned exactly once per `git_mount` entry.
5. **FR-5**: Each mapping MUST create a separate Docker bind mount.
6. **FR-6**: Glob patterns in `source_path` MUST be expanded per mapping.
7. **FR-7**: Relative `target_path` values MUST be resolved against `working_directory`.
8. **FR-8**: The merge logic for included profiles MUST deduplicate by `(remote_url, branch)` within a single resolved profile's `git_mounts` list.
### Non-Functional
1. **NFR-1**: No database schema migration required.
2. **NFR-2**: Existing API responses must remain backward-compatible.
3. **NFR-3**: Frontend type-check must pass without errors.
## API Contracts
### ConfigProfile model (git_mounts field)
```json
{
"git_mounts": [
{
"remote_url": "https://github.com/user/monorepo.git",
"branch": "main",
"mappings": [
{"source_path": "packages/api", "target_path": "/app/api"},
{"source_path": "packages/web", "target_path": "/app/web"}
]
},
{
"remote_url": "https://github.com/user/docs.git",
"source_path": ".",
"target_path": "/docs",
"branch": "main"
}
]
}
```
### Validation rules
1. `mappings` must be a non-empty array if present.
2. Each mapping must have `source_path` (string) and `target_path` (string).
3. Either `mappings` OR (`source_path` AND `target_path`) must be present.
4. `remote_url` must be a valid HTTPS or SSH Git URL.
## Database Schema
No changes. `git_mounts` is stored as JSONB in `config_profiles.git_mounts`.
## Scenarios
### Scenario 1: Monorepo with multiple packages
**Given** a Config Profile with:
```json
{"git_mounts": [{
"remote_url": "https://github.com/corp/monorepo.git",
"branch": "main",
"mappings": [
{"source_path": "packages/api", "target_path": "/app/api"},
{"source_path": "packages/web", "target_path": "/app/web"},
{"source_path": "packages/shared", "target_path": "/app/shared"}
]
}]}
```
**When** the profile is applied to an instance
**Then**:
1. `corp/monorepo` is cloned once to `git-mounts/monorepo-{hash}/repo-clone`
2. Three bind mounts are created:
- `{clone}/packages/api``/app/api`
- `{clone}/packages/web``/app/web`
- `{clone}/packages/shared``/app/shared`
### Scenario 2: Legacy single mapping (backward compatibility)
**Given** a Config Profile with:
```json
{"git_mounts": [{
"remote_url": "https://github.com/user/repo.git",
"source_path": "src",
"target_path": "/workspace/src",
"branch": "main"
}]}
```
**When** the profile is applied
**Then** the behavior is identical to before (single clone, single mount).
### Scenario 3: Glob expansion within mapping
**Given** a mapping with:
```json
{"source_path": "packages/*", "target_path": "/app/packages"}
```
**When** the repo is cloned and the glob is expanded
**Then** each matched directory is mounted as a separate bind mount with the relative path appended to the target:
- `{clone}/packages/api``/app/packages/api`
- `{clone}/packages/web``/app/packages/web`
## Test Strategy
1. Unit test `_resolve_single_git_mount` with `mappings` array
2. Unit test `_merge_git_mounts` with mappings deduplication
3. Integration test: profile with 3 mappings from same repo → verify single clone
4. Integration test: legacy profile without `mappings` → verify backward compatibility
5. Frontend unit test: GitMountEditor renders mappings form correctly
@@ -0,0 +1,132 @@
# Tasks: Config Profile Multi-Repo Mounts
## T1: Backend — Refactor git mount resolution for mappings
### T1.1: Normalize legacy git mount format
**File**: `apps/api/src/api/tool_instances.py`
- Add `_normalize_git_mount(entry: dict) -> dict` helper
- Converts `{"source_path": "...", "target_path": "..."}` to `{"mappings": [{...}]}`
- Call normalization at the start of `_resolve_single_git_mount`
### T1.2: Extract clone logic
**File**: `apps/api/src/api/tool_instances.py`
- Create `_clone_git_repo(remote_url, branch, clone_parent) -> str` function
- Move clone/pull logic from `_resolve_single_git_mount` into it
- Returns `repo_path` (path to `repo-clone`)
### T1.3: Resolve mappings from cloned repo
**File**: `apps/api/src/api/tool_instances.py`
- Create `_resolve_git_mount_mappings(repo_path, mappings, working_directory) -> list[dict]`
- Iterates over mappings, expands globs, resolves targets
- Returns flat list of volume mount dicts
### T1.4: Wire it together
**File**: `apps/api/src/api/tool_instances.py`
- Update `_resolve_single_git_mount` to:
1. Normalize entry
2. Clone repo once
3. Resolve all mappings
4. Return flat volume mounts
### T1.5: Update merge logic
**File**: `apps/api/src/services/config_profile_resolver.py`
- Change `_merge_git_mounts` merge key from `(remote_url, target_path)` to `(remote_url, branch)`
- When same repo+branch: concatenate `mappings` arrays
- When different: append as separate entry
### T1.6: Add validation
**File**: `apps/api/src/api/config_profiles.py`
- Validate `git_mounts` on create/update:
- `remote_url` required
- Either `mappings` (non-empty array) OR (`source_path` + `target_path`)
- Each mapping has `source_path` and `target_path`
### T1.7: Unit tests
**File**: `apps/api/tests/unit/test_git_mounts.py` (new)
- Test normalization: legacy → mappings
- Test single clone with 3 mappings → 3 volume mounts
- Test glob expansion within mapping
- Test relative target resolution
**File**: `apps/api/tests/unit/test_config_profile_resolver.py`
- Update merge tests for new dedup key
---
## T2: Frontend — Git mount mappings editor
### T2.1: Update types
**File**: `apps/web/src/api/config_profiles.ts`
- Add `GitMountMapping` interface
- Update `GitMountEntry` to have `mappings: GitMountMapping[]`
- Keep optional `source_path`/`target_path` for backward compat
### T2.2: Auto-convert legacy entries on load
**File**: `apps/web/src/components/config-profile-editor.tsx` or new `GitMountEditor.tsx`
- On loading a profile, normalize any git_mount entries that lack `mappings`
### T2.3: Build mappings UI
**File**: `apps/web/src/components/GitMountEditor.tsx` (new)
- Render table/list of mappings per git mount entry
- "Add mapping" button appends empty row
- "Remove" button deletes a mapping row
- Source path and target path inputs
### T2.4: Integrate into ConfigProfileEditor
**File**: `apps/web/src/components/config-profile-editor.tsx`
- Replace existing git_mounts flat form with GitMountEditor component
- Ensure save sends correct JSON shape
### T2.5: Frontend tests
**File**: `apps/web/src/components/GitMountEditor.test.tsx` (new)
- Render with 2 mappings
- Add mapping increases count
- Remove mapping decreases count
- Legacy entry auto-converts
---
## T3: Integration & Verification
### T3.1: Integration test
**File**: `apps/api/tests/integration/test_config_profiles_git_mounts.py` (new)
- Create profile with 2 mappings from same repo
- Start instance
- Verify single clone directory exists
- Verify 2 bind mounts in compose file
### T3.2: Manual verification
- Create a Config Profile with a monorepo git mount + 3 mappings
- Create and start a pi-agent instance with the profile
- Verify all 3 directories are mounted correctly
- Verify legacy profile (single mapping) still works
### T3.3: Typecheck & tests
```bash
cd apps/web && npm run typecheck
cd apps/api && pytest tests/unit/test_git_mounts.py tests/unit/test_config_profile_resolver.py
```
---
## Estimation
| Task | Effort | Files |
|------|--------|-------|
| T1.1-T1.4 | 2h | 1 |
| T1.5 | 1h | 1 |
| T1.6 | 1h | 1 |
| T1.7 | 2h | 2 |
| T2.1-T2.4 | 3h | 3 |
| T2.5 | 1h | 1 |
| T3.1-T3.3 | 2h | 2 |
| **Total** | **12h** | **11** |
## PR Strategy
**Single PR** (~400 lines estimated, within review budget):
- Backend changes (T1)
- Frontend changes (T2)
- Tests (T1.7, T2.5, T3.1)
All changes are tightly coupled (backend schema + frontend UI + tests) so a single PR is appropriate.