cccf4379d8
- Create InstanceList.module.css, AppShell.module.css, SettingsTabLayout.module.css - Create CommitPanel.module.css, FileViewer.module.css - Create page CSS files: sessions, repo-workspace, dashboard, projects, git-history, ssh-keys, settings - Update components to import and use CSS modules - Delete monolithic styles.css (2,255 lines) - Update main.tsx to import page CSS and new modules Quality gates: tsc (pass), eslint (pass), build (pass) Refs: repo-restructure Task 2.3
212 lines
6.8 KiB
Python
212 lines
6.8 KiB
Python
"""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())
|