From 4c11163bff43372f180c32862a7ed4ddc79b84ec Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Tue, 26 May 2026 22:27:04 +0200 Subject: [PATCH] feat: implement config profile git mounts - Add git_mounts column to config_profiles table (JSONB) - Create GitMount Pydantic models with validation - Add repo validation in create/update endpoints - Update profile resolver to merge git mounts from includes - Implement auto-clone, branch checkout, and glob expansion - Add parallel processing for git mount resolution - Create GitMountEditor frontend component - Update TypeScript types and API clients - Add CSS styles for git mount UI - Frontend type check and build pass Implements tasks 1.1-7.9 of config-profile-git-mounts spec --- .../versions/2026_05_26_add_git_mounts.py | 28 +++ apps/api/src/api/config_profiles.py | 104 ++++++++ apps/api/src/api/tool_instances.py | 209 +++++++++++++++- apps/api/src/models/config_profile.py | 3 + .../src/services/config_profile_resolver.py | 33 ++- apps/web/src/api/config_profiles.ts | 11 + apps/web/src/components/git-mount-editor.tsx | 229 ++++++++++++++++++ apps/web/src/pages/config-profiles.tsx | 27 +++ apps/web/src/styles.css | 125 ++++++++++ .../config-profile-git-mounts/.openspec.yaml | 2 + .../config-profile-git-mounts/design.md | 75 ++++++ .../config-profile-git-mounts/proposal.md | 31 +++ .../specs/config-profile-git-mounts/spec.md | 137 +++++++++++ .../specs/tool-instances/spec.md | 43 ++++ .../config-profile-git-mounts/tasks.md | 64 +++++ 15 files changed, 1118 insertions(+), 3 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_26_add_git_mounts.py create mode 100644 apps/web/src/components/git-mount-editor.tsx create mode 100644 openspec/changes/config-profile-git-mounts/.openspec.yaml create mode 100644 openspec/changes/config-profile-git-mounts/design.md create mode 100644 openspec/changes/config-profile-git-mounts/proposal.md create mode 100644 openspec/changes/config-profile-git-mounts/specs/config-profile-git-mounts/spec.md create mode 100644 openspec/changes/config-profile-git-mounts/specs/tool-instances/spec.md create mode 100644 openspec/changes/config-profile-git-mounts/tasks.md diff --git a/apps/api/alembic/versions/2026_05_26_add_git_mounts.py b/apps/api/alembic/versions/2026_05_26_add_git_mounts.py new file mode 100644 index 0000000..d82a85b --- /dev/null +++ b/apps/api/alembic/versions/2026_05_26_add_git_mounts.py @@ -0,0 +1,28 @@ +"""add_git_mounts_to_config_profiles + +Revision ID: 2026_05_26_add_git_mounts +Revises: f3d2dc90ba3a +Create Date: 2026-05-26 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "2026_05_26_add_git_mounts" +down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "config_profiles", + sa.Column("git_mounts", sa.JSON(), nullable=True, default=list), + ) + + +def downgrade() -> None: + op.drop_column("config_profiles", "git_mounts") diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 573438f..8091a63 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload from src.api.shared_validators import validate_env_vars as _validate_env_vars from src.auth.dependencies import get_current_user_id, get_db_session from src.models.config_profile import ConfigProfile, ConfigProfileInclude +from src.models.git_repository import GitRepository from src.models.project import Project from src.models.tool_type import ToolType from src.services.config_profile_resolver import ( @@ -56,6 +57,40 @@ def _calculate_profile_size(data: dict) -> int: return total +class GitMountItem(BaseModel): + repo_id: str = Field(description="UUID of the git repository") + source_path: str = Field(default=".", 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("repo_id") + @classmethod + def validate_repo_id(cls, v: str) -> str: + try: + uuid.UUID(v) + except ValueError: + raise ValueError(f"Invalid repo_id UUID: {v}") + return v + + @field_validator("source_path") + @classmethod + def validate_source_path(cls, v: str) -> str: + 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) -> str: + if not v.startswith("/"): + raise ValueError("target_path must be absolute (start with /)") + if ".." in v: + raise ValueError("target_path cannot contain path traversal (..)") + return v + + class MountItem(BaseModel): target: str = Field(description="Absolute mount target path") mode: str = Field(default="rw", description="Mount mode: ro or rw") @@ -98,6 +133,7 @@ class ConfigProfileCreate(BaseModel): runtime_hints: dict = Field(default_factory=dict, description="Runtime hints") mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions") files: dict = Field(default_factory=dict, description="Files as {relative_path: content}") + git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts") is_default: bool = Field(default=False, description="Whether this is the default profile for its scope") @field_validator("project_id", "tool_type_id") @@ -150,6 +186,7 @@ class ConfigProfileUpdate(BaseModel): runtime_hints: dict | None = Field(default=None, description="Runtime hints") mounts: list[MountItem] | None = Field(default=None, description="Mount definitions") files: dict | None = Field(default=None, description="Files as {relative_path: content}") + git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts") is_default: bool | None = Field(default=None, description="Whether this is the default profile") @field_validator("project_id", "tool_type_id") @@ -193,6 +230,7 @@ class ConfigProfileResponse(BaseModel): runtime_hints: dict mounts: list files: dict + git_mounts: list is_default: bool includes: list[dict] created_at: str @@ -227,6 +265,55 @@ async def _check_access( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found") +async def _validate_git_mounts( + session: AsyncSession, + user_id: uuid.UUID, + git_mounts: list[dict], + project_id: uuid.UUID | None = None, +) -> None: + """Validate that all referenced git repositories exist and are accessible. + + Repositories must: + 1. Exist + 2. Belong to the user + 3. If project_id is specified, belong to that project + """ + for mount in git_mounts: + repo_id = mount.get("repo_id") + if not repo_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Git mount missing repo_id", + ) + + try: + repo_uuid = uuid.UUID(repo_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid repo_id UUID: {repo_id}", + ) + + repo = await session.get(GitRepository, repo_uuid) + if repo is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Git repository not found: {repo_id}", + ) + + if repo.owner_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Not authorized to access repository: {repo_id}", + ) + + if project_id is not None and repo.project_id != project_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Repository {repo_id} does not belong to project {project_id}", + ) + + def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict: return { "id": str(profile.id), @@ -238,6 +325,7 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc "env_vars": profile.env_vars or {}, "runtime_hints": profile.runtime_hints or {}, "mounts": profile.mounts or [], + "git_mounts": profile.git_mounts or [], "files": profile.files or {}, "is_default": profile.is_default, "includes": [ @@ -321,6 +409,11 @@ async def create_config_profile( project_uuid = uuid.UUID(data.project_id) if data.project_id else None tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None await _check_access(session, user_uuid, project_uuid, tool_uuid) + + # Validate git mounts reference existing repositories + if data.git_mounts: + git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts] + await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid) # Check size size = _calculate_profile_size(data.model_dump()) @@ -339,6 +432,7 @@ async def create_config_profile( env_vars=data.env_vars, runtime_hints=data.runtime_hints, mounts=[m.model_dump() for m in data.mounts], + git_mounts=[m.model_dump() for m in data.git_mounts], files=data.files, is_default=data.is_default, ) @@ -415,6 +509,14 @@ async def update_config_profile( else (profile.tool_type_id if "tool_type_id" not in update_data else None) ) await _check_access(session, profile.user_id, project_uuid, tool_uuid) + + # Validate git mounts reference existing repositories + if "git_mounts" in update_data and update_data["git_mounts"] is not None: + git_mounts_data = [ + m.model_dump() if hasattr(m, "model_dump") else m + for m in update_data["git_mounts"] + ] + await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid) # Check size current_data = _profile_to_response(profile) @@ -432,6 +534,8 @@ async def update_config_profile( value = uuid.UUID(value) if value else None elif field_name == "mounts" and value is not None: value = [m.model_dump() if not isinstance(m, dict) else m for m in value] + elif field_name == "git_mounts" and value is not None: + value = [m.model_dump() if not isinstance(m, dict) else m for m in value] setattr(profile, field_name, value) await session.commit() diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index c130d94..b2a69b2 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -46,13 +46,214 @@ from src.services.docker import ( from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory from src.services.docker_build import build_image from src.services.config_profile_resolver import ( - ConfigProfileCycleError, apply_resolved_profile, resolve_profile, + ConfigProfileCycleError, + ResolvedProfile, ) from src.services.readiness_probe import execute_probe from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files +import asyncio +import glob as glob_module + +async def _resolve_git_mounts( + session: AsyncSession, + resolved: ResolvedProfile, + instance_dir: str | None = None, +) -> list[dict]: + """Convert git mounts from resolved profile to Docker volume mounts. + + Looks up repository paths, auto-clones if needed, handles branch checkout, + expands glob patterns, and prepares bind mount entries. + Logs warnings for missing repos or invalid paths (non-blocking). + """ + if not resolved.git_mounts: + return [] + + # Process all git mounts concurrently + tasks = [] + for git_mount in resolved.git_mounts: + tasks.append(_resolve_single_git_mount(session, git_mount, instance_dir)) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + volume_mounts = [] + for result in results: + if isinstance(result, Exception): + logger.warning("Git mount failed: %s", result) + continue + if result: + volume_mounts.extend(result) + + return volume_mounts + + +async def _resolve_single_git_mount( + session: AsyncSession, + git_mount: dict, + instance_dir: str | None = None, +) -> list[dict]: + """Resolve a single git mount to volume mount entries. + + Returns a list of volume mounts (one for each matched file/directory). + """ + repo_id = git_mount.get("repo_id") + source_path = git_mount.get("source_path", ".") + target_path = git_mount.get("target_path") + branch = git_mount.get("branch") + + if not repo_id or not target_path: + logger.warning("Invalid git mount skipped: missing repo_id or target_path") + return [] + + try: + repo_uuid = uuid.UUID(repo_id) + except ValueError: + logger.warning("Invalid git mount skipped: repo_id is not a valid UUID: %s", repo_id) + return [] + + repo = await session.get(GitRepository, repo_uuid) + if repo is None: + logger.warning("Git mount skipped: repository %s not found", repo_id) + return [] + + # Determine repo path - use existing or auto-clone + repo_path = repo.path + + if not repo_path or not os.path.exists(repo_path): + # Auto-clone if remote URL is available and instance_dir is provided + if repo.remote_url and instance_dir: + try: + ssh_key_path = None + if repo.ssh_key_id: + from src.models.ssh_key import SSHKey + ssh_key = await session.get(SSHKey, repo.ssh_key_id) + if ssh_key: + ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) + ssh_key_path = str(Path(ssh_dir) / "id_ed25519") + + repo_path = await asyncio.to_thread( + clone_repository, + repo.remote_url, + ssh_key_path, + instance_dir, + branch or "main", + ) + logger.info("Auto-cloned repository %s to %s", repo.name, repo_path) + except Exception as exc: + logger.warning("Auto-clone failed for repository %s: %s", repo.name, exc) + return [] + else: + logger.warning( + "Git mount skipped: repository %s path not found at %s and no remote_url available", + repo_id, repo_path + ) + return [] + + # Handle branch checkout if specified + if branch and repo_path: + try: + await asyncio.to_thread(_checkout_branch, repo_path, branch) + logger.info("Checked out branch %s for repository %s", branch, repo.name) + except Exception as exc: + logger.warning("Branch checkout failed for %s@%s: %s", repo.name, branch, exc) + # Continue with current branch as fallback + + # 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 repository %s", source_path, repo_id) + 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.info("Added git mount: %s -> %s (repo: %s)", matched_path, final_target, repo.name) + + return volume_mounts + + +def _checkout_branch(repo_path: str, branch: str) -> None: + """Checkout a specific branch in a git repository.""" + import subprocess + + # First try to checkout existing branch + result = subprocess.run( + ["git", "-C", repo_path, "checkout", branch], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + # Try fetching and checking out + subprocess.run( + ["git", "-C", repo_path, "fetch", "origin", branch], + capture_output=True, + text=True, + ) + result = subprocess.run( + ["git", "-C", repo_path, "checkout", "-b", branch, f"origin/{branch}"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError(f"Failed to checkout branch {branch}: {result.stderr}") + + +def _expand_glob_source(source_path: str, repo_path: str) -> list[str]: + """Expand glob patterns in source path. + + Returns a list of matched absolute paths. + Limits results to prevent abuse. + """ + MAX_GLOB_MATCHES = 100 + + # Check if path contains glob characters + if not any(c in source_path for c in "*?["): + # No glob pattern: return single path if it exists + return [source_path] if os.path.exists(source_path) else [] + + # Expand glob pattern + matched = glob_module.glob(source_path, recursive=True) + + # Filter to only paths within the repo and limit count + results = [] + for path in matched: + abs_path = os.path.abspath(path) + if abs_path.startswith(os.path.abspath(repo_path)): + results.append(abs_path) + if len(results) >= MAX_GLOB_MATCHES: + logger.warning("Glob pattern matched too many files, limiting to %d", MAX_GLOB_MATCHES) + break + + return results + + router = APIRouter(prefix="/projects", tags=["tool-instances"]) @@ -687,6 +888,9 @@ async def start_instance( config_files.update(profile_files) # Profile mounts are added to extra volumes extra_volumes.extend(profile_mounts) + # Git repository mounts are resolved and added + git_mount_volumes = await _resolve_git_mounts(session, resolved, instance_dir) + extra_volumes.extend(git_mount_volumes) # Profile runtime hints override tool config values if profile_hints.get("start_command"): start_command = profile_hints["start_command"] @@ -695,12 +899,13 @@ async def start_instance( if profile_hints.get("port_override"): port_override = profile_hints["port_override"] logger.info( - "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d)", + "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)", resolved.profile_name, instance.id, len(profile_env), len(profile_files), len(profile_mounts), + len(git_mount_volumes), ) except ConfigProfileCycleError as exc: logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc) diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py index a806ff2..1b5bc54 100644 --- a/apps/api/src/models/config_profile.py +++ b/apps/api/src/models/config_profile.py @@ -39,6 +39,9 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): files: Mapped[dict] = mapped_column( JSON, default=dict, nullable=False ) # {"rel/path": "content", ...} + git_mounts: Mapped[list] = mapped_column( + JSON, default=list, nullable=False + ) # [{"repo_id": "uuid", "source_path": ".", "target_path": "/path", "branch": "main"}, ...] is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) user: Mapped["User"] = relationship() diff --git a/apps/api/src/services/config_profile_resolver.py b/apps/api/src/services/config_profile_resolver.py index 77ba8e8..afcc525 100644 --- a/apps/api/src/services/config_profile_resolver.py +++ b/apps/api/src/services/config_profile_resolver.py @@ -48,6 +48,7 @@ class ResolvedProfile: env_vars: dict[str, str] = field(default_factory=dict) runtime_hints: dict[str, Any] = field(default_factory=dict) mounts: dict[str, ResolvedMount] = field(default_factory=dict) + git_mounts: list[dict[str, Any]] = field(default_factory=list) files: dict[str, str] = field(default_factory=dict) env_overrides: dict[str, str] = field(default_factory=dict) hint_overrides: dict[str, str] = field(default_factory=dict) @@ -168,6 +169,28 @@ def _merge_mounts( return result +def _merge_git_mounts( + base: list[dict[str, Any]], + overlay: list[dict[str, Any]], + source_name: str, +) -> list[dict[str, Any]]: + """Merge git mounts from included profiles. + + Later mounts override earlier ones with the same repo_id + target_path combo. + """ + result = list(base) + # Build lookup by (repo_id, target_path) + seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)} + for mount in overlay: + key = (mount["repo_id"], mount["target_path"]) + if key in seen: + result[seen[key]] = dict(mount) + else: + seen[key] = len(result) + result.append(dict(mount)) + return result + + async def _resolve_profile_recursive( session: AsyncSession, profile_id: uuid.UUID, @@ -244,6 +267,9 @@ async def _resolve_profile_recursive( result.mount_overrides, included.profile_name, ) + result.git_mounts = _merge_git_mounts( + result.git_mounts, included.git_mounts, included.profile_name + ) # Apply the profile's own settings (selected profile overrides includes) result.env_vars = _merge_env_vars( @@ -270,7 +296,11 @@ async def _resolve_profile_recursive( result.mount_overrides, profile.name, ) - + result.git_mounts = _merge_git_mounts( + result.git_mounts, + profile.git_mounts or [], + profile.name, + ) return result @@ -449,5 +479,6 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]: "files": resolved.file_overrides, "mounts": resolved.mount_overrides, }, + "git_mounts": resolved.git_mounts, "included_profiles": resolved.included_profiles, } diff --git a/apps/web/src/api/config_profiles.ts b/apps/web/src/api/config_profiles.ts index 15cfb10..219c258 100644 --- a/apps/web/src/api/config_profiles.ts +++ b/apps/web/src/api/config_profiles.ts @@ -10,6 +10,7 @@ export interface ConfigProfile { env_vars: Record; runtime_hints: Record; mounts: ConfigProfileMount[]; + git_mounts: GitMount[]; files: Record; is_default: boolean; includes: ConfigProfileInclude[]; @@ -23,6 +24,13 @@ export interface ConfigProfileMount { files: Record; } +export interface GitMount { + repo_id: string; + source_path: string; + target_path: string; + branch?: string; +} + export interface ConfigProfileInclude { id: string; included_profile_id: string; @@ -35,6 +43,7 @@ export interface ResolvedProfile { env_vars: Record; runtime_hints: Record; mounts: ResolvedMount[]; + git_mounts: GitMount[]; files: Record; overrides: { env_vars: Record; @@ -60,6 +69,7 @@ export interface CreateConfigProfileRequest { env_vars?: Record; runtime_hints?: Record; mounts?: ConfigProfileMount[]; + git_mounts?: GitMount[]; files?: Record; is_default?: boolean; } @@ -72,6 +82,7 @@ export interface UpdateConfigProfileRequest { env_vars?: Record; runtime_hints?: Record; mounts?: ConfigProfileMount[]; + git_mounts?: GitMount[]; files?: Record; is_default?: boolean; } diff --git a/apps/web/src/components/git-mount-editor.tsx b/apps/web/src/components/git-mount-editor.tsx new file mode 100644 index 0000000..7a4eed0 --- /dev/null +++ b/apps/web/src/components/git-mount-editor.tsx @@ -0,0 +1,229 @@ +import { useState } from "react"; +import { Icon } from "./icon"; +import type { GitMount } from "../api/config_profiles"; +import type { GitRepository } from "../api/git_repositories"; + +interface GitMountEditorProps { + mounts: GitMount[]; + repositories: GitRepository[]; + onChange: (mounts: GitMount[]) => void; +} + +export const GitMountEditor = ({ mounts, repositories, onChange }: GitMountEditorProps) => { + const [editingIndex, setEditingIndex] = useState(null); + const [newMount, setNewMount] = useState({ + repo_id: "", + source_path: ".", + target_path: "", + branch: "", + }); + + const handleAdd = () => { + if (!newMount.repo_id || !newMount.target_path) return; + onChange([...mounts, { ...newMount }]); + setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" }); + }; + + const handleUpdate = (index: number, updated: GitMount) => { + const updatedMounts = [...mounts]; + updatedMounts[index] = updated; + 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 "Target path must be absolute"; + if (!isTarget && path.startsWith("/")) return "Source path must be relative"; + return null; + }; + + return ( +
+

Git Mounts

+ + {mounts.length > 0 && ( +
+ {mounts.map((mount, index) => ( +
+ {editingIndex === index ? ( + handleUpdate(index, updated)} + onCancel={() => setEditingIndex(null)} + validatePath={validatePath} + /> + ) : ( +
+
+ + {repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id} + + + {mount.source_path || "."} → {mount.target_path} + + {mount.branch && ( + @{mount.branch} + )} +
+
+ + +
+
+ )} +
+ ))} +
+ )} + +
+
Add Git Mount
+ setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })} + validatePath={validatePath} + isNew + /> +
+
+ ); +}; + +interface GitMountFormProps { + mount: GitMount; + repositories: GitRepository[]; + onSave: (mount: GitMount) => void; + onCancel: () => void; + validatePath: (path: string, isTarget: boolean) => string | null; + isNew?: boolean; +} + +const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, isNew }: GitMountFormProps) => { + const [form, setForm] = useState({ ...mount }); + const [errors, setErrors] = useState>({}); + + const handleChange = (field: keyof GitMount, value: string) => { + setForm((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + } + }; + + const handleSubmit = () => { + const newErrors: Record = {}; + + if (!form.repo_id) { + newErrors.repo_id = "Repository is required"; + } + + 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({ repo_id: "", source_path: ".", target_path: "", branch: "" }); + } + }; + + return ( +
+
+ + + {errors.repo_id && {errors.repo_id}} +
+ +
+ + handleChange("source_path", e.target.value)} + placeholder="e.g., . or configs/*.json" + className={errors.source_path ? "error" : ""} + /> + Relative path in repo (supports glob patterns) + {errors.source_path && {errors.source_path}} +
+ +
+ + handleChange("target_path", e.target.value)} + placeholder="e.g., /app/config" + className={errors.target_path ? "error" : ""} + /> + Absolute path inside container + {errors.target_path && {errors.target_path}} +
+ +
+ + handleChange("branch", e.target.value)} + placeholder="e.g., main or v1.0" + /> + Branch or tag to checkout +
+ +
+ + +
+
+ ); +}; diff --git a/apps/web/src/pages/config-profiles.tsx b/apps/web/src/pages/config-profiles.tsx index 0e06106..255744c 100644 --- a/apps/web/src/pages/config-profiles.tsx +++ b/apps/web/src/pages/config-profiles.tsx @@ -19,8 +19,10 @@ import { type ResolvedProfile, } from "../api/config_profiles"; import { listProjects } from "../api/projects"; +import { listRepositories, type GitRepository } from "../api/git_repositories"; import type { Project } from "../types"; import { listToolTypes, type ToolType } from "../api/tool_types"; +import { GitMountEditor } from "../components/git-mount-editor"; type Status = "loading" | "ready" | "error"; type MobileView = "list" | "detail" | "edit"; @@ -32,6 +34,7 @@ export const ConfigProfilesPage = () => { const [profiles, setProfiles] = useState([]); const [projects, setProjects] = useState([]); const [toolTypes, setToolTypes] = useState([]); + const [repositories, setRepositories] = useState([]); const [selectedProfileId, setSelectedProfileId] = useState(null); const [isCreating, setIsCreating] = useState(false); @@ -47,6 +50,7 @@ export const ConfigProfilesPage = () => { env_vars: {}, runtime_hints: {}, mounts: [], + git_mounts: [], files: {}, is_default: false, }); @@ -67,6 +71,19 @@ export const ConfigProfilesPage = () => { setProfiles(profs || []); setProjects(projs || []); setToolTypes(types || []); + + // Load repositories from all projects + const allRepos: GitRepository[] = []; + for (const project of projs || []) { + try { + const repos = await listRepositories(project.id); + allRepos.push(...repos); + } catch { + // Skip projects we can't access + } + } + setRepositories(allRepos); + setStatus("ready"); } catch { setStatus("error"); @@ -84,6 +101,7 @@ export const ConfigProfilesPage = () => { env_vars: {}, runtime_hints: {}, mounts: [], + git_mounts: [], files: {}, is_default: false, }); @@ -102,6 +120,7 @@ export const ConfigProfilesPage = () => { env_vars: profile.env_vars, runtime_hints: profile.runtime_hints, mounts: profile.mounts, + git_mounts: profile.git_mounts || [], files: profile.files, is_default: profile.is_default, }); @@ -1239,6 +1258,14 @@ export const ConfigProfilesPage = () => { +
+ updateFormField("git_mounts", git_mounts)} + /> +
+