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
@@ -97,7 +97,7 @@ class TestMergeFunctions:
assert overrides == {"/app": "source"}
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(
[],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
@@ -105,29 +105,54 @@ class TestMergeFunctions:
)
assert len(result) == 1
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:
"""Test that git mounts with same repo+target override."""
def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
"""Test that git mounts with same repo+branch concatenate mappings."""
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": "src", "target_path": "/app", "branch": "dev"}],
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/src", "branch": "main"}],
"source",
)
assert len(result) == 1
assert result[0]["source_path"] == "src"
assert result[0]["branch"] == "dev"
assert result[0]["branch"] == "main"
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:
"""Test that git mounts with different targets are preserved."""
def test_merge_git_mounts_dedup_same_mapping(self) -> None:
"""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(
[{"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"}],
"source",
)
assert len(result) == 2
targets = {m["target_path"] for m in result}
assert targets == {"/app", "/config"}
urls = {m["remote_url"] for m in result}
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:
@@ -284,7 +309,7 @@ class TestResolveProfile:
@pytest.mark.asyncio
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()
profile = ConfigProfile(
@@ -303,7 +328,8 @@ class TestResolveProfile:
result = await resolve_profile(db_session, profile.id)
assert len(result.git_mounts) == 1
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
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)
assert len(result.git_mounts) == 2
targets = {m["target_path"] for m in result.git_mounts}
assert targets == {"/app", "/config"}
urls = {m["remote_url"] for m in result.git_mounts}
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
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None: