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
+50 -13
View File
@@ -2,9 +2,10 @@
import logging
import uuid
from typing import Any
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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -56,18 +57,9 @@ def _calculate_profile_size(data: dict) -> int:
return total
class GitMountItem(BaseModel):
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
class GitMountMapping(BaseModel):
source_path: str = Field(description="Path within repository (supports glob patterns)")
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")
@classmethod
@@ -86,6 +78,51 @@ class GitMountItem(BaseModel):
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):
target: str = Field(description="Absolute mount target path")
mode: str = Field(default="rw", description="Mount mode: ro or rw")
@@ -263,7 +300,7 @@ async def _check_access(
async def _validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[dict],
git_mounts: list[Any],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs.
+155 -99
View File
@@ -114,6 +114,148 @@ async def _resolve_git_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(
session: AsyncSession,
git_mount: dict,
@@ -125,119 +267,33 @@ async def _resolve_single_git_mount(
Clones directly from remote_url, no database lookup needed.
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")
source_path = git_mount.get("source_path", ".")
target_path = git_mount.get("target_path")
branch = git_mount.get("branch")
mappings = git_mount.get("mappings", [])
if not remote_url or not target_path:
logger.warning("Invalid git mount skipped: missing remote_url or target_path")
if not remote_url:
logger.warning("Invalid git mount skipped: missing remote_url")
return []
# Resolve relative target paths against working directory
if target_path and 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,
)
return []
target_path = os.path.join(working_directory, target_path)
logger.debug("Resolved relative target path to %s", target_path)
if not mappings:
logger.warning("Invalid git mount skipped: no mappings")
return []
if not instance_dir:
logger.warning("Git mount skipped: no instance_dir provided for cloning")
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
if not os.path.exists(repo_path):
try:
os.makedirs(clone_parent, exist_ok=True)
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,
try:
repo_path = await asyncio.to_thread(
_clone_git_repo, remote_url, branch, instance_dir
)
except Exception:
return []
volume_mounts = []
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
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
# Resolve all mappings from the cloned repo
return _resolve_git_mount_mappings(repo_path, mappings, working_directory)
def _checkout_branch(repo_path: str, branch: str) -> bool:
@@ -176,21 +176,59 @@ def _merge_git_mounts(
) -> list[dict[str, Any]]:
"""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)
# Build lookup by (remote_url, target_path)
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
# Normalize existing entries to mappings format
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:
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:
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:
seen[key] = len(result)
result.append(dict(mount))
result.append(mount)
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(
session: AsyncSession,
profile_id: uuid.UUID,
@@ -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:
+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>;
}
export interface GitMount {
remote_url: string;
export interface GitMountMapping {
source_path: string;
target_path: string;
}
export interface GitMount {
remote_url: string;
branch?: string;
mappings: GitMountMapping[];
// Legacy fields (for backward compatibility when reading old data)
source_path?: string;
target_path?: string;
}
export interface ConfigProfileInclude {
+239 -154
View File
@@ -1,96 +1,113 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Icon } from "./icon";
import type { GitMount } from "../api/config_profiles";
import type { GitMount, GitMountMapping } from "../api/config_profiles";
interface GitMountEditorProps {
mounts: GitMount[];
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) => {
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() => normalizeMounts(mounts));
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [newMount, setNewMount] = useState<GitMount>({
remote_url: "",
source_path: ".",
target_path: "",
branch: "",
});
const [isAdding, setIsAdding] = useState(false);
useEffect(() => {
setNormalizedMounts(normalizeMounts(mounts));
}, [mounts]);
const handleAdd = (mount: GitMount) => {
onChange([...mounts, mount]);
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
const updated = [...normalizedMounts, normalizeMount(mount)];
setNormalizedMounts(updated);
onChange(updated);
setIsAdding(false);
};
const handleUpdate = (index: number, updated: GitMount) => {
const updatedMounts = [...mounts];
updatedMounts[index] = updated;
const updatedMounts = [...normalizedMounts];
updatedMounts[index] = normalizeMount(updated);
setNormalizedMounts(updatedMounts);
onChange(updatedMounts);
setEditingIndex(null);
};
const handleRemove = (index: number) => {
onChange(mounts.filter((_, i) => i !== index));
};
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;
const updated = normalizedMounts.filter((_, i) => i !== index);
setNormalizedMounts(updated);
onChange(updated);
};
return (
<div className="git-mount-editor">
<h4 className="section-subtitle">Git Mounts</h4>
{mounts.length > 0 && (
<div className="git-mount-list">
{mounts.map((mount, index) => (
<div key={index} className="git-mount-item">
<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>
{normalizedMounts.length > 0 && (
<div className="git-mount-list" style={{ display: "flex", flexDirection: "column", gap: "0.75rem", marginBottom: "1rem" }}>
{normalizedMounts.map((mount, index) => (
<div key={index} className="card" style={{ padding: "1rem" }}>
{editingIndex === index ? (
<GitMountForm
mount={mount}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
validatePath={validatePath}
validateUrl={validateUrl}
/>
) : (
<div className="git-mount-display">
<div className="git-mount-info">
<span className="git-mount-repo">{mount.remote_url}</span>
<span className="git-mount-paths">
{mount.source_path || "."} {mount.target_path}
</span>
{mount.branch && (
<span className="git-mount-branch">@{mount.branch}</span>
)}
</div>
<div className="git-mount-actions">
<button
type="button"
className="icon-button"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<Icon name="edit" size="sm" />
</button>
<button
type="button"
className="icon-button danger"
onClick={() => handleRemove(index)}
title="Remove"
>
<Icon name="delete" size="sm" />
</button>
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "0.5rem" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: "0.9375rem", marginBottom: "0.25rem" }}>
{mount.remote_url}
{mount.branch && (
<span style={{ color: "var(--muted)", fontWeight: 400, marginLeft: "0.5rem" }}>
@{mount.branch}
</span>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{mount.mappings?.map((m, mi) => (
<div key={mi} style={{ fontSize: "0.875rem", color: "var(--muted)", fontFamily: "monospace" }}>
{m.source_path || "."} {m.target_path}
</div>
))}
</div>
</div>
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
<button
type="button"
className="ghost-button small"
onClick={() => setEditingIndex(index)}
title="Edit"
>
<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>
)}
@@ -99,17 +116,20 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
</div>
)}
<div className="git-mount-add">
<h5>Add Git Mount</h5>
<GitMountForm
mount={newMount}
onSave={handleAdd}
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
validatePath={validatePath}
validateUrl={validateUrl}
isNew
/>
</div>
{isAdding ? (
<div className="card" style={{ padding: "1rem" }}>
<GitMountForm
mount={{ remote_url: "", branch: "", mappings: [{ source_path: ".", target_path: "" }] }}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
</div>
) : (
<button type="button" className="secondary-button" onClick={() => setIsAdding(true)}>
<Icon name="add" size="sm" />
Add Git Mount
</button>
)}
</div>
);
};
@@ -118,104 +138,169 @@ interface GitMountFormProps {
mount: GitMount;
onSave: (mount: GitMount) => 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 [form, setForm] = useState<GitMount>({ ...mount });
const GitMountForm = ({ mount, onSave, onCancel }: GitMountFormProps) => {
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 handleChange = (field: keyof GitMount, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
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) => {
const next = { ...prev };
delete next[field];
delete next[`mapping_${index}_${field}`];
return next;
});
}
};
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
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: "" });
}
const removeMapping = (index: number) => {
setMappings((prev) => prev.filter((_, i) => i !== index));
};
return (
<div className="git-mount-form">
<div className="form-row">
<label>Git URL</label>
<input
type="text"
value={form.remote_url}
onChange={(e) => handleChange("remote_url", e.target.value)}
placeholder="https://github.com/user/repo.git"
className={errors.remote_url ? "error" : ""}
/>
<span className="hint">Repository URL (HTTPS or SSH)</span>
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="form-row" style={{ gap: "0.5rem" }}>
<div style={{ flex: 2 }}>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Repository URL</label>
<input
type="text"
value={remoteUrl}
onChange={(e) => {
setRemoteUrl(e.target.value);
if (errors.remote_url) {
setErrors((prev) => {
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 className="form-row">
<label>Source Path</label>
<input
type="text"
value={form.source_path || "."}
onChange={(e) => handleChange("source_path", e.target.value)}
placeholder="e.g., . or configs/*.json"
className={errors.source_path ? "error" : ""}
/>
<span className="hint">Relative path in repo (supports glob patterns)</span>
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
<div>
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>Mappings</label>
<p className="muted" style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}>
Source paths within the repo and where to mount them in the container.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{mappings.map((mapping, index) => (
<div key={index} className="form-row" style={{ gap: "0.5rem", alignItems: "flex-start" }}>
<input
type="text"
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 className="form-row">
<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">
<div className="form-actions" style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}>
<button type="button" className="primary-button" onClick={handleSubmit}>
{isNew ? "Add" : "Save"}
Save
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
@@ -223,4 +308,4 @@ const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNe
</div>
</div>
);
};
};