"""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())