diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py
index 87c41d7..1a58249 100644
--- a/apps/api/src/api/config_profiles.py
+++ b/apps/api/src/api/config_profiles.py
@@ -2,7 +2,6 @@
import logging
import uuid
-from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
@@ -16,124 +15,39 @@ from src.models.config_profile import ConfigProfile
from src.models.tool_type import ToolType
from src.models.user_config import UserConfig
from src.schemas.config_profile import (
- ConfigProfileCreate,
- ConfigProfileUpdate,
- ConfigProfileResponse,
- ConfigProfileDetailResponse,
ConfigIncludeCreate,
ConfigIncludeUpdate,
- ConfigIncludeResponse,
ConfigMountCreate,
ConfigMountUpdate,
- ConfigMountResponse,
+ ConfigProfileCreate,
+ ConfigProfileUpdate,
DefaultProfilesUpdate,
)
+from src.services.config_profiles import (
+ get_owned_profile,
+ validate_includes_no_cycle,
+)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
-MAX_INCLUDES_DEPTH = 10
-
-
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-async def _get_owned_profile(
- profile_id: uuid.UUID,
- user_id: uuid.UUID,
- session: AsyncSession,
-) -> ConfigProfile:
- """Fetch a config profile and verify ownership."""
- profile = await session.get(ConfigProfile, profile_id)
- if profile is None or profile.user_id != user_id:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="config profile not found",
- )
- return profile
-
-
-async def _detect_cycle(
- session: AsyncSession,
- profile_id: uuid.UUID,
- visited: set[uuid.UUID] | None = None,
- depth: int = 0,
-) -> bool:
- """Detect cycles in profile includes using DFS.
-
- Returns True if a cycle is detected.
- """
- if depth > MAX_INCLUDES_DEPTH:
- return True
-
- if visited is None:
- visited = set()
-
- if profile_id in visited:
- return True
-
- visited.add(profile_id)
-
- result = await session.execute(
- select(ConfigInclude.included_profile_id).where(
- ConfigInclude.profile_id == profile_id
- )
- )
- included_ids = result.scalars().all()
-
- for included_id in included_ids:
- if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
- return True
-
- return False
-
-
-async def _validate_includes_no_cycle(
- session: AsyncSession,
- profile_id: uuid.UUID,
- new_included_id: uuid.UUID | None = None,
-) -> None:
- """Validate that adding an include wouldn't create a cycle."""
- if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="adding this include would create a circular reference",
- )
-
# ---------------------------------------------------------------------------
# Profile CRUD
# ---------------------------------------------------------------------------
-@router.get(
- "",
- summary="List config profiles",
- description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
-)
+@router.get("", summary="List config profiles")
async def list_config_profiles(
tool_type_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """List config profiles for the current user."""
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
-
- # If tool_type_id is provided, filter to compatible profiles
- # For now, all profiles are considered compatible with all tool types
- # since there's no explicit compatibility matrix. Future enhancement:
- # could filter by profile tags or mount path patterns.
if tool_type_id:
- # Validate the tool type exists
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="tool type not found",
- )
- # All profiles are compatible; just return user's profiles
- pass
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
result = await session.execute(query.order_by(ConfigProfile.name))
profiles = result.scalars().all()
@@ -153,19 +67,12 @@ async def list_config_profiles(
}
-@router.post(
- "",
- summary="Create config profile",
- description="Create a new config profile.",
- status_code=status.HTTP_201_CREATED,
-)
+@router.post("", status_code=status.HTTP_201_CREATED, summary="Create config profile")
async def create_config_profile(
data: ConfigProfileCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Create a config profile."""
- # Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
@@ -178,11 +85,7 @@ async def create_config_profile(
detail=f"config profile with name '{data.name}' already exists",
)
- profile = ConfigProfile(
- user_id=user_id,
- name=data.name,
- description=data.description,
- )
+ profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
session.add(profile)
await session.commit()
await session.refresh(profile)
@@ -197,118 +100,68 @@ async def create_config_profile(
}
-@router.get(
- "/defaults",
- summary="Get default profiles",
- description="Get the current user's default profile assignments per tool type.",
-)
+@router.get("/defaults", summary="Get default profiles")
async def get_default_profiles(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Get default profiles for the current user."""
- result = await session.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
+ result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
-
- if user_config is None:
- return {"default_profiles": {}}
-
- return {"default_profiles": user_config.default_profiles}
+ return {"default_profiles": user_config.default_profiles if user_config else {}}
-@router.put(
- "/defaults",
- summary="Set default profiles",
- description="Set the current user's default profile assignments per tool type.",
-)
+@router.put("/defaults", summary="Set default profiles")
async def set_default_profiles(
data: DefaultProfilesUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Set default profiles for the current user."""
- result = await session.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
+ result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
if user_config is None:
user_config = UserConfig(user_id=user_id, config={})
session.add(user_config)
- # Validate all profile IDs belong to the user
for tool_type_id, profile_id_str in data.default_profiles.items():
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
if profile is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"profile {profile_id_str} not found",
- )
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
if profile.user_id != user_id:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"profile {profile_id_str} does not belong to user",
- )
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
- # SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
await session.commit()
await session.refresh(user_config)
-
return {"default_profiles": user_config.default_profiles}
-@router.get(
- "/defaults/{tool_type_id}",
- summary="Get default profile for tool type",
- description="Get the default profile ID for a specific tool type.",
-)
+@router.get("/defaults/{tool_type_id}", summary="Get default profile for tool type")
async def get_default_profile_for_tool_type(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Get default profile for a specific tool type."""
- result = await session.execute(
- select(UserConfig).where(UserConfig.user_id == user_id)
- )
+ result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
user_config = result.scalar_one_or_none()
-
- if user_config is None:
- return {"tool_type_id": tool_type_id, "profile_id": None}
-
- profile_id = user_config.default_profiles.get(tool_type_id)
+ profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
-@router.get(
- "/{profile_id}",
- summary="Get config profile",
- description="Get a config profile with its includes and mounts.",
-)
+@router.get("/{profile_id}", summary="Get config profile")
async def get_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Get a config profile with includes and mounts."""
profile = await session.get(
ConfigProfile,
profile_id,
- options=[
- selectinload(ConfigProfile.includes),
- selectinload(ConfigProfile.mounts),
- ],
+ options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
)
if profile is None or profile.user_id != user_id:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="config profile not found",
- )
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
- # Fetch included profile names
includes_data = []
for inc in profile.includes:
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
@@ -348,22 +201,16 @@ async def get_config_profile(
}
-@router.put(
- "/{profile_id}",
- summary="Update config profile",
- description="Update an existing config profile.",
-)
+@router.put("/{profile_id}", summary="Update config profile")
async def update_config_profile(
profile_id: uuid.UUID,
data: ConfigProfileUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Update a config profile."""
- profile = await _get_owned_profile(profile_id, user_id, session)
+ profile = await get_owned_profile(profile_id, user_id, session)
if data.name is not None:
- # Check for duplicate name
existing = await session.scalar(
select(ConfigProfile).where(
ConfigProfile.user_id == user_id,
@@ -394,19 +241,13 @@ async def update_config_profile(
}
-@router.delete(
- "/{profile_id}",
- summary="Delete config profile",
- description="Delete a config profile and all its includes and mounts.",
- status_code=status.HTTP_204_NO_CONTENT,
-)
+@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete config profile")
async def delete_config_profile(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
- """Delete a config profile."""
- profile = await _get_owned_profile(profile_id, user_id, session)
+ profile = await get_owned_profile(profile_id, user_id, session)
await session.delete(profile)
await session.commit()
@@ -415,19 +256,13 @@ async def delete_config_profile(
# Include management
# ---------------------------------------------------------------------------
-@router.get(
- "/{profile_id}/includes",
- summary="List profile includes",
- description="Get all includes for a config profile.",
-)
+@router.get("/{profile_id}/includes", summary="List profile includes")
async def list_profile_includes(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """List includes for a config profile."""
- await _get_owned_profile(profile_id, user_id, session)
-
+ await get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigInclude)
.where(ConfigInclude.profile_id == profile_id)
@@ -451,44 +286,25 @@ async def list_profile_includes(
return {"includes": includes_data}
-@router.post(
- "/{profile_id}/includes",
- summary="Add profile include",
- description="Add an include to a config profile.",
- status_code=status.HTTP_201_CREATED,
-)
+@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED, summary="Add profile include")
async def add_profile_include(
profile_id: uuid.UUID,
data: ConfigIncludeCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Add an include to a config profile."""
- profile = await _get_owned_profile(profile_id, user_id, session)
-
+ profile = await get_owned_profile(profile_id, user_id, session)
included_profile_id = uuid.UUID(data.included_profile_id)
- # Cannot include self
if included_profile_id == profile_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="a profile cannot include itself",
- )
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
- # Verify the included profile exists and belongs to the user
included_profile = await session.get(ConfigProfile, included_profile_id)
if included_profile is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="included profile not found",
- )
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
if included_profile.user_id != user_id:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="included profile does not belong to user",
- )
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
- # Check for duplicate include
existing = await session.scalar(
select(ConfigInclude).where(
ConfigInclude.profile_id == profile_id,
@@ -496,13 +312,9 @@ async def add_profile_include(
)
)
if existing:
- raise HTTPException(
- status_code=status.HTTP_409_CONFLICT,
- detail="this include already exists",
- )
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="this include already exists")
- # Validate no cycles
- await _validate_includes_no_cycle(session, profile_id, included_profile_id)
+ await validate_includes_no_cycle(session, profile_id, included_profile_id)
include = ConfigInclude(
profile_id=profile_id,
@@ -524,11 +336,7 @@ async def add_profile_include(
}
-@router.put(
- "/{profile_id}/includes/{include_id}",
- summary="Update profile include",
- description="Update the order index of a profile include.",
-)
+@router.put("/{profile_id}/includes/{include_id}", summary="Update profile include")
async def update_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
@@ -536,15 +344,10 @@ async def update_profile_include(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Update a profile include."""
- await _get_owned_profile(profile_id, user_id, session)
-
+ await get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="include not found",
- )
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
include.order_index = data.order_index
await session.commit()
@@ -562,28 +365,17 @@ async def update_profile_include(
}
-@router.delete(
- "/{profile_id}/includes/{include_id}",
- summary="Remove profile include",
- description="Remove an include from a config profile.",
- status_code=status.HTTP_204_NO_CONTENT,
-)
+@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile include")
async def remove_profile_include(
profile_id: uuid.UUID,
include_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
- """Remove an include from a config profile."""
- await _get_owned_profile(profile_id, user_id, session)
-
+ await get_owned_profile(profile_id, user_id, session)
include = await session.get(ConfigInclude, include_id)
if include is None or include.profile_id != profile_id:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="include not found",
- )
-
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
await session.delete(include)
await session.commit()
@@ -592,19 +384,13 @@ async def remove_profile_include(
# Mount management
# ---------------------------------------------------------------------------
-@router.get(
- "/{profile_id}/mounts",
- summary="List profile mounts",
- description="Get all mounts for a config profile.",
-)
+@router.get("/{profile_id}/mounts", summary="List profile mounts")
async def list_profile_mounts(
profile_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """List mounts for a config profile."""
- await _get_owned_profile(profile_id, user_id, session)
-
+ await get_owned_profile(profile_id, user_id, session)
result = await session.execute(
select(ConfigMount)
.where(ConfigMount.profile_id == profile_id)
@@ -629,22 +415,14 @@ async def list_profile_mounts(
}
-@router.post(
- "/{profile_id}/mounts",
- summary="Add profile mount",
- description="Add a mount to a config profile.",
- status_code=status.HTTP_201_CREATED,
-)
+@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED, summary="Add profile mount")
async def add_profile_mount(
profile_id: uuid.UUID,
data: ConfigMountCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Add a mount to a config profile."""
- profile = await _get_owned_profile(profile_id, user_id, session)
-
- # Check for duplicate target_path
+ profile = await get_owned_profile(profile_id, user_id, session)
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
@@ -680,11 +458,7 @@ async def add_profile_mount(
}
-@router.put(
- "/{profile_id}/mounts/{mount_id}",
- summary="Update profile mount",
- description="Update a mount in a config profile.",
-)
+@router.put("/{profile_id}/mounts/{mount_id}", summary="Update profile mount")
async def update_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
@@ -692,18 +466,12 @@ async def update_profile_mount(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Update a profile mount."""
- await _get_owned_profile(profile_id, user_id, session)
-
+ await get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="mount not found",
- )
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
if data.target_path is not None:
- # Check for duplicate target_path
existing = await session.scalar(
select(ConfigMount).where(
ConfigMount.profile_id == profile_id,
@@ -720,7 +488,6 @@ async def update_profile_mount(
if data.files is not None:
mount.files = data.files
-
if data.order_index is not None:
mount.order_index = data.order_index
@@ -739,27 +506,16 @@ async def update_profile_mount(
}
-@router.delete(
- "/{profile_id}/mounts/{mount_id}",
- summary="Remove profile mount",
- description="Remove a mount from a config profile.",
- status_code=status.HTTP_204_NO_CONTENT,
-)
+@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile mount")
async def remove_profile_mount(
profile_id: uuid.UUID,
mount_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
- """Remove a mount from a config profile."""
- await _get_owned_profile(profile_id, user_id, session)
-
+ await get_owned_profile(profile_id, user_id, session)
mount = await session.get(ConfigMount, mount_id)
if mount is None or mount.profile_id != profile_id:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="mount not found",
- )
-
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
await session.delete(mount)
await session.commit()
diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py
index 8d7e313..d221a01 100644
--- a/apps/api/src/api/git_repositories.py
+++ b/apps/api/src/api/git_repositories.py
@@ -1,58 +1,37 @@
-import logging
-import os
-import shutil
-import subprocess
-import uuid
-from datetime import datetime
+"""Git repository API endpoints."""
-from fastapi import APIRouter, Depends, HTTPException, Response, status
-from sqlalchemy import select
+import logging
+import uuid
+
+from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
+from src.models.project import Project
+from src.models.user import User
from src.schemas.git_repository import (
- GitRepositoryCreate,
- GitRepositoryResponse,
- URLParseRequest,
- URLParseResponse,
- FileListResponse,
- FileContentResponse,
- BranchesResponse,
- FileUpdateRequest,
- FileUpdateResponse,
- StatusResponse,
BranchCreateRequest,
CheckoutRequest,
CommitRequest,
CommitResponse,
FetchResponse,
- PullResponse,
- PushResponse,
+ FileContentResponse,
+ FileListResponse,
+ FileUpdateRequest,
+ FileUpdateResponse,
+ GitRepositoryCreate,
+ GitRepositoryResponse,
MergeRequest,
MergeResponse,
+ PullResponse,
+ PushResponse,
+ StatusResponse,
+ URLParseRequest,
+ URLParseResponse,
)
-from src.config import Settings
-from src.models.git_repository import GitRepository
-from src.models.project import Project
-from src.models.user import User
-from src.utils.git_files import (
- commit_file,
- get_file_content,
- list_branches,
- list_tree,
-)
-from src.utils.git_control import (
- checkout_branch,
- commit_changes,
- create_branch,
- delete_branch,
- fetch,
- get_status,
- merge,
- pull,
- push,
-)
-from src.utils.git_history import get_commit_detail, get_commit_history
+from src.services.git import control as git_control
+from src.services.git import files as git_files
+from src.services.git.repository import create_repository, delete_repository, list_repositories
from src.utils.git_url_parser import parse_git_url
router = APIRouter(prefix="/projects", tags=["git-repositories"])
@@ -60,190 +39,18 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
logger = logging.getLogger(__name__)
-
-
-def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
- """Generate the filesystem path for a repository.
-
- Args:
- user_id: UUID of the repository owner.
- project_id: UUID of the project.
- name: Repository name.
-
- Returns:
- Absolute path to the repository directory.
- """
- base = Settings().repo_base_path or "/data/repos"
- return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
-
-
-def _build_provider_clone_url(owner: str, repo: str) -> str:
- """Build the SSH clone URL for the fixed git provider."""
- return f"git@git.commumedia.org:{owner}/{repo}.git"
-
-
-def _preflight_remote_repository(remote_url: str) -> None:
- """Verify a remote repository is reachable before cloning."""
- try:
- result = subprocess.run(
- ["git", "ls-remote", remote_url],
- capture_output=True,
- text=True,
- timeout=60,
- )
- except subprocess.TimeoutExpired:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
- except FileNotFoundError:
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
-
- if result.returncode != 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="repository not found or inaccessible",
- )
-
-
-def _clone_working_repository(remote_url: str, repo_path: str) -> None:
- try:
- result = subprocess.run(
- ["git", "clone", remote_url, repo_path],
- capture_output=True,
- text=True,
- timeout=300,
- )
- except subprocess.TimeoutExpired:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
- except FileNotFoundError:
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
-
- if result.returncode != 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"failed to clone repository: {result.stderr}",
- )
-
-
-def _init_working_repository(repo_path: str) -> None:
- try:
- result = subprocess.run(
- ["git", "init", "-b", "main", repo_path],
- capture_output=True,
- text=True,
- )
- except FileNotFoundError:
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
-
- if result.returncode == 0:
- return
-
- fallback = subprocess.run(
- ["git", "init", repo_path],
- capture_output=True,
- text=True,
- )
- if fallback.returncode != 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"failed to initialize repository: {fallback.stderr}",
- )
-
- ref_result = subprocess.run(
- ["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
- capture_output=True,
- text=True,
- )
- if ref_result.returncode != 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"failed to set initial branch: {ref_result.stderr}",
- )
-
-
-
@router.get(
"/{project_id}/repositories",
response_model=list[GitRepositoryResponse],
summary="List repositories",
- description="List all git repositories in a project.",
)
-async def list_repositories(
+async def list_repositories_endpoint(
project_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
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.
- """
-
- 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: User = Depends(get_current_user),
- project: Project = Depends(get_owned_project),
- 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.
- """
-
- 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(
- "/repositories/parse-url",
- response_model=URLParseResponse,
- summary="Parse a git URL",
- description="Parse a git URL and detect if it's a browser URL that needs correction.",
-)
-async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
- """Parse a git URL and detect if it's a browser URL that needs correction.
-
- Args:
- data: Request containing the URL to parse.
-
- Returns:
- Parsed URL information including whether it needs parsing and suggested corrections.
- """
- result = parse_git_url(data.url)
- return URLParseResponse(**result)
+):
+ return await list_repositories(session, project_id)
@router.post(
@@ -251,86 +58,49 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
response_model=GitRepositoryResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a repository",
- description="Create a new git repository in a project. Can clone from remote or initialize a working repository.",
)
-async def create_repository(
+async def create_repository_endpoint(
project_id: uuid.UUID,
data: GitRepositoryCreate,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
-) -> GitRepository:
- """Create a new git repository.
+):
+ return await create_repository(session, project_id, data, user)
- Args:
- project_id: UUID of the project.
- data: Repository creation data including name and optional remote URL.
- user_id: ID of the authenticated user.
- session: Database session.
- Returns:
- The newly created repository.
- """
+@router.delete(
+ "/{project_id}/repositories/{repo_id}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ summary="Delete a repository",
+)
+async def delete_repository_endpoint(
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ user: User = Depends(get_current_user),
+ project: Project = Depends(get_owned_project),
+ session: AsyncSession = Depends(get_db_session),
+):
+ await delete_repository(session, repo_id, project_id)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
- # Check for duplicate name
- existing = await session.execute(
- select(GitRepository).where(
- GitRepository.project_id == project_id,
- GitRepository.name == data.name,
- )
- )
- if existing.scalar_one_or_none():
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
- # Validate and potentially correct the URL
- remote_url = data.remote_url
- if remote_url and not data.force_original_url:
- parse_result = parse_git_url(remote_url)
- if parse_result["needs_parsing"] and parse_result["base_url"]:
- raise HTTPException(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- detail={
- "message": "The provided URL appears to be a browser URL, not a git clone URL",
- "suggested_url": parse_result["base_url"],
- "original_url": remote_url,
- "error_code": "URL_NEEDS_PARSING",
- },
- )
- # Use base_url if it was extracted (for URLs without .git suffix)
- if parse_result["base_url"]:
- remote_url = parse_result["base_url"]
+@router.post(
+ "/repositories/parse-url",
+ response_model=URLParseResponse,
+ summary="Parse a git URL",
+)
+async def parse_repository_url(data: URLParseRequest) -> URLParseResponse:
+ result = parse_git_url(data.url)
+ return URLParseResponse(**result)
- if remote_url:
- _preflight_remote_repository(remote_url)
- repo_path = _get_repo_path(user.id, project_id, data.name)
-
- # Ensure parent directory exists
- os.makedirs(os.path.dirname(repo_path), exist_ok=True)
-
- if remote_url:
- _clone_working_repository(remote_url, repo_path)
- else:
- _init_working_repository(repo_path)
-
- repo = GitRepository(
- name=data.name,
- path=repo_path,
- project_id=project_id,
- owner_id=user.id,
- is_mirror=False,
- remote_url=remote_url,
- )
- session.add(repo)
- await session.commit()
- await session.refresh(repo)
- return repo
+# History
@router.get(
"/{project_id}/repositories/{repo_id}/history",
summary="Get repository history",
- description="Get commit history for a repository with optional branch filtering.",
)
async def get_repository_history(
project_id: uuid.UUID,
@@ -343,40 +113,17 @@ async def get_repository_history(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Get commit history for a repository.
+ from src.utils.git_history import get_commit_history
+ from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- view: View type for history display (default: graph).
- branch: Optional branch name to filter commits.
- limit: Maximum number of commits to return (default: 100).
- offset: Number of commits to skip (default: 0).
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Dictionary containing commit history data.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
- return history
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
@router.get(
"/{project_id}/repositories/{repo_id}/commits/{commit_hash}",
summary="Get commit details",
- description="Get detailed information about a specific commit.",
)
async def get_repository_commit(
project_id: uuid.UUID,
@@ -386,42 +133,21 @@ async def get_repository_commit(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Get detailed information about a specific commit.
+ from src.utils.git_history import get_commit_detail
+ from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- commit_hash: Hash of the commit to retrieve.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Dictionary containing commit details.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- detail = get_commit_detail(repo.path, commit_hash)
- return detail
- except (RuntimeError, ValueError) as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ return get_commit_detail(repo.path, commit_hash)
-# File browsing endpoints
-
+# File browsing
@router.get(
"/{project_id}/repositories/{repo_id}/files",
response_model=FileListResponse,
summary="List repository files",
- description="List files and directories in a repository path.",
)
async def list_repository_files(
project_id: uuid.UUID,
@@ -432,61 +158,13 @@ async def list_repository_files(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FileListResponse:
- """List files and directories in a repository path.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- branch: Branch name to browse (default: main).
- path: Directory path within the repository (default: root).
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- List of files and directories in the specified path.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- entries = list_tree(repo.path, branch=branch, path=path)
- return FileListResponse(
- path=path,
- branch=branch,
- entries=[
- {
- "name": e.name,
- "type": e.type,
- "path": e.path,
- "size": e.size,
- "mode": e.mode,
- "last_commit": e.last_commit,
- }
- for e in entries
- ],
- )
- except RuntimeError as e:
- logger.error(
- "Failed to list files for repo %s (path=%s, branch=%s): %s",
- repo_id,
- path,
- branch,
- str(e),
- exc_info=True,
- )
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+ return await git_files.list_files(session, project_id, repo_id, branch, path)
@router.get(
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileContentResponse,
summary="Get file content",
- description="Get the content of a file in a repository.",
)
async def get_repository_file_content(
project_id: uuid.UUID,
@@ -497,105 +175,13 @@ async def get_repository_file_content(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FileContentResponse:
- """Get the content of a file.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- branch: Branch name where the file is located.
- path: File path within the repository.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- File content and metadata.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- file_content = get_file_content(repo.path, branch=branch, path=path)
- return FileContentResponse(
- path=file_content.path,
- branch=file_content.branch,
- content=file_content.content,
- size=file_content.size,
- encoding=file_content.encoding,
- language=file_content.language,
- is_binary=file_content.is_binary,
- last_commit=file_content.last_commit,
- )
- except FileNotFoundError:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
-
-
-@router.get(
- "/{project_id}/repositories/{repo_id}/branches",
- response_model=BranchesResponse,
- summary="List branches",
- description="List all branches in the repository.",
-)
-async def get_repository_branches(
- project_id: uuid.UUID,
- repo_id: uuid.UUID,
- user: User = Depends(get_current_user),
- project: Project = Depends(get_owned_project),
- session: AsyncSession = Depends(get_db_session),
-) -> BranchesResponse:
- """List all branches in the repository.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- List of branches and the default branch name.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- branches, default_branch = list_branches(repo.path)
- return BranchesResponse(
- branches=[
- {
- "name": b.name,
- "is_default": b.is_default,
- "last_commit": b.last_commit,
- }
- for b in branches
- ],
- default_branch=default_branch,
- )
- except RuntimeError as e:
- logger.error(
- "Failed to list branches for repo %s: %s",
- repo_id,
- str(e),
- exc_info=True,
- )
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+ return await git_files.get_file(session, project_id, repo_id, branch, path)
@router.post(
"/{project_id}/repositories/{repo_id}/files/content",
response_model=FileUpdateResponse,
summary="Update file content",
- description="Update a file and create a commit.",
)
async def update_repository_file(
project_id: uuid.UUID,
@@ -605,104 +191,29 @@ async def update_repository_file(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FileUpdateResponse:
- """Update a file and create a commit.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- data: File update data including path, branch, content, and commit message.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Commit information for the file update.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- # Get user info for commit
- author_name = user.name or "Unknown"
- author_email = user.email or "unknown@example.com"
-
- try:
- commit_hash = commit_file(
- repo_path=repo.path,
- branch=data.branch,
- path=data.path,
- content=data.content,
- commit_message=data.commit_message,
- author_name=author_name,
- author_email=author_email,
- )
- return FileUpdateResponse(
- commit_hash=commit_hash,
- message=data.commit_message,
- branch=data.branch,
- )
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+ return await git_files.update_file(session, project_id, repo_id, data, user)
-# Git Control Endpoints
+# Branches
@router.get(
- "/{project_id}/repositories/{repo_id}/status",
- response_model=StatusResponse,
- summary="Get repository status",
- description="Get the working directory status including modified, added, and deleted files.",
+ "/{project_id}/repositories/{repo_id}/branches",
+ summary="List branches",
)
-async def get_repository_status(
+async def get_repository_branches(
project_id: uuid.UUID,
repo_id: uuid.UUID,
user: User = Depends(get_current_user),
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
-) -> StatusResponse:
- """Get the working directory status.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Repository status including branch, modified files, and ahead/behind counts.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- status_result = get_status(repo.path)
- return StatusResponse(
- branch=status_result.branch,
- modified=status_result.modified,
- added=status_result.added,
- deleted=status_result.deleted,
- untracked=status_result.untracked,
- renamed=status_result.renamed,
- ahead=status_result.ahead,
- behind=status_result.behind,
- )
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+) -> dict:
+ return await git_files.list_branches_with_validation(session, project_id, repo_id)
@router.post(
"/{project_id}/repositories/{repo_id}/branches",
summary="Create a branch",
- description="Create a new branch in the repository.",
)
async def create_repository_branch(
project_id: uuid.UUID,
@@ -712,37 +223,12 @@ async def create_repository_branch(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Create a new branch.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- data: Branch creation data including name and optional base branch.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Dictionary with success message and branch name.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- create_branch(repo.path, data.name, data.base_branch)
- return {"message": f"Branch '{data.name}' created", "branch": data.name}
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+ return await git_control.create_branch_with_validation(session, project_id, repo_id, data)
@router.delete(
"/{project_id}/repositories/{repo_id}/branches/{branch_name}",
summary="Delete a branch",
- description="Delete a branch from the repository.",
)
async def delete_repository_branch(
project_id: uuid.UUID,
@@ -753,38 +239,12 @@ async def delete_repository_branch(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Delete a branch.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- branch_name: Name of the branch to delete.
- force: Whether to force delete the branch.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Dictionary with success message.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- delete_branch(repo.path, branch_name, force)
- return {"message": f"Branch '{branch_name}' deleted"}
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+ return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force)
@router.post(
"/{project_id}/repositories/{repo_id}/checkout",
summary="Checkout a branch",
- description="Checkout a branch in the repository.",
)
async def checkout_repository_branch(
project_id: uuid.UUID,
@@ -794,39 +254,31 @@ async def checkout_repository_branch(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> dict:
- """Checkout a branch.
+ return await git_control.checkout_branch_with_validation(session, project_id, repo_id, data)
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- data: Checkout request containing the branch name.
- user_id: ID of the authenticated user.
- session: Database session.
- Returns:
- Dictionary with success message and checked out branch name.
- """
+# Git control
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- checkout_branch(repo.path, data.branch)
- return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+@router.get(
+ "/{project_id}/repositories/{repo_id}/status",
+ response_model=StatusResponse,
+ summary="Get repository status",
+)
+async def get_repository_status(
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ user: User = Depends(get_current_user),
+ project: Project = Depends(get_owned_project),
+ session: AsyncSession = Depends(get_db_session),
+) -> StatusResponse:
+ return await git_control.get_status_with_validation(session, project_id, repo_id)
@router.post(
"/{project_id}/repositories/{repo_id}/commit",
response_model=CommitResponse,
summary="Commit changes",
- description="Commit changes to the repository.",
)
async def commit_repository_changes(
project_id: uuid.UUID,
@@ -836,52 +288,14 @@ async def commit_repository_changes(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> CommitResponse:
- """Commit changes to the repository.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- data: Commit request containing message and optional files to commit.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Commit information including hash and message.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- # Get user info for commit
- author_name = user.name or "Unknown"
- author_email = user.email or "unknown@example.com"
-
- try:
- commit_hash = commit_changes(
- repo_path=repo.path,
- message=data.message,
- author_name=author_name,
- author_email=author_email,
- files=data.files,
- )
- return CommitResponse(
- commit_hash=commit_hash,
- message=data.message,
- )
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
-
+ result = await git_control.commit_changes_with_validation(session, project_id, repo_id, data, user)
+ return CommitResponse(commit_hash=result["commit_hash"], message=result["message"])
@router.post(
"/{project_id}/repositories/{repo_id}/fetch",
response_model=FetchResponse,
summary="Fetch from remote",
- description="Fetch updates from the remote repository.",
)
async def fetch_repository(
project_id: uuid.UUID,
@@ -890,38 +304,13 @@ async def fetch_repository(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> FetchResponse:
- """Fetch from remote.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Success message.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- fetch(repo.path)
- return FetchResponse(message="Fetched from remote")
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
-
+ return await git_control.fetch_with_validation(session, project_id, repo_id)
@router.post(
"/{project_id}/repositories/{repo_id}/pull",
response_model=PullResponse,
summary="Pull from remote",
- description="Pull updates from the remote repository.",
)
async def pull_repository(
project_id: uuid.UUID,
@@ -931,39 +320,13 @@ async def pull_repository(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> PullResponse:
- """Pull updates from remote.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- branch: Optional branch name to pull.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Success message.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- pull(repo.path, branch)
- return PullResponse(message="Pulled from remote")
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
-
+ return await git_control.pull_with_validation(session, project_id, repo_id, branch)
@router.post(
"/{project_id}/repositories/{repo_id}/push",
response_model=PushResponse,
summary="Push to remote",
- description="Push changes to the remote repository.",
)
async def push_repository(
project_id: uuid.UUID,
@@ -973,39 +336,13 @@ async def push_repository(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> PushResponse:
- """Push changes to remote.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- branch: Optional branch name to push.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Success message.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- push(repo.path, branch)
- return PushResponse(message="Pushed to remote")
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
-
+ return await git_control.push_with_validation(session, project_id, repo_id, branch)
@router.post(
"/{project_id}/repositories/{repo_id}/merge",
response_model=MergeResponse,
summary="Merge branches",
- description="Merge one branch into another.",
)
async def merge_repository_branches(
project_id: uuid.UUID,
@@ -1015,36 +352,4 @@ async def merge_repository_branches(
project: Project = Depends(get_owned_project),
session: AsyncSession = Depends(get_db_session),
) -> MergeResponse:
- """Merge branches.
-
- Args:
- project_id: UUID of the project.
- repo_id: UUID of the repository.
- data: Merge request containing source branch, optional target branch, and message.
- user_id: ID of the authenticated user.
- session: Database session.
-
- Returns:
- Merge result with commit hash and message.
- """
-
- 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")
-
- if not os.path.exists(repo.path):
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
-
- try:
- commit_hash = merge(
- repo_path=repo.path,
- source_branch=data.source_branch,
- target_branch=data.target_branch,
- message=data.message,
- )
- return MergeResponse(
- commit_hash=commit_hash,
- message=data.message or f"Merge {data.source_branch}",
- )
- except RuntimeError as e:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+ return await git_control.merge_with_validation(session, project_id, repo_id, data)
diff --git a/apps/api/src/services/config_profiles.py b/apps/api/src/services/config_profiles.py
new file mode 100644
index 0000000..295455b
--- /dev/null
+++ b/apps/api/src/services/config_profiles.py
@@ -0,0 +1,81 @@
+"""Config profile business logic."""
+
+import logging
+import uuid
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.models.config_include import ConfigInclude
+from src.models.config_mount import ConfigMount
+from src.models.config_profile import ConfigProfile
+from src.models.tool_type import ToolType
+from src.models.user_config import UserConfig
+
+logger = logging.getLogger(__name__)
+
+MAX_INCLUDES_DEPTH = 10
+
+
+async def get_owned_profile(
+ profile_id: uuid.UUID,
+ user_id: uuid.UUID,
+ session: AsyncSession,
+) -> ConfigProfile:
+ """Fetch a config profile and verify ownership."""
+ profile = await session.get(ConfigProfile, profile_id)
+ if profile is None or profile.user_id != user_id:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="config profile not found",
+ )
+ return profile
+
+
+async def _detect_cycle(
+ session: AsyncSession,
+ profile_id: uuid.UUID,
+ visited: set[uuid.UUID] | None = None,
+ depth: int = 0,
+) -> bool:
+ """Detect cycles in profile includes using DFS.
+
+ Returns True if a cycle is detected.
+ """
+ if depth > MAX_INCLUDES_DEPTH:
+ return True
+
+ if visited is None:
+ visited = set()
+
+ if profile_id in visited:
+ return True
+
+ visited.add(profile_id)
+
+ result = await session.execute(
+ select(ConfigInclude.included_profile_id).where(
+ ConfigInclude.profile_id == profile_id
+ )
+ )
+ included_ids = result.scalars().all()
+
+ for included_id in included_ids:
+ if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
+ return True
+
+ return False
+
+
+async def validate_includes_no_cycle(
+ session: AsyncSession,
+ profile_id: uuid.UUID,
+ new_included_id: uuid.UUID | None = None,
+) -> None:
+ """Validate that adding an include wouldn't create a cycle."""
+ if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="adding this include would create a circular reference",
+ )
diff --git a/apps/api/src/services/git/__init__.py b/apps/api/src/services/git/__init__.py
new file mode 100644
index 0000000..ed70d67
--- /dev/null
+++ b/apps/api/src/services/git/__init__.py
@@ -0,0 +1 @@
+"""Git services package."""
diff --git a/apps/api/src/services/git/control.py b/apps/api/src/services/git/control.py
new file mode 100644
index 0000000..0cbb9c0
--- /dev/null
+++ b/apps/api/src/services/git/control.py
@@ -0,0 +1,196 @@
+"""Git control operations with repo validation."""
+
+import logging
+import os
+import uuid
+
+from fastapi import HTTPException, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.models.git_repository import GitRepository
+from src.models.user import User
+from src.schemas.git_repository import (
+ BranchCreateRequest,
+ CheckoutRequest,
+ CommitRequest,
+ FetchResponse,
+ MergeRequest,
+ MergeResponse,
+ PullResponse,
+ PushResponse,
+ StatusResponse,
+)
+from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
+from src.utils.git_control import (
+ checkout_branch,
+ commit_changes,
+ create_branch,
+ delete_branch,
+ fetch,
+ get_status,
+ merge,
+ pull,
+ push,
+)
+
+logger = logging.getLogger(__name__)
+
+
+async def get_status_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+) -> StatusResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ result = get_status(repo.path)
+ return StatusResponse(
+ branch=result.branch,
+ modified=result.modified,
+ added=result.added,
+ deleted=result.deleted,
+ untracked=result.untracked,
+ renamed=result.renamed,
+ ahead=result.ahead,
+ behind=result.behind,
+ )
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def create_branch_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ data: BranchCreateRequest,
+) -> dict:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ create_branch(repo.path, data.name, data.base_branch)
+ return {"message": f"Branch '{data.name}' created", "branch": data.name}
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def delete_branch_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ branch_name: str,
+ force: bool = False,
+) -> dict:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ delete_branch(repo.path, branch_name, force)
+ return {"message": f"Branch '{branch_name}' deleted"}
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def checkout_branch_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ data: CheckoutRequest,
+) -> dict:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ checkout_branch(repo.path, data.branch)
+ return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def commit_changes_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ data: CommitRequest,
+ user: User,
+) -> dict:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ author_name = user.name or "Unknown"
+ author_email = user.email or "unknown@example.com"
+ try:
+ commit_hash = commit_changes(
+ repo_path=repo.path,
+ message=data.message,
+ author_name=author_name,
+ author_email=author_email,
+ files=data.files,
+ )
+ return {"commit_hash": commit_hash, "message": data.message}
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def fetch_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+) -> FetchResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ fetch(repo.path)
+ return FetchResponse(message="Fetched from remote")
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def pull_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ branch: str | None = None,
+) -> PullResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ pull(repo.path, branch)
+ return PullResponse(message="Pulled from remote")
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def push_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ branch: str | None = None,
+) -> PushResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ push(repo.path, branch)
+ return PushResponse(message="Pushed to remote")
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def merge_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ data: MergeRequest,
+) -> MergeResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ commit_hash = merge(
+ repo_path=repo.path,
+ source_branch=data.source_branch,
+ target_branch=data.target_branch,
+ message=data.message,
+ )
+ return MergeResponse(
+ commit_hash=commit_hash,
+ message=data.message or f"Merge {data.source_branch}",
+ )
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
diff --git a/apps/api/src/services/git/files.py b/apps/api/src/services/git/files.py
new file mode 100644
index 0000000..b1d2148
--- /dev/null
+++ b/apps/api/src/services/git/files.py
@@ -0,0 +1,150 @@
+"""Git file operations with repo validation."""
+
+import logging
+import uuid
+
+from fastapi import HTTPException, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.models.git_repository import GitRepository
+from src.models.user import User
+from src.schemas.git_repository import (
+ FileContentResponse,
+ FileListResponse,
+ FileUpdateRequest,
+ FileUpdateResponse,
+)
+from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
+from src.utils.git_files import (
+ commit_file,
+ get_file_content,
+ list_branches,
+ list_tree,
+)
+
+logger = logging.getLogger(__name__)
+
+
+async def list_files(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ branch: str = "main",
+ path: str = "",
+) -> FileListResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ entries = list_tree(repo.path, branch=branch, path=path)
+ return FileListResponse(
+ path=path,
+ branch=branch,
+ entries=[
+ {
+ "name": e.name,
+ "type": e.type,
+ "path": e.path,
+ "size": e.size,
+ "mode": e.mode,
+ "last_commit": e.last_commit,
+ }
+ for e in entries
+ ],
+ )
+ except RuntimeError as e:
+ logger.error(
+ "Failed to list files for repo %s (path=%s, branch=%s): %s",
+ repo_id,
+ path,
+ branch,
+ str(e),
+ exc_info=True,
+ )
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def get_file(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ branch: str,
+ path: str,
+) -> FileContentResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ file_content = get_file_content(repo.path, branch=branch, path=path)
+ return FileContentResponse(
+ path=file_content.path,
+ branch=file_content.branch,
+ content=file_content.content,
+ size=file_content.size,
+ encoding=file_content.encoding,
+ language=file_content.language,
+ is_binary=file_content.is_binary,
+ last_commit=file_content.last_commit,
+ )
+ except FileNotFoundError:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def update_file(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+ data: FileUpdateRequest,
+ user: User,
+) -> FileUpdateResponse:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ author_name = user.name or "Unknown"
+ author_email = user.email or "unknown@example.com"
+ try:
+ commit_hash = commit_file(
+ repo_path=repo.path,
+ branch=data.branch,
+ path=data.path,
+ content=data.content,
+ commit_message=data.commit_message,
+ author_name=author_name,
+ author_email=author_email,
+ )
+ return FileUpdateResponse(
+ commit_hash=commit_hash,
+ message=data.commit_message,
+ branch=data.branch,
+ )
+ except RuntimeError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+
+async def list_branches_with_validation(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ repo_id: uuid.UUID,
+) -> dict:
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+ ensure_repo_on_disk(repo)
+ try:
+ branches, default_branch = list_branches(repo.path)
+ return {
+ "branches": [
+ {
+ "name": b.name,
+ "is_default": b.is_default,
+ "last_commit": b.last_commit,
+ }
+ for b in branches
+ ],
+ "default_branch": default_branch,
+ }
+ except RuntimeError as e:
+ logger.error(
+ "Failed to list branches for repo %s: %s",
+ repo_id,
+ str(e),
+ exc_info=True,
+ )
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
diff --git a/apps/api/src/services/git/repository.py b/apps/api/src/services/git/repository.py
new file mode 100644
index 0000000..d4b4bb0
--- /dev/null
+++ b/apps/api/src/services/git/repository.py
@@ -0,0 +1,211 @@
+"""Repository lifecycle and path helpers."""
+
+import logging
+import os
+import shutil
+import subprocess
+import uuid
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.config import Settings
+from src.models.git_repository import GitRepository
+from src.models.project import Project
+from src.models.user import User
+from src.schemas.git_repository import GitRepositoryCreate
+from src.utils.git_url_parser import parse_git_url
+
+logger = logging.getLogger(__name__)
+
+
+def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
+ """Generate the filesystem path for a repository."""
+ base = Settings().repo_base_path or "/data/repos"
+ return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
+
+
+def _build_provider_clone_url(owner: str, repo: str) -> str:
+ """Build the SSH clone URL for the fixed git provider."""
+ return f"git@git.commumedia.org:{owner}/{repo}.git"
+
+
+def _preflight_remote_repository(remote_url: str) -> None:
+ """Verify a remote repository is reachable before cloning."""
+ try:
+ result = subprocess.run(
+ ["git", "ls-remote", remote_url],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ except subprocess.TimeoutExpired:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
+ except FileNotFoundError:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
+
+ if result.returncode != 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="repository not found or inaccessible",
+ )
+
+
+def _clone_working_repository(remote_url: str, repo_path: str) -> None:
+ try:
+ result = subprocess.run(
+ ["git", "clone", remote_url, repo_path],
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
+ except subprocess.TimeoutExpired:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
+ except FileNotFoundError:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
+
+ if result.returncode != 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"failed to clone repository: {result.stderr}",
+ )
+
+
+def _init_working_repository(repo_path: str) -> None:
+ try:
+ result = subprocess.run(
+ ["git", "init", "-b", "main", repo_path],
+ capture_output=True,
+ text=True,
+ )
+ except FileNotFoundError:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
+
+ if result.returncode == 0:
+ return
+
+ fallback = subprocess.run(
+ ["git", "init", repo_path],
+ capture_output=True,
+ text=True,
+ )
+ if fallback.returncode != 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"failed to initialize repository: {fallback.stderr}",
+ )
+
+ ref_result = subprocess.run(
+ ["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
+ capture_output=True,
+ text=True,
+ )
+ if ref_result.returncode != 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"failed to set initial branch: {ref_result.stderr}",
+ )
+
+
+async def get_repo_and_validate(
+ session: AsyncSession,
+ repo_id: uuid.UUID,
+ project_id: uuid.UUID,
+) -> GitRepository:
+ """Fetch a repository and validate ownership + disk presence."""
+ 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")
+ return repo
+
+
+def ensure_repo_on_disk(repo: GitRepository) -> None:
+ """Raise 404 if the repository is not present on disk."""
+ if not os.path.exists(repo.path):
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
+
+
+async def create_repository(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+ data: GitRepositoryCreate,
+ user: User,
+) -> GitRepository:
+ """Create a new git repository (clone or init)."""
+ # Check for duplicate name
+ existing = await session.execute(
+ select(GitRepository).where(
+ GitRepository.project_id == project_id,
+ GitRepository.name == data.name,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
+
+ # Validate and potentially correct the URL
+ remote_url = data.remote_url
+ if remote_url and not data.force_original_url:
+ parse_result = parse_git_url(remote_url)
+ if parse_result["needs_parsing"] and parse_result["base_url"]:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail={
+ "message": "The provided URL appears to be a browser URL, not a git clone URL",
+ "suggested_url": parse_result["base_url"],
+ "original_url": remote_url,
+ "error_code": "URL_NEEDS_PARSING",
+ },
+ )
+ if parse_result["base_url"]:
+ remote_url = parse_result["base_url"]
+
+ if remote_url:
+ _preflight_remote_repository(remote_url)
+
+ repo_path = _get_repo_path(user.id, project_id, data.name)
+ os.makedirs(os.path.dirname(repo_path), exist_ok=True)
+
+ if remote_url:
+ _clone_working_repository(remote_url, repo_path)
+ else:
+ _init_working_repository(repo_path)
+
+ repo = GitRepository(
+ name=data.name,
+ path=repo_path,
+ project_id=project_id,
+ owner_id=user.id,
+ is_mirror=False,
+ remote_url=remote_url,
+ )
+ session.add(repo)
+ await session.commit()
+ await session.refresh(repo)
+ return repo
+
+
+async def delete_repository(
+ session: AsyncSession,
+ repo_id: uuid.UUID,
+ project_id: uuid.UUID,
+) -> None:
+ """Delete a repository from DB and disk."""
+ repo = await get_repo_and_validate(session, repo_id, project_id)
+
+ if os.path.exists(repo.path):
+ shutil.rmtree(repo.path)
+
+ await session.delete(repo)
+ await session.commit()
+
+
+async def list_repositories(
+ session: AsyncSession,
+ project_id: uuid.UUID,
+) -> list[GitRepository]:
+ """List all repositories in a project."""
+ result = await session.execute(
+ select(GitRepository).where(GitRepository.project_id == project_id)
+ )
+ return list(result.scalars().all())
diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx
index 5e65cce..b596412 100644
--- a/apps/web/src/components/app-shell.tsx
+++ b/apps/web/src/components/app-shell.tsx
@@ -8,6 +8,7 @@ import { useAuth } from "../state/auth";
import { useSessions } from "../state/sessions";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
+import styles from "./layout/AppShell.module.css";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Home", icon: "dashboard" },
@@ -28,12 +29,12 @@ const SessionItem = ({ session }: { session: Session }) => {
href={session.url ?? `/projects/${session.project_id}`}
target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined}
- className="nav-item session-item"
+ className={`${styles.navItem} ${styles.sessionItem}`}
title={`${displayName} (${session.status})`}
>
-
+