feat: simplify git mounts to use direct URLs instead of repo references
- Change git mount schema from repo_id to remote_url - Remove database lookups for git mount resolution - Clone directly from URL at instance startup - Simplify frontend UI to text input for Git URL - Fix route ordering in git_repositories.py to prevent 422 errors - Update all tests to use remote_url field Breaking change: Git mounts now use remote_url instead of repo_id
This commit is contained in:
@@ -58,18 +58,16 @@ def _calculate_profile_size(data: dict) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class GitMountItem(BaseModel):
|
class GitMountItem(BaseModel):
|
||||||
repo_id: str = Field(description="UUID of the git repository")
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||||
target_path: str = Field(description="Absolute path inside container")
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
|
|
||||||
@field_validator("repo_id")
|
@field_validator("remote_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_repo_id(cls, v: str) -> str:
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
try:
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
uuid.UUID(v)
|
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"Invalid repo_id UUID: {v}")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("source_path")
|
@field_validator("source_path")
|
||||||
@@ -271,53 +269,23 @@ async def _validate_git_mounts(
|
|||||||
git_mounts: list[dict],
|
git_mounts: list[dict],
|
||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate that all referenced git repositories exist and are accessible.
|
"""Validate git mount URLs.
|
||||||
|
|
||||||
Repositories must:
|
Simply checks that remote_url looks like a valid git URL.
|
||||||
1. Exist
|
Actual clone validation happens at instance startup time.
|
||||||
2. Belong to the user (external repos with no project are allowed)
|
|
||||||
3. If project_id is specified, repos can be either:
|
|
||||||
- External repos (project_id is null) belonging to the user
|
|
||||||
- Project repos belonging to that project
|
|
||||||
"""
|
"""
|
||||||
for mount in git_mounts:
|
for mount in git_mounts:
|
||||||
repo_id = mount.get("repo_id")
|
remote_url = mount.get("remote_url")
|
||||||
if not repo_id:
|
if not remote_url:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Git mount missing repo_id",
|
detail="Git mount missing remote_url",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
repo_uuid = uuid.UUID(repo_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Invalid repo_id UUID: {repo_id}",
|
detail=f"Invalid git URL: {remote_url}",
|
||||||
)
|
|
||||||
|
|
||||||
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}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# External repos (no project) are always allowed for git mounts
|
|
||||||
if repo.project_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Project repos are allowed if they belong to the profile's project
|
|
||||||
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}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -232,36 +232,6 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{project_id}/repositories",
|
|
||||||
response_model=list[GitRepositoryResponse],
|
|
||||||
summary="List repositories",
|
|
||||||
description="List all git repositories in a project.",
|
|
||||||
)
|
|
||||||
async def list_repositories(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[GitRepository]:
|
|
||||||
"""List all repositories in a project.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of repositories in the project.
|
|
||||||
"""
|
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/repositories",
|
"/repositories",
|
||||||
response_model=list[GitRepositoryResponse],
|
response_model=list[GitRepositoryResponse],
|
||||||
@@ -287,45 +257,6 @@ async def list_user_repositories(
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{project_id}/repositories/{repo_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
summary="Delete a repository",
|
|
||||||
description="Delete a git repository from the project and remove it from disk.",
|
|
||||||
)
|
|
||||||
async def delete_repository(
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> Response:
|
|
||||||
"""Delete a repository.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: UUID of the project.
|
|
||||||
repo_id: UUID of the repository to delete.
|
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Empty response with 204 status code.
|
|
||||||
"""
|
|
||||||
_user = await _get_user(session, user_id)
|
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
|
||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
|
||||||
if repo is None or repo.project_id != project_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
|
||||||
|
|
||||||
# Remove from disk
|
|
||||||
if os.path.exists(repo.path):
|
|
||||||
shutil.rmtree(repo.path)
|
|
||||||
|
|
||||||
await session.delete(repo)
|
|
||||||
await session.commit()
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/repositories/parse-url",
|
"/repositories/parse-url",
|
||||||
response_model=URLParseResponse,
|
response_model=URLParseResponse,
|
||||||
@@ -451,6 +382,75 @@ async def create_external_repository(
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{project_id}/repositories",
|
||||||
|
response_model=list[GitRepositoryResponse],
|
||||||
|
summary="List repositories",
|
||||||
|
description="List all git repositories in a project.",
|
||||||
|
)
|
||||||
|
async def list_repositories(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[GitRepository]:
|
||||||
|
"""List all repositories in a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of repositories in the project.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{project_id}/repositories/{repo_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a repository",
|
||||||
|
description="Delete a git repository from the project and remove it from disk.",
|
||||||
|
)
|
||||||
|
async def delete_repository(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> Response:
|
||||||
|
"""Delete a repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository to delete.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Empty response with 204 status code.
|
||||||
|
"""
|
||||||
|
_user = await _get_user(session, user_id)
|
||||||
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, repo_id)
|
||||||
|
if repo is None or repo.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
||||||
|
|
||||||
|
# Remove from disk
|
||||||
|
if os.path.exists(repo.path):
|
||||||
|
shutil.rmtree(repo.path)
|
||||||
|
|
||||||
|
await session.delete(repo)
|
||||||
|
await session.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{project_id}/repositories",
|
"/{project_id}/repositories",
|
||||||
response_model=GitRepositoryResponse,
|
response_model=GitRepositoryResponse,
|
||||||
|
|||||||
@@ -96,78 +96,58 @@ async def _resolve_single_git_mount(
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Resolve a single git mount to volume mount entries.
|
"""Resolve a single git mount to volume mount entries.
|
||||||
|
|
||||||
|
Clones directly from remote_url, no database lookup needed.
|
||||||
Returns a list of volume mounts (one for each matched file/directory).
|
Returns a list of volume mounts (one for each matched file/directory).
|
||||||
"""
|
"""
|
||||||
repo_id = git_mount.get("repo_id")
|
remote_url = git_mount.get("remote_url")
|
||||||
source_path = git_mount.get("source_path", ".")
|
source_path = git_mount.get("source_path", ".")
|
||||||
target_path = git_mount.get("target_path")
|
target_path = git_mount.get("target_path")
|
||||||
branch = git_mount.get("branch")
|
branch = git_mount.get("branch")
|
||||||
|
|
||||||
if not repo_id or not target_path:
|
if not remote_url or not target_path:
|
||||||
logger.warning("Invalid git mount skipped: missing repo_id or target_path")
|
logger.warning("Invalid git mount skipped: missing remote_url or target_path")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
if not instance_dir:
|
||||||
repo_uuid = uuid.UUID(repo_id)
|
logger.warning("Git mount skipped: no instance_dir provided for cloning")
|
||||||
except ValueError:
|
|
||||||
logger.warning("Invalid git mount skipped: repo_id is not a valid UUID: %s", repo_id)
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
repo = await session.get(GitRepository, repo_uuid)
|
# Generate a unique directory name from the URL
|
||||||
if repo is None:
|
import hashlib
|
||||||
logger.warning("Git mount skipped: repository %s not found", repo_id)
|
url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12]
|
||||||
return []
|
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
|
||||||
|
clone_dir = os.path.join(instance_dir, "git-mounts", f"{repo_name}-{url_hash}")
|
||||||
|
|
||||||
# Determine repo path - use existing or auto-clone
|
# Clone or pull the repository
|
||||||
repo_path = repo.path
|
repo_path = clone_dir
|
||||||
|
if not os.path.exists(clone_dir):
|
||||||
if not repo_path or not os.path.exists(repo_path):
|
try:
|
||||||
# Auto-clone if remote URL is available and instance_dir is provided
|
repo_path = await asyncio.to_thread(
|
||||||
if repo.remote_url and instance_dir:
|
clone_repository,
|
||||||
try:
|
remote_url,
|
||||||
ssh_key_path = None
|
None, # No SSH key for now - can be added later
|
||||||
if repo.ssh_key_id:
|
os.path.dirname(clone_dir),
|
||||||
from src.models.ssh_key import SSHKey
|
branch or "main",
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
logger.info("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 []
|
return []
|
||||||
else:
|
else:
|
||||||
# Repo exists - pull latest updates
|
# Repo exists - pull latest updates
|
||||||
if repo.remote_url:
|
try:
|
||||||
try:
|
await asyncio.to_thread(_pull_repository_updates, repo_path, remote_url)
|
||||||
await asyncio.to_thread(_pull_repository_updates, repo_path, repo.remote_url)
|
logger.info("Pulled updates for git mount %s", remote_url)
|
||||||
logger.info("Pulled updates for repository %s", repo.name)
|
except Exception as exc:
|
||||||
except Exception as exc:
|
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
|
||||||
logger.warning("Failed to pull updates for repository %s: %s", repo.name, exc)
|
|
||||||
# Continue with existing code as fallback
|
|
||||||
|
|
||||||
# Handle branch checkout if specified
|
# Handle branch checkout if specified
|
||||||
if branch and repo_path:
|
if branch and repo_path:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_checkout_branch, repo_path, branch)
|
await asyncio.to_thread(_checkout_branch, repo_path, branch)
|
||||||
logger.info("Checked out branch %s for repository %s", branch, repo.name)
|
logger.info("Checked out branch %s for %s", branch, remote_url)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Branch checkout failed for %s@%s: %s", repo.name, branch, exc)
|
logger.warning("Branch checkout failed for %s@%s: %s", remote_url, branch, exc)
|
||||||
# Continue with current branch as fallback
|
|
||||||
|
|
||||||
# Build source path and expand globs
|
# Build source path and expand globs
|
||||||
if source_path and source_path != ".":
|
if source_path and source_path != ".":
|
||||||
@@ -179,7 +159,7 @@ async def _resolve_single_git_mount(
|
|||||||
matched_paths = _expand_glob_source(source_full, repo_path)
|
matched_paths = _expand_glob_source(source_full, repo_path)
|
||||||
|
|
||||||
if not matched_paths:
|
if not matched_paths:
|
||||||
logger.warning("Git mount skipped: no files matched source path %s in repository %s", source_path, repo_id)
|
logger.warning("Git mount skipped: no files matched source path %s in %s", source_path, remote_url)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
volume_mounts = []
|
volume_mounts = []
|
||||||
@@ -201,7 +181,7 @@ async def _resolve_single_git_mount(
|
|||||||
"target": final_target,
|
"target": final_target,
|
||||||
"type": "bind",
|
"type": "bind",
|
||||||
})
|
})
|
||||||
logger.info("Added git mount: %s -> %s (repo: %s)", matched_path, final_target, repo.name)
|
logger.info("Added git mount: %s -> %s (url: %s)", matched_path, final_target, remote_url)
|
||||||
|
|
||||||
return volume_mounts
|
return volume_mounts
|
||||||
|
|
||||||
|
|||||||
@@ -176,13 +176,13 @@ def _merge_git_mounts(
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Merge git mounts from included profiles.
|
"""Merge git mounts from included profiles.
|
||||||
|
|
||||||
Later mounts override earlier ones with the same repo_id + target_path combo.
|
Later mounts override earlier ones with the same remote_url + target_path combo.
|
||||||
"""
|
"""
|
||||||
result = list(base)
|
result = list(base)
|
||||||
# Build lookup by (repo_id, target_path)
|
# Build lookup by (remote_url, target_path)
|
||||||
seen = {(m["repo_id"], m["target_path"]): i for i, m in enumerate(result)}
|
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||||
for mount in overlay:
|
for mount in overlay:
|
||||||
key = (mount["repo_id"], mount["target_path"])
|
key = (mount["remote_url"], mount["target_path"])
|
||||||
if key in seen:
|
if key in seen:
|
||||||
result[seen[key]] = dict(mount)
|
result[seen[key]] = dict(mount)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
@@ -369,7 +369,7 @@ class TestConfigProfilesAPI:
|
|||||||
json={
|
json={
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": "config",
|
"source_path": "config",
|
||||||
"target_path": "/config",
|
"target_path": "/config",
|
||||||
}
|
}
|
||||||
@@ -393,7 +393,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": "/absolute/path",
|
"source_path": "/absolute/path",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -414,7 +414,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "relative/path",
|
"target_path": "relative/path",
|
||||||
}
|
}
|
||||||
@@ -436,7 +436,7 @@ class TestConfigProfilesAPI:
|
|||||||
"files": {},
|
"files": {},
|
||||||
"git_mounts": [
|
"git_mounts": [
|
||||||
{
|
{
|
||||||
"repo_id": repo_id,
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
"source_path": ".",
|
"source_path": ".",
|
||||||
"target_path": "/app",
|
"target_path": "/app",
|
||||||
}
|
}
|
||||||
@@ -450,4 +450,4 @@ class TestConfigProfilesAPI:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data["git_mounts"]) == 1
|
assert len(data["git_mounts"]) == 1
|
||||||
assert data["git_mounts"][0]["repo_id"] == repo_id
|
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||||
|
|||||||
@@ -102,18 +102,18 @@ class TestMergeFunctions:
|
|||||||
"""Test basic git mount merging."""
|
"""Test basic git mount merging."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[],
|
[],
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["repo_id"] == "repo1"
|
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result[0]["target_path"] == "/app"
|
assert result[0]["target_path"] == "/app"
|
||||||
|
|
||||||
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
||||||
"""Test that git mounts with same repo+target override."""
|
"""Test that git mounts with same repo+target override."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||||
[{"repo_id": "repo1", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
@@ -123,8 +123,8 @@ class TestMergeFunctions:
|
|||||||
def test_merge_git_mounts_different_targets(self) -> None:
|
def test_merge_git_mounts_different_targets(self) -> None:
|
||||||
"""Test that git mounts with different targets are preserved."""
|
"""Test that git mounts with different targets are preserved."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"repo_id": "repo1", "source_path": ".", "target_path": "/app"}],
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
[{"repo_id": "repo2", "source_path": ".", "target_path": "/config"}],
|
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
@@ -296,7 +296,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(profile)
|
db_session.add(profile)
|
||||||
@@ -304,7 +304,7 @@ class TestResolveProfile:
|
|||||||
|
|
||||||
result = await resolve_profile(db_session, profile.id)
|
result = await resolve_profile(db_session, profile.id)
|
||||||
assert len(result.git_mounts) == 1
|
assert len(result.git_mounts) == 1
|
||||||
assert result.git_mounts[0]["repo_id"] == "repo1"
|
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result.git_mounts[0]["target_path"] == "/app"
|
assert result.git_mounts[0]["target_path"] == "/app"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -320,7 +320,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo1", "source_path": ".", "target_path": "/app"},
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(base)
|
db_session.add(base)
|
||||||
@@ -333,7 +333,7 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"repo_id": "repo2", "source_path": "config", "target_path": "/config"},
|
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(child)
|
db_session.add(child)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface ConfigProfileMount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GitMount {
|
export interface GitMount {
|
||||||
repo_id: string;
|
remote_url: string;
|
||||||
source_path: string;
|
source_path: string;
|
||||||
target_path: string;
|
target_path: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { GitMount } from "../api/config_profiles";
|
import type { GitMount } from "../api/config_profiles";
|
||||||
import type { GitRepository } from "../api/git_repositories";
|
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
repositories: GitRepository[];
|
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
onCreateRepository?: (name: string, remoteUrl: string) => Promise<GitRepository>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, repositories, onChange, onCreateRepository }: GitMountEditorProps) => {
|
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [newMount, setNewMount] = useState<GitMount>({
|
const [newMount, setNewMount] = useState<GitMount>({
|
||||||
repo_id: "",
|
remote_url: "",
|
||||||
source_path: ".",
|
source_path: ".",
|
||||||
target_path: "",
|
target_path: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!newMount.repo_id || !newMount.target_path) return;
|
if (!newMount.remote_url || !newMount.target_path) return;
|
||||||
onChange([...mounts, { ...newMount }]);
|
onChange([...mounts, { ...newMount }]);
|
||||||
setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = (index: number, updated: GitMount) => {
|
const handleUpdate = (index: number, updated: GitMount) => {
|
||||||
@@ -44,6 +41,14 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateUrl = (url: string): string | null => {
|
||||||
|
if (!url) return "Git URL is required";
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
|
||||||
|
return "Must be a valid git URL (https://, git@, or ssh://)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-editor">
|
<div className="git-mount-editor">
|
||||||
<h4 className="section-subtitle">Git Mounts</h4>
|
<h4 className="section-subtitle">Git Mounts</h4>
|
||||||
@@ -55,18 +60,15 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
{editingIndex === index ? (
|
{editingIndex === index ? (
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={mount}
|
mount={mount}
|
||||||
repositories={repositories}
|
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
onCreateRepository={onCreateRepository}
|
validateUrl={validateUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="git-mount-display">
|
<div className="git-mount-display">
|
||||||
<div className="git-mount-info">
|
<div className="git-mount-info">
|
||||||
<span className="git-mount-repo">
|
<span className="git-mount-repo">{mount.remote_url}</span>
|
||||||
{repositories.find((r) => r.id === mount.repo_id)?.name || mount.repo_id}
|
|
||||||
</span>
|
|
||||||
<span className="git-mount-paths">
|
<span className="git-mount-paths">
|
||||||
{mount.source_path || "."} → {mount.target_path}
|
{mount.source_path || "."} → {mount.target_path}
|
||||||
</span>
|
</span>
|
||||||
@@ -103,11 +105,10 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
<h5>Add Git Mount</h5>
|
<h5>Add Git Mount</h5>
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={newMount}
|
mount={newMount}
|
||||||
repositories={repositories}
|
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setNewMount({ repo_id: "", source_path: ".", target_path: "", branch: "" })}
|
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
||||||
validatePath={validatePath}
|
validatePath={validatePath}
|
||||||
onCreateRepository={onCreateRepository}
|
validateUrl={validateUrl}
|
||||||
isNew
|
isNew
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,21 +118,16 @@ export const GitMountEditor = ({ mounts, repositories, onChange, onCreateReposit
|
|||||||
|
|
||||||
interface GitMountFormProps {
|
interface GitMountFormProps {
|
||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
repositories: GitRepository[];
|
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
validatePath: (path: string, isTarget: boolean) => string | null;
|
||||||
onCreateRepository?: (name: string, remoteUrl: string) => Promise<GitRepository>;
|
validateUrl: (url: string) => string | null;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onCreateRepository, isNew }: GitMountFormProps) => {
|
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
||||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
const [form, setForm] = useState<GitMount>({ ...mount });
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [isCreatingRepo, setIsCreatingRepo] = useState(false);
|
|
||||||
const [newRepoName, setNewRepoName] = useState("");
|
|
||||||
const [newRepoUrl, setNewRepoUrl] = useState("");
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleChange = (field: keyof GitMount, value: string) => {
|
const handleChange = (field: keyof GitMount, value: string) => {
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
@@ -144,32 +140,11 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onC
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRepo = async () => {
|
|
||||||
if (!onCreateRepository || !newRepoName.trim() || !newRepoUrl.trim()) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const repo = await onCreateRepository(newRepoName.trim(), newRepoUrl.trim());
|
|
||||||
handleChange("repo_id", repo.id);
|
|
||||||
setIsCreatingRepo(false);
|
|
||||||
setNewRepoName("");
|
|
||||||
setNewRepoUrl("");
|
|
||||||
} catch (err) {
|
|
||||||
setErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
repo_id: err instanceof Error ? err.message : "Failed to create repository",
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
if (!form.repo_id) {
|
const urlError = validateUrl(form.remote_url);
|
||||||
newErrors.repo_id = "Repository is required";
|
if (urlError) newErrors.remote_url = urlError;
|
||||||
}
|
|
||||||
|
|
||||||
const sourceError = validatePath(form.source_path || ".", false);
|
const sourceError = validatePath(form.source_path || ".", false);
|
||||||
if (sourceError) newErrors.source_path = sourceError;
|
if (sourceError) newErrors.source_path = sourceError;
|
||||||
@@ -184,79 +159,23 @@ const GitMountForm = ({ mount, repositories, onSave, onCancel, validatePath, onC
|
|||||||
|
|
||||||
onSave(form);
|
onSave(form);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
setForm({ repo_id: "", source_path: ".", target_path: "", branch: "" });
|
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-form">
|
<div className="git-mount-form">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>Repository</label>
|
<label>Git URL</label>
|
||||||
{!isCreatingRepo ? (
|
<input
|
||||||
<>
|
type="text"
|
||||||
<select
|
value={form.remote_url}
|
||||||
value={form.repo_id}
|
onChange={(e) => handleChange("remote_url", e.target.value)}
|
||||||
onChange={(e) => {
|
placeholder="https://github.com/user/repo.git"
|
||||||
if (e.target.value === "__new__") {
|
className={errors.remote_url ? "error" : ""}
|
||||||
setIsCreatingRepo(true);
|
/>
|
||||||
} else {
|
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
||||||
handleChange("repo_id", e.target.value);
|
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={errors.repo_id ? "error" : ""}
|
|
||||||
>
|
|
||||||
<option value="">Select a repository...</option>
|
|
||||||
{repositories.map((repo) => (
|
|
||||||
<option key={repo.id} value={repo.id}>
|
|
||||||
{repo.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{onCreateRepository && (
|
|
||||||
<option value="__new__">+ Add new repository...</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
{errors.repo_id && <span className="error-text">{errors.repo_id}</span>}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="new-repo-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newRepoName}
|
|
||||||
onChange={(e) => setNewRepoName(e.target.value)}
|
|
||||||
placeholder="Repository name"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newRepoUrl}
|
|
||||||
onChange={(e) => setNewRepoUrl(e.target.value)}
|
|
||||||
placeholder="https://github.com/user/repo.git"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
<div className="new-repo-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="primary-button small"
|
|
||||||
onClick={handleCreateRepo}
|
|
||||||
disabled={isSubmitting || !newRepoName.trim() || !newRepoUrl.trim()}
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Creating..." : "Create Repository"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="secondary-button small"
|
|
||||||
onClick={() => {
|
|
||||||
setIsCreatingRepo(false);
|
|
||||||
setNewRepoName("");
|
|
||||||
setNewRepoUrl("");
|
|
||||||
}}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import { listAllUserRepositories, createExternalRepository, type GitRepository } from "../api/git_repositories";
|
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { GitMountEditor } from "../components/git-mount-editor";
|
import { GitMountEditor } from "../components/git-mount-editor";
|
||||||
@@ -34,7 +33,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
|
||||||
|
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -72,14 +70,6 @@ export const ConfigProfilesPage = () => {
|
|||||||
setProjects(projs || []);
|
setProjects(projs || []);
|
||||||
setToolTypes(types || []);
|
setToolTypes(types || []);
|
||||||
|
|
||||||
// Load all user repositories (including external ones)
|
|
||||||
try {
|
|
||||||
const allRepos = await listAllUserRepositories();
|
|
||||||
setRepositories(allRepos);
|
|
||||||
} catch {
|
|
||||||
setRepositories([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -1257,16 +1247,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<GitMountEditor
|
<GitMountEditor
|
||||||
mounts={formData.git_mounts || []}
|
mounts={formData.git_mounts || []}
|
||||||
repositories={repositories}
|
|
||||||
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
onChange={(git_mounts) => updateFormField("git_mounts", git_mounts)}
|
||||||
onCreateRepository={async (name, remoteUrl) => {
|
|
||||||
const repo = await createExternalRepository({
|
|
||||||
name,
|
|
||||||
remote_url: remoteUrl,
|
|
||||||
});
|
|
||||||
setRepositories((prev) => [...prev, repo]);
|
|
||||||
return repo;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -73,3 +73,10 @@
|
|||||||
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
- [x] 9.6 Update spec: auto-clone to persistent location on every container creation
|
||||||
- [x] 9.7 Update spec: pull updates when creating new containers
|
- [x] 9.7 Update spec: pull updates when creating new containers
|
||||||
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
- [x] 9.8 Update spec: per-instance isolation (no shared clones)
|
||||||
|
|
||||||
|
## 10. UI Improvements
|
||||||
|
|
||||||
|
- [x] 10.1 Add ability to create external repositories from git mount editor
|
||||||
|
- [x] 10.2 Show "+ Add new repository..." option in repo dropdown
|
||||||
|
- [x] 10.3 Add form fields for repo name and remote URL
|
||||||
|
- [x] 10.4 Auto-refresh repo list after creating new repository
|
||||||
|
|||||||
Reference in New Issue
Block a user