Merge feat/config-profile-multi-repo-mounts into dev

This commit is contained in:
Alex Blank
2026-05-28 23:35:29 +02:00
13 changed files with 1354 additions and 289 deletions
+50 -13
View File
@@ -2,9 +2,10 @@
import logging import logging
import uuid import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -56,18 +57,9 @@ def _calculate_profile_size(data: dict) -> int:
return total return total
class GitMountItem(BaseModel): class GitMountMapping(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)") source_path: str = Field(description="Path within repository (supports glob patterns)")
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
target_path: str = Field(description="Absolute path inside container") target_path: str = Field(description="Absolute path inside container")
branch: str | None = Field(default=None, description="Optional branch or tag name")
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
return v
@field_validator("source_path") @field_validator("source_path")
@classmethod @classmethod
@@ -86,6 +78,51 @@ class GitMountItem(BaseModel):
return v return v
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str | None = Field(default=None, description="Path within repository (legacy single mapping)")
target_path: str | None = Field(default=None, description="Absolute path inside container (legacy single mapping)")
branch: str | None = Field(default=None, description="Optional branch or tag name")
mappings: list[GitMountMapping] | None = Field(default=None, description="Multiple source/target mappings from the same repo")
@field_validator("remote_url")
@classmethod
def validate_remote_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://", "git@", "ssh://")):
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
return v
@field_validator("source_path")
@classmethod
def validate_source_path(cls, v: str | None) -> str | None:
if v is None:
return v
if v.startswith("/"):
raise ValueError("source_path must be relative (no leading /)")
if ".." in v:
raise ValueError("source_path cannot contain path traversal (..)")
return v
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if ".." in v:
raise ValueError("target_path cannot contain path traversal (..)")
return v
@model_validator(mode="after")
def check_mappings_or_legacy(self):
has_legacy = self.source_path is not None and self.target_path is not None
has_mappings = self.mappings is not None and len(self.mappings) > 0
if not has_legacy and not has_mappings:
raise ValueError(
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
)
return self
class MountItem(BaseModel): class MountItem(BaseModel):
target: str = Field(description="Absolute mount target path") target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw") mode: str = Field(default="rw", description="Mount mode: ro or rw")
@@ -263,7 +300,7 @@ async def _check_access(
async def _validate_git_mounts( async def _validate_git_mounts(
session: AsyncSession, session: AsyncSession,
user_id: uuid.UUID, user_id: uuid.UUID,
git_mounts: list[dict], git_mounts: list[Any],
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
) -> None: ) -> None:
"""Validate git mount URLs. """Validate git mount URLs.
+155 -99
View File
@@ -114,6 +114,148 @@ async def _resolve_git_mounts(
return volume_mounts return volume_mounts
def _normalize_git_mount(entry: dict) -> dict:
"""Normalize a git mount entry to the unified mappings format.
Converts legacy source_path + target_path into a single-entry mappings array.
"""
entry = dict(entry)
if "mappings" not in entry or not entry.get("mappings"):
source = entry.get("source_path", ".")
target = entry.get("target_path")
if target is not None:
entry["mappings"] = [{"source_path": source, "target_path": target}]
entry.pop("source_path", None)
entry.pop("target_path", None)
return entry
def _clone_git_repo(
remote_url: str,
branch: str | None,
clone_parent: str,
) -> str:
"""Clone or pull a git repository.
Returns the path to the cloned repo (repo-clone directory).
"""
import hashlib
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
repo_path = os.path.join(clone_dir, "repo-clone")
if not os.path.exists(repo_path):
try:
os.makedirs(clone_dir, exist_ok=True)
repo_path = clone_repository(
remote_url,
None, # No SSH key for now - can be added later
clone_dir,
branch or "main",
)
logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path)
except Exception as exc:
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
raise
else:
# Repo exists - pull latest updates
try:
_pull_repository_updates(repo_path, remote_url)
logger.debug("Pulled updates for git mount %s", remote_url)
except Exception as exc:
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
# Handle branch checkout if specified
if branch and repo_path:
success = _checkout_branch(repo_path, branch)
if success:
logger.debug("Checked out branch %s for %s", branch, remote_url)
else:
logger.warning(
"Branch %s not found in %s, using current branch", branch, remote_url
)
return repo_path
def _resolve_git_mount_mappings(
repo_path: str,
mappings: list[dict],
working_directory: str | None,
) -> list[dict]:
"""Resolve mappings from an already-cloned repo to volume mount entries.
Returns a flat list of volume mount dicts.
"""
volume_mounts = []
for mapping in mappings:
source_path = mapping.get("source_path", ".")
target_path = mapping.get("target_path")
if not target_path:
logger.warning("Invalid mapping skipped: missing target_path")
continue
# Resolve relative target paths against working directory
final_target = target_path
if not target_path.startswith("/"):
if not working_directory:
logger.warning(
"Git mount skipped: target_path '%s' is relative but no working_directory is configured. "
"Set working_directory in the tool config or use an absolute path.",
target_path,
)
continue
final_target = os.path.join(working_directory, target_path)
# Build source path and expand globs
if source_path and source_path != ".":
source_full = os.path.join(repo_path, source_path)
else:
source_full = repo_path
# Expand glob patterns
matched_paths = _expand_glob_source(source_full, repo_path)
if not matched_paths:
logger.warning(
"Git mount skipped: no files matched source path %s in repo",
source_path,
)
continue
for matched_path in matched_paths:
if not os.path.exists(matched_path):
continue
# Determine target path for this match
if len(matched_paths) == 1:
# Single match: mount directly to target_path
mount_target = final_target
else:
# Multiple matches: append relative path to target
rel_path = os.path.relpath(matched_path, repo_path)
mount_target = os.path.join(final_target, rel_path)
volume_mounts.append(
{
"source": matched_path,
"target": mount_target,
"type": "bind",
}
)
logger.debug(
"Added git mount: %s -> %s",
matched_path,
mount_target,
)
return volume_mounts
async def _resolve_single_git_mount( async def _resolve_single_git_mount(
session: AsyncSession, session: AsyncSession,
git_mount: dict, git_mount: dict,
@@ -125,119 +267,33 @@ async def _resolve_single_git_mount(
Clones directly from remote_url, no database lookup needed. Clones directly from remote_url, no database lookup needed.
Returns a list of volume mounts (one for each matched file/directory). Returns a list of volume mounts (one for each matched file/directory).
""" """
git_mount = _normalize_git_mount(git_mount)
remote_url = git_mount.get("remote_url") remote_url = git_mount.get("remote_url")
source_path = git_mount.get("source_path", ".")
target_path = git_mount.get("target_path")
branch = git_mount.get("branch") branch = git_mount.get("branch")
mappings = git_mount.get("mappings", [])
if not remote_url or not target_path: if not remote_url:
logger.warning("Invalid git mount skipped: missing remote_url or target_path") logger.warning("Invalid git mount skipped: missing remote_url")
return [] return []
# Resolve relative target paths against working directory if not mappings:
if target_path and not target_path.startswith("/"): logger.warning("Invalid git mount skipped: no mappings")
if not working_directory: return []
logger.warning(
"Git mount skipped: target_path '%s' is relative but no working_directory is configured. "
"Set working_directory in the tool config or use an absolute path.",
target_path,
)
return []
target_path = os.path.join(working_directory, target_path)
logger.debug("Resolved relative target path to %s", target_path)
if not instance_dir: if not instance_dir:
logger.warning("Git mount skipped: no instance_dir provided for cloning") logger.warning("Git mount skipped: no instance_dir provided for cloning")
return [] return []
# Generate a unique directory name from the URL
import hashlib
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_parent = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}")
# clone_repository always creates 'repo-clone' inside the given directory
repo_path = os.path.join(clone_parent, "repo-clone")
# Clone or pull the repository # Clone or pull the repository
if not os.path.exists(repo_path): try:
try: repo_path = await asyncio.to_thread(
os.makedirs(clone_parent, exist_ok=True) _clone_git_repo, remote_url, branch, instance_dir
repo_path = await asyncio.to_thread(
clone_repository,
remote_url,
None, # No SSH key for now - can be added later
clone_parent,
branch or "main",
)
logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path)
except Exception as exc:
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
return []
else:
# Repo exists - pull latest updates
try:
await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url)
logger.debug("Pulled updates for git mount %s", remote_url)
except Exception as exc:
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
# Handle branch checkout if specified
if branch and repo_path:
success = await asyncio.to_thread(_checkout_branch, repo_path, branch)
if success:
logger.debug("Checked out branch %s for %s", branch, remote_url)
else:
logger.warning(
"Branch %s not found in %s, using current branch", branch, remote_url
)
# Build source path and expand globs
if source_path and source_path != ".":
source_full = os.path.join(repo_path, source_path)
else:
source_full = repo_path
# Expand glob patterns
matched_paths = _expand_glob_source(source_full, repo_path)
if not matched_paths:
logger.warning(
"Git mount skipped: no files matched source path %s in %s",
source_path,
remote_url,
) )
except Exception:
return [] return []
volume_mounts = [] # Resolve all mappings from the cloned repo
for matched_path in matched_paths: return _resolve_git_mount_mappings(repo_path, mappings, working_directory)
if not os.path.exists(matched_path):
continue
# Determine target path for this match
if len(matched_paths) == 1:
# Single match: mount directly to target_path
final_target = target_path
else:
# Multiple matches: append relative path to target
rel_path = os.path.relpath(matched_path, repo_path)
final_target = os.path.join(target_path, rel_path)
volume_mounts.append(
{
"source": matched_path,
"target": final_target,
"type": "bind",
}
)
logger.debug(
"Added git mount: %s -> %s (url: %s)",
matched_path,
final_target,
remote_url,
)
return volume_mounts
def _checkout_branch(repo_path: str, branch: str) -> bool: def _checkout_branch(repo_path: str, branch: str) -> bool:
@@ -176,21 +176,59 @@ def _merge_git_mounts(
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Merge git mounts from included profiles. """Merge git mounts from included profiles.
Later mounts override earlier ones with the same remote_url + target_path combo. Entries with the same remote_url + branch have their mappings concatenated.
Different repos are kept as separate entries.
All entries are normalized to the mappings format.
""" """
result = list(base) result = list(base)
# Build lookup by (remote_url, target_path) # Normalize existing entries to mappings format
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)} for i, m in enumerate(result):
result[i] = _normalize_git_mount_entry(dict(m))
# Build lookup by (remote_url, branch)
seen = {}
for i, m in enumerate(result):
key = (m["remote_url"], m.get("branch"))
seen[key] = i
for mount in overlay: for mount in overlay:
key = (mount["remote_url"], mount["target_path"]) mount = _normalize_git_mount_entry(dict(mount))
key = (mount["remote_url"], mount.get("branch"))
if key in seen: if key in seen:
result[seen[key]] = dict(mount) # Same repo+branch: concatenate mappings, dedup by (source_path, target_path)
existing = result[seen[key]]
existing_sources = {
(m["source_path"], m["target_path"])
for m in existing.get("mappings", [])
}
for mapping in mount.get("mappings", []):
map_key = (mapping["source_path"], mapping["target_path"])
if map_key not in existing_sources:
existing["mappings"].append(dict(mapping))
existing_sources.add(map_key)
else: else:
seen[key] = len(result) seen[key] = len(result)
result.append(dict(mount)) result.append(mount)
return result return result
def _normalize_git_mount_entry(entry: dict[str, Any]) -> dict[str, Any]:
"""Normalize a git mount entry to the unified mappings format.
Converts legacy source_path + target_path into a single-entry mappings array.
"""
entry = dict(entry)
if "mappings" not in entry or not entry.get("mappings"):
source = entry.get("source_path", ".")
target = entry.get("target_path")
if target is not None:
entry["mappings"] = [{"source_path": source, "target_path": target}]
# Remove legacy fields once normalized
entry.pop("source_path", None)
entry.pop("target_path", None)
return entry
async def _resolve_profile_recursive( async def _resolve_profile_recursive(
session: AsyncSession, session: AsyncSession,
profile_id: uuid.UUID, profile_id: uuid.UUID,
@@ -97,7 +97,7 @@ class TestMergeFunctions:
assert overrides == {"/app": "source"} assert overrides == {"/app": "source"}
def test_merge_git_mounts_basic(self) -> None: def test_merge_git_mounts_basic(self) -> None:
"""Test basic git mount merging.""" """Test basic git mount merging normalizes to mappings format."""
result = _merge_git_mounts( result = _merge_git_mounts(
[], [],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}], [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
@@ -105,29 +105,54 @@ class TestMergeFunctions:
) )
assert len(result) == 1 assert len(result) == 1
assert result[0]["remote_url"] == "https://github.com/user/repo1.git" assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result[0]["target_path"] == "/app" assert "mappings" in result[0]
assert result[0]["mappings"] == [{"source_path": ".", "target_path": "/app"}]
def test_merge_git_mounts_override_same_repo_target(self) -> None: def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
"""Test that git mounts with same repo+target override.""" """Test that git mounts with same repo+branch concatenate mappings."""
result = _merge_git_mounts( result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}], [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}], [{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/src", "branch": "main"}],
"source", "source",
) )
assert len(result) == 1 assert len(result) == 1
assert result[0]["source_path"] == "src" assert result[0]["branch"] == "main"
assert result[0]["branch"] == "dev" mappings: list[dict[str, str]] = result[0]["mappings"]
assert len(mappings) == 2
assert {"source_path": ".", "target_path": "/app"} in mappings
assert {"source_path": "src", "target_path": "/src"} in mappings
def test_merge_git_mounts_different_targets(self) -> None: def test_merge_git_mounts_dedup_same_mapping(self) -> None:
"""Test that git mounts with different targets are preserved.""" """Test that duplicate mappings are deduplicated."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
"source",
)
assert len(result) == 1
assert len(result[0]["mappings"]) == 1
def test_merge_git_mounts_different_repos(self) -> None:
"""Test that git mounts with different repos are preserved."""
result = _merge_git_mounts( result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}], [{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}], [{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
"source", "source",
) )
assert len(result) == 2 assert len(result) == 2
targets = {m["target_path"] for m in result} urls = {m["remote_url"] for m in result}
assert targets == {"/app", "/config"} assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
def test_merge_git_mounts_different_branches(self) -> None:
"""Test that same repo with different branches are kept separate."""
result = _merge_git_mounts(
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "dev"}],
"source",
)
assert len(result) == 2
branches = {m.get("branch") for m in result}
assert branches == {"main", "dev"}
class TestResolveProfile: class TestResolveProfile:
@@ -284,7 +309,7 @@ class TestResolveProfile:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None: async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
"""Test resolving a profile with git mounts.""" """Test resolving a profile with git mounts normalizes to mappings."""
user_id = uuid.uuid4() user_id = uuid.uuid4()
profile = ConfigProfile( profile = ConfigProfile(
@@ -303,7 +328,8 @@ class TestResolveProfile:
result = await resolve_profile(db_session, profile.id) result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1 assert len(result.git_mounts) == 1
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git" assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
assert result.git_mounts[0]["target_path"] == "/app" assert "mappings" in result.git_mounts[0]
assert result.git_mounts[0]["mappings"] == [{"source_path": ".", "target_path": "/app"}]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None: async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
@@ -349,8 +375,10 @@ class TestResolveProfile:
result = await resolve_profile(db_session, child.id) result = await resolve_profile(db_session, child.id)
assert len(result.git_mounts) == 2 assert len(result.git_mounts) == 2
targets = {m["target_path"] for m in result.git_mounts} urls = {m["remote_url"] for m in result.git_mounts}
assert targets == {"/app", "/config"} assert urls == {"https://github.com/user/repo1.git", "https://github.com/user/repo2.git"}
for m in result.git_mounts:
assert "mappings" in m
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None: async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
+213
View File
@@ -0,0 +1,213 @@
"""Unit tests for git mount resolution with multi-mapping support."""
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from src.api.tool_instances import (
_clone_git_repo,
_expand_glob_source,
_normalize_git_mount,
_resolve_git_mount_mappings,
_resolve_single_git_mount,
)
class TestNormalizeGitMount:
"""Tests for _normalize_git_mount."""
def test_legacy_to_mappings(self) -> None:
"""Legacy source_path + target_path becomes mappings array."""
entry = {
"remote_url": "https://github.com/user/repo.git",
"source_path": "packages/api",
"target_path": "/app/api",
"branch": "main",
}
result = _normalize_git_mount(entry)
assert "mappings" in result
assert result["mappings"] == [
{"source_path": "packages/api", "target_path": "/app/api"}
]
assert "source_path" not in result
assert "target_path" not in result
assert result["remote_url"] == "https://github.com/user/repo.git"
assert result["branch"] == "main"
def test_already_mappings(self) -> None:
"""Entry already with mappings is left unchanged."""
entry = {
"remote_url": "https://github.com/user/repo.git",
"branch": "main",
"mappings": [
{"source_path": "a", "target_path": "/a"},
{"source_path": "b", "target_path": "/b"},
],
}
result = _normalize_git_mount(entry)
assert result["mappings"] == [
{"source_path": "a", "target_path": "/a"},
{"source_path": "b", "target_path": "/b"},
]
assert "source_path" not in result
assert "target_path" not in result
def test_missing_target_path_no_mappings(self) -> None:
"""Entry with source_path but no target_path creates empty mappings."""
entry = {
"remote_url": "https://github.com/user/repo.git",
"source_path": "src",
}
result = _normalize_git_mount(entry)
assert "mappings" not in result
class TestResolveGitMountMappings:
"""Tests for _resolve_git_mount_mappings."""
def test_single_mapping(self) -> None:
"""A single mapping produces one volume mount."""
with tempfile.TemporaryDirectory() as repo_path:
os.makedirs(os.path.join(repo_path, "packages", "api"))
mappings = [
{"source_path": "packages/api", "target_path": "/app/api"},
]
result = _resolve_git_mount_mappings(repo_path, mappings, None)
assert len(result) == 1
assert result[0]["source"] == os.path.join(repo_path, "packages", "api")
assert result[0]["target"] == "/app/api"
assert result[0]["type"] == "bind"
def test_multiple_mappings(self) -> None:
"""Multiple mappings from same repo produce multiple mounts."""
with tempfile.TemporaryDirectory() as repo_path:
os.makedirs(os.path.join(repo_path, "packages", "api"))
os.makedirs(os.path.join(repo_path, "packages", "web"))
mappings = [
{"source_path": "packages/api", "target_path": "/app/api"},
{"source_path": "packages/web", "target_path": "/app/web"},
]
result = _resolve_git_mount_mappings(repo_path, mappings, None)
assert len(result) == 2
targets = {r["target"] for r in result}
assert targets == {"/app/api", "/app/web"}
def test_relative_target_path(self) -> None:
"""Relative target_path is resolved against working_directory."""
with tempfile.TemporaryDirectory() as repo_path:
os.makedirs(os.path.join(repo_path, "src"))
mappings = [
{"source_path": "src", "target_path": "code"},
]
result = _resolve_git_mount_mappings(repo_path, mappings, "/workspace")
assert len(result) == 1
assert result[0]["target"] == "/workspace/code"
def test_glob_expansion(self) -> None:
"""Glob patterns in source_path are expanded."""
with tempfile.TemporaryDirectory() as repo_path:
os.makedirs(os.path.join(repo_path, "packages", "api"))
os.makedirs(os.path.join(repo_path, "packages", "web"))
mappings = [
{"source_path": "packages/*", "target_path": "/app/packages"},
]
result = _resolve_git_mount_mappings(repo_path, mappings, None)
assert len(result) == 2
targets = {r["target"] for r in result}
assert targets == {
os.path.join("/app/packages", "packages", "api"),
os.path.join("/app/packages", "packages", "web"),
}
def test_missing_target_path_skipped(self) -> None:
"""Mapping without target_path is skipped."""
with tempfile.TemporaryDirectory() as repo_path:
mappings = [
{"source_path": "src"},
]
result = _resolve_git_mount_mappings(repo_path, mappings, None)
assert len(result) == 0
def test_no_working_directory_for_relative_target(self) -> None:
"""Relative target without working_directory is skipped."""
with tempfile.TemporaryDirectory() as repo_path:
os.makedirs(os.path.join(repo_path, "src"))
mappings = [
{"source_path": "src", "target_path": "code"},
]
result = _resolve_git_mount_mappings(repo_path, mappings, None)
assert len(result) == 0
class TestResolveSingleGitMount:
"""Tests for _resolve_single_git_mount."""
@pytest.mark.asyncio
async def test_missing_remote_url(self) -> None:
"""Git mount without remote_url returns empty list."""
result = await _resolve_single_git_mount(
MagicMock(), {"mappings": [{"source_path": ".", "target_path": "/app"}]}, "/tmp", None
)
assert result == []
@pytest.mark.asyncio
async def test_missing_instance_dir(self) -> None:
"""Git mount without instance_dir returns empty list."""
result = await _resolve_single_git_mount(
MagicMock(),
{"remote_url": "https://github.com/user/repo.git", "mappings": [{"source_path": ".", "target_path": "/app"}]},
None,
None,
)
assert result == []
@pytest.mark.asyncio
async def test_legacy_format_normalized(self) -> None:
"""Legacy format is normalized and resolved."""
with tempfile.TemporaryDirectory() as instance_dir:
with patch(
"src.api.tool_instances._clone_git_repo",
return_value=os.path.join(instance_dir, "repo-clone"),
):
os.makedirs(os.path.join(instance_dir, "repo-clone", "src"))
result = await _resolve_single_git_mount(
MagicMock(),
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "src",
"target_path": "/app/src",
},
instance_dir,
None,
)
assert len(result) == 1
assert result[0]["target"] == "/app/src"
class TestExpandGlobSource:
"""Tests for _expand_glob_source."""
def test_no_glob(self) -> None:
"""Non-glob path returns single item if exists."""
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "file.txt")
open(path, "w").close()
result = _expand_glob_source(path, tmp)
assert result == [path]
def test_no_glob_missing(self) -> None:
"""Non-glob path that doesn't exist returns empty list."""
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "missing.txt")
result = _expand_glob_source(path, tmp)
assert result == []
def test_glob_pattern(self) -> None:
"""Glob pattern expands to matched paths."""
with tempfile.TemporaryDirectory() as tmp:
open(os.path.join(tmp, "a.txt"), "w").close()
open(os.path.join(tmp, "b.txt"), "w").close()
result = _expand_glob_source(os.path.join(tmp, "*.txt"), tmp)
assert len(result) == 2
+9 -2
View File
@@ -24,11 +24,18 @@ export interface ConfigProfileMount {
files: Record<string, string>; files: Record<string, string>;
} }
export interface GitMount { export interface GitMountMapping {
remote_url: string;
source_path: string; source_path: string;
target_path: string; target_path: string;
}
export interface GitMount {
remote_url: string;
branch?: string; branch?: string;
mappings: GitMountMapping[];
// Legacy fields (for backward compatibility when reading old data)
source_path?: string;
target_path?: string;
} }
export interface ConfigProfileInclude { export interface ConfigProfileInclude {
+237 -152
View File
@@ -1,96 +1,113 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Icon } from "./icon"; import { Icon } from "./icon";
import type { GitMount } from "../api/config_profiles"; import type { GitMount, GitMountMapping } from "../api/config_profiles";
interface GitMountEditorProps { interface GitMountEditorProps {
mounts: GitMount[]; mounts: GitMount[];
onChange: (mounts: GitMount[]) => void; onChange: (mounts: GitMount[]) => void;
} }
function normalizeMount(mount: GitMount): GitMount {
// Auto-convert legacy source_path + target_path to mappings
if ((!mount.mappings || mount.mappings.length === 0) && mount.source_path !== undefined && mount.target_path !== undefined) {
return {
remote_url: mount.remote_url,
branch: mount.branch,
mappings: [{ source_path: mount.source_path || ".", target_path: mount.target_path }],
};
}
return mount;
}
function normalizeMounts(mounts: GitMount[]): GitMount[] {
return mounts.map(normalizeMount);
}
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => { export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() => normalizeMounts(mounts));
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [newMount, setNewMount] = useState<GitMount>({ const [isAdding, setIsAdding] = useState(false);
remote_url: "",
source_path: ".", useEffect(() => {
target_path: "", setNormalizedMounts(normalizeMounts(mounts));
branch: "", }, [mounts]);
});
const handleAdd = (mount: GitMount) => { const handleAdd = (mount: GitMount) => {
onChange([...mounts, mount]); const updated = [...normalizedMounts, normalizeMount(mount)];
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" }); setNormalizedMounts(updated);
onChange(updated);
setIsAdding(false);
}; };
const handleUpdate = (index: number, updated: GitMount) => { const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...mounts]; const updatedMounts = [...normalizedMounts];
updatedMounts[index] = updated; updatedMounts[index] = normalizeMount(updated);
setNormalizedMounts(updatedMounts);
onChange(updatedMounts); onChange(updatedMounts);
setEditingIndex(null); setEditingIndex(null);
}; };
const handleRemove = (index: number) => { const handleRemove = (index: number) => {
onChange(mounts.filter((_, i) => i !== index)); const updated = normalizedMounts.filter((_, i) => i !== index);
}; setNormalizedMounts(updated);
onChange(updated);
const validatePath = (path: string, isTarget: boolean): string | null => {
if (!path) return isTarget ? "Target path is required" : null;
if (path.includes("..")) return "Path cannot contain ..";
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
return null;
};
const validateUrl = (url: string): string | null => {
if (!url) return "Git URL is required";
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
return "Must be a valid git URL (https://, git@, or ssh://)";
}
return null;
}; };
return ( return (
<div className="git-mount-editor"> <div className="git-mount-editor">
<h4 className="section-subtitle">Git Mounts</h4> <h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
<p className="muted" style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}>
Clone a repository once and mount multiple directories from it.
</p>
{mounts.length > 0 && ( {normalizedMounts.length > 0 && (
<div className="git-mount-list"> <div className="git-mount-list" style={{ display: "flex", flexDirection: "column", gap: "0.75rem", marginBottom: "1rem" }}>
{mounts.map((mount, index) => ( {normalizedMounts.map((mount, index) => (
<div key={index} className="git-mount-item"> <div key={index} className="card" style={{ padding: "1rem" }}>
{editingIndex === index ? ( {editingIndex === index ? (
<GitMountForm <GitMountForm
mount={mount} mount={mount}
onSave={(updated) => handleUpdate(index, updated)} onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)} onCancel={() => setEditingIndex(null)}
validatePath={validatePath}
validateUrl={validateUrl}
/> />
) : ( ) : (
<div className="git-mount-display"> <div>
<div className="git-mount-info"> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "0.5rem" }}>
<span className="git-mount-repo">{mount.remote_url}</span> <div style={{ flex: 1, minWidth: 0 }}>
<span className="git-mount-paths"> <div style={{ fontWeight: 600, fontSize: "0.9375rem", marginBottom: "0.25rem" }}>
{mount.source_path || "."} {mount.target_path} {mount.remote_url}
</span> {mount.branch && (
{mount.branch && ( <span style={{ color: "var(--muted)", fontWeight: 400, marginLeft: "0.5rem" }}>
<span className="git-mount-branch">@{mount.branch}</span> @{mount.branch}
)} </span>
</div> )}
<div className="git-mount-actions"> </div>
<button <div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
type="button" {mount.mappings?.map((m, mi) => (
className="icon-button" <div key={mi} style={{ fontSize: "0.875rem", color: "var(--muted)", fontFamily: "monospace" }}>
onClick={() => setEditingIndex(index)} {m.source_path || "."} {m.target_path}
title="Edit" </div>
> ))}
<Icon name="edit" size="sm" /> </div>
</button> </div>
<button <div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
type="button" <button
className="icon-button danger" type="button"
onClick={() => handleRemove(index)} className="ghost-button small"
title="Remove" onClick={() => setEditingIndex(index)}
> title="Edit"
<Icon name="delete" size="sm" /> >
</button> <Icon name="edit" size="sm" />
</button>
<button
type="button"
className="ghost-button small"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div> </div>
</div> </div>
)} )}
@@ -99,17 +116,20 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
</div> </div>
)} )}
<div className="git-mount-add"> {isAdding ? (
<h5>Add Git Mount</h5> <div className="card" style={{ padding: "1rem" }}>
<GitMountForm <GitMountForm
mount={newMount} mount={{ remote_url: "", branch: "", mappings: [{ source_path: ".", target_path: "" }] }}
onSave={handleAdd} onSave={handleAdd}
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })} onCancel={() => setIsAdding(false)}
validatePath={validatePath} />
validateUrl={validateUrl} </div>
isNew ) : (
/> <button type="button" className="secondary-button" onClick={() => setIsAdding(true)}>
</div> <Icon name="add" size="sm" />
Add Git Mount
</button>
)}
</div> </div>
); );
}; };
@@ -118,104 +138,169 @@ interface GitMountFormProps {
mount: GitMount; mount: GitMount;
onSave: (mount: GitMount) => void; onSave: (mount: GitMount) => void;
onCancel: () => void; onCancel: () => void;
validatePath: (path: string, isTarget: boolean) => string | null;
validateUrl: (url: string) => string | null;
isNew?: boolean;
} }
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => { const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
const [form, setForm] = useState<GitMount>({ ...mount }); const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
const [branch, setBranch] = useState(mount.branch || "");
const [mappings, setMappings] = useState<GitMountMapping[]>(
mount.mappings?.length ? mount.mappings : [{ source_path: ".", target_path: "" }]
);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const handleChange = (field: keyof GitMount, value: string) => { const validate = (): boolean => {
setForm((prev) => ({ ...prev, [field]: value })); const newErrors: Record<string, string> = {};
if (errors[field]) {
if (!remoteUrl.trim()) {
newErrors.remote_url = "Git URL is required";
} else if (
!remoteUrl.startsWith("http://") &&
!remoteUrl.startsWith("https://") &&
!remoteUrl.startsWith("git@") &&
!remoteUrl.startsWith("ssh://")
) {
newErrors.remote_url = "Must be a valid git URL (https://, git@, or ssh://)";
}
mappings.forEach((m, i) => {
if (!m.target_path.trim()) {
newErrors[`mapping_${i}_target`] = "Target path is required";
}
if (m.source_path.includes("..")) {
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
}
if (m.target_path.includes("..")) {
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (!validate()) return;
onSave({
remote_url: remoteUrl.trim(),
branch: branch.trim() || undefined,
mappings: mappings.map((m) => ({
source_path: m.source_path.trim() || ".",
target_path: m.target_path.trim(),
})),
});
};
const addMapping = () => {
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
};
const updateMapping = (index: number, field: keyof GitMountMapping, value: string) => {
setMappings((prev) => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
if (errors[`mapping_${index}_${field}`]) {
setErrors((prev) => { setErrors((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[field]; delete next[`mapping_${index}_${field}`];
return next; return next;
}); });
} }
}; };
const handleSubmit = () => { const removeMapping = (index: number) => {
const newErrors: Record<string, string> = {}; setMappings((prev) => prev.filter((_, i) => i !== index));
const urlError = validateUrl(form.remote_url);
if (urlError) newErrors.remote_url = urlError;
const sourceError = validatePath(form.source_path || ".", false);
if (sourceError) newErrors.source_path = sourceError;
const targetError = validatePath(form.target_path, true);
if (targetError) newErrors.target_path = targetError;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSave(form);
if (isNew) {
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
}
}; };
return ( return (
<div className="git-mount-form"> <div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="form-row"> <div className="form-row" style={{ gap: "0.5rem" }}>
<label>Git URL</label> <div style={{ flex: 2 }}>
<input <label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Repository URL</label>
type="text" <input
value={form.remote_url} type="text"
onChange={(e) => handleChange("remote_url", e.target.value)} value={remoteUrl}
placeholder="https://github.com/user/repo.git" onChange={(e) => {
className={errors.remote_url ? "error" : ""} setRemoteUrl(e.target.value);
/> if (errors.remote_url) {
<span className="hint">Repository URL (HTTPS or SSH)</span> setErrors((prev) => {
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>} const next = { ...prev };
delete next.remote_url;
return next;
});
}
}}
placeholder="https://github.com/user/repo.git"
className={`form-input ${errors.remote_url ? "error" : ""}`}
/>
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Branch (optional)</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="form-input"
/>
</div>
</div> </div>
<div className="form-row"> <div>
<label>Source Path</label> <label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Mappings</label>
<input <p className="muted" style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}>
type="text" Source paths within the repo and where to mount them in the container.
value={form.source_path || "."} </p>
onChange={(e) => handleChange("source_path", e.target.value)} <div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
placeholder="e.g., . or configs/*.json" {mappings.map((mapping, index) => (
className={errors.source_path ? "error" : ""} <div key={index} className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
/> <input
<span className="hint">Relative path in repo (supports glob patterns)</span> type="text"
{errors.source_path && <span className="error-text">{errors.source_path}</span>} value={mapping.source_path}
onChange={(e) => updateMapping(index, "source_path", e.target.value)}
placeholder="packages/api"
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
<span style={{ padding: "0.5rem 0", color: "var(--muted)", fontSize: "0.875rem" }}></span>
<input
type="text"
value={mapping.target_path}
onChange={(e) => updateMapping(index, "target_path", e.target.value)}
placeholder="/app/api"
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
style={{ flex: 1 }}
/>
{mappings.length > 1 && (
<button
type="button"
className="ghost-button small"
onClick={() => removeMapping(index)}
title="Remove mapping"
>
<Icon name="delete" size="sm" />
</button>
)}
{errors[`mapping_${index}_source`] && (
<span className="error-text">{errors[`mapping_${index}_source`]}</span>
)}
{errors[`mapping_${index}_target`] && (
<span className="error-text">{errors[`mapping_${index}_target`]}</span>
)}
</div>
))}
</div>
<button type="button" className="secondary-button small" onClick={addMapping} style={{ marginTop: "0.5rem" }}>
<Icon name="add" size="sm" />
Add Mapping
</button>
</div> </div>
<div className="form-row"> <div className="form-actions" style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<label>Target Path</label>
<input
type="text"
value={form.target_path}
onChange={(e) => handleChange("target_path", e.target.value)}
placeholder="e.g., /app/config"
className={errors.target_path ? "error" : ""}
/>
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
</div>
<div className="form-row">
<label>Branch (optional)</label>
<input
type="text"
value={form.branch || ""}
onChange={(e) => handleChange("branch", e.target.value)}
placeholder="e.g., main or v1.0"
/>
<span className="hint">Branch or tag to checkout</span>
</div>
<div className="form-actions">
<button type="button" className="primary-button" onClick={handleSubmit}> <button type="button" className="primary-button" onClick={handleSubmit}>
{isNew ? "Add" : "Save"} Save
</button> </button>
<button type="button" className="secondary-button" onClick={onCancel}> <button type="button" className="secondary-button" onClick={onCancel}>
Cancel Cancel
@@ -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.