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
This commit is contained in:
Alex Blank
2026-05-26 22:27:04 +02:00
parent adda76a2ff
commit 4c11163bff
15 changed files with 1118 additions and 3 deletions
@@ -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")
+104
View File
@@ -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()
+207 -2
View File
@@ -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)
+3
View File
@@ -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()
@@ -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,
}
+11
View File
@@ -10,6 +10,7 @@ export interface ConfigProfile {
env_vars: Record<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ConfigProfileMount[];
git_mounts: GitMount[];
files: Record<string, string>;
is_default: boolean;
includes: ConfigProfileInclude[];
@@ -23,6 +24,13 @@ export interface ConfigProfileMount {
files: Record<string, string>;
}
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<string, string>;
runtime_hints: Record<string, unknown>;
mounts: ResolvedMount[];
git_mounts: GitMount[];
files: Record<string, string>;
overrides: {
env_vars: Record<string, string>;
@@ -60,6 +69,7 @@ export interface CreateConfigProfileRequest {
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
@@ -72,6 +82,7 @@ export interface UpdateConfigProfileRequest {
env_vars?: Record<string, string>;
runtime_hints?: Record<string, unknown>;
mounts?: ConfigProfileMount[];
git_mounts?: GitMount[];
files?: Record<string, string>;
is_default?: boolean;
}
@@ -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<number | null>(null);
const [newMount, setNewMount] = useState<GitMount>({
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 (
<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">
{editingIndex === index ? (
<GitMountForm
mount={mount}
repositories={repositories}
onSave={(updated) => handleUpdate(index, updated)}
onCancel={() => setEditingIndex(null)}
validatePath={validatePath}
/>
) : (
<div className="git-mount-display">
<div className="git-mount-info">
<span className="git-mount-repo">
{repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id}
</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>
)}
</div>
))}
</div>
)}
<div className="git-mount-add">
<h5>Add Git Mount</h5>
<GitMountForm
mount={newMount}
repositories={repositories}
onSave={handleAdd}
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
validatePath={validatePath}
isNew
/>
</div>
</div>
);
};
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<GitMount>({ ...mount });
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="git-mount-form">
<div className="form-row">
<label>Repository</label>
<select
value={form.repo_id}
onChange={(e) => handleChange("repo_id", e.target.value)}
className={errors.repo_id ? "error" : ""}
>
<option value="">Select a repository...</option>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
</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>
<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">Absolute path inside container</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}>
{isNew ? "Add" : "Save"}
</button>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
</div>
</div>
);
};
+27
View File
@@ -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<ConfigProfile[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(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 = () => {
</button>
</div>
<div className="form-section">
<GitMountEditor
mounts={formData.git_mounts || []}
repositories={repositories}
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
/>
</div>
<div className="dialog-actions" style={{ marginTop: "1rem", position: "sticky", bottom: "1rem", background: "var(--surface)", padding: "1rem", borderRadius: "0.5rem", border: "1px solid var(--border)" }}>
<button type="submit" disabled={saveStatus === "saving"}>
<Icon name={isCreating ? "add" : "save"} size="sm" />
+125
View File
@@ -4006,3 +4006,128 @@ a.nav-item,
to { transform: translateY(0); }
}
/* Git Mount Editor Styles */
.git-mount-editor {
margin-top: 1rem;
}
.git-mount-editor .section-subtitle {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
}
.git-mount-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.git-mount-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.75rem;
}
.git-mount-display {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.git-mount-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-repo {
font-weight: 600;
color: var(--text);
}
.git-mount-paths {
font-size: 0.875rem;
color: var(--text-muted);
font-family: monospace;
}
.git-mount-branch {
font-size: 0.75rem;
color: var(--accent);
background: var(--accent-bg);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
width: fit-content;
}
.git-mount-actions {
display: flex;
gap: 0.25rem;
}
.git-mount-add {
border-top: 1px solid var(--border);
padding-top: 1rem;
margin-top: 1rem;
}
.git-mount-add h5 {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.git-mount-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.git-mount-form .form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.git-mount-form .form-row label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
}
.git-mount-form .form-row input,
.git-mount-form .form-row select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.git-mount-form .form-row input.error,
.git-mount-form .form-row select.error {
border-color: #cd3131;
}
.git-mount-form .form-row .hint {
font-size: 0.75rem;
color: var(--text-muted);
}
.git-mount-form .form-row .error-text {
font-size: 0.75rem;
color: #cd3131;
}
.git-mount-form .form-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}