22474cdba5
Backend (ruff): - Fix 106 errors: move imports to top of file (E402) - Remove unused imports (F401) - Add missing imports for undefined names (F821) - Remove unused variables (F841) - Fix test_models.py broken RefreshToken test - Fix test_projects_api.py missing TestClient import Frontend (eslint): - Remove unused imports/variables across 10 files - Fix explicit any types in client.ts and sessions.ts - Clean up empty block statements in terminal.tsx Quality gates: ruff (pass), eslint (pass), tsc --noEmit (pass), pytest (98 passed, 4 pre-existing failures)
1428 lines
46 KiB
Python
1428 lines
46 KiB
Python
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
from pydantic import BaseModel, ConfigDict
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
|
from src.config import Settings
|
|
from src.models.git_repository import GitRepository
|
|
from src.models.ssh_key import SSHKey
|
|
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.utils.git_url_parser import parse_git_url
|
|
from src.services.ssh_keys import _get_fernet
|
|
|
|
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 _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
|
|
"""Prepare environment variables for git commands with SSH authentication.
|
|
|
|
Returns a dict of extra env vars, or None if no SSH key provided.
|
|
The caller is responsible for cleaning up the temporary key file.
|
|
"""
|
|
if ssh_key is None:
|
|
return None
|
|
|
|
import tempfile
|
|
|
|
# Decrypt private key
|
|
fernet = _get_fernet()
|
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
|
|
|
# Write to temp file with restricted permissions
|
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
|
try:
|
|
os.write(fd, private_key.encode())
|
|
finally:
|
|
os.close(fd)
|
|
os.chmod(key_path, 0o600)
|
|
|
|
# Return env vars and the key path for cleanup
|
|
env = {
|
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
}
|
|
return env, key_path
|
|
|
|
|
|
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
|
|
"""Verify a remote repository is reachable before cloning."""
|
|
env = None
|
|
key_path = None
|
|
|
|
if ssh_key is not None:
|
|
ssh_result = _prepare_ssh_env(ssh_key)
|
|
if ssh_result:
|
|
env, key_path = ssh_result
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "ls-remote", remote_url],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
env={**os.environ, **env} if env else None,
|
|
)
|
|
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")
|
|
finally:
|
|
if key_path and os.path.exists(key_path):
|
|
os.unlink(key_path)
|
|
|
|
if result.returncode != 0:
|
|
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"repository not found or inaccessible: {result.stderr}",
|
|
)
|
|
|
|
|
|
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
|
|
env = None
|
|
key_path = None
|
|
|
|
if ssh_key is not None:
|
|
ssh_result = _prepare_ssh_env(ssh_key)
|
|
if ssh_result:
|
|
env, key_path = ssh_result
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "clone", remote_url, repo_path],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
env={**os.environ, **env} if env else None,
|
|
)
|
|
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")
|
|
finally:
|
|
if key_path and os.path.exists(key_path):
|
|
os.unlink(key_path)
|
|
|
|
if result.returncode != 0:
|
|
logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr)
|
|
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}",
|
|
)
|
|
|
|
|
|
class GitRepositoryCreate(BaseModel):
|
|
name: str
|
|
remote_url: str | None = None
|
|
force_original_url: bool = False
|
|
ssh_key_id: str | None = None
|
|
|
|
|
|
class URLParseRequest(BaseModel):
|
|
url: str
|
|
|
|
|
|
class URLParseResponse(BaseModel):
|
|
original_url: str
|
|
base_url: str | None
|
|
is_valid_clone_url: bool
|
|
needs_parsing: bool
|
|
host: str | None
|
|
message: str
|
|
error_code: str | None
|
|
|
|
|
|
class GitRepositoryResponse(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
name: str
|
|
path: str
|
|
project_id: uuid.UUID | None
|
|
owner_id: uuid.UUID
|
|
is_mirror: bool
|
|
remote_url: str | None
|
|
last_push: datetime | None
|
|
ssh_key_id: uuid.UUID | None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
@router.get(
|
|
"/repositories",
|
|
response_model=list[GitRepositoryResponse],
|
|
summary="List all user repositories",
|
|
description="List all git repositories owned by the user, including external repositories not tied to any project.",
|
|
)
|
|
async def list_user_repositories(
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> list[GitRepository]:
|
|
"""List all repositories owned by the user.
|
|
|
|
Args:
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
List of all repositories owned by the user.
|
|
"""
|
|
result = await session.execute(
|
|
select(GitRepository).where(GitRepository.owner_id == user_id)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
@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)
|
|
|
|
|
|
@router.post(
|
|
"/repositories",
|
|
response_model=GitRepositoryResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create an external repository",
|
|
description="Create a new external git repository (not tied to any project). Can clone from remote URL.",
|
|
)
|
|
async def create_external_repository(
|
|
data: GitRepositoryCreate,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> GitRepository:
|
|
"""Create a new external git repository.
|
|
|
|
External repositories are not tied to any project and can be used
|
|
across all projects for config profile git mounts.
|
|
|
|
Args:
|
|
data: Repository creation data including name and optional remote URL.
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
The newly created external repository.
|
|
"""
|
|
_user = await _get_user(session, user_id)
|
|
|
|
# Check for duplicate name (external repos only)
|
|
existing = await session.execute(
|
|
select(GitRepository).where(
|
|
GitRepository.project_id.is_(None),
|
|
GitRepository.owner_id == user_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"]
|
|
|
|
# Validate SSH key if provided
|
|
ssh_key_id = None
|
|
ssh_key = None
|
|
if data.ssh_key_id:
|
|
try:
|
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
|
|
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
|
if ssh_key is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
|
if ssh_key.user_id != user_id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
|
|
|
if remote_url:
|
|
_preflight_remote_repository(remote_url, ssh_key)
|
|
|
|
# Create external repo with no project
|
|
repo = GitRepository(
|
|
name=data.name,
|
|
path="", # Will be set after clone
|
|
project_id=None,
|
|
owner_id=user_id,
|
|
remote_url=remote_url,
|
|
ssh_key_id=ssh_key_id,
|
|
)
|
|
session.add(repo)
|
|
await session.flush()
|
|
|
|
# Set path and optionally clone
|
|
repo_path = f"/data/repos/external/{user_id}/{repo.id}"
|
|
repo.path = repo_path
|
|
|
|
if remote_url:
|
|
try:
|
|
_clone_working_repository(remote_url, repo_path, ssh_key)
|
|
repo.is_mirror = False
|
|
except Exception as exc:
|
|
await session.rollback()
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
|
else:
|
|
# Initialize empty repo
|
|
os.makedirs(repo_path, exist_ok=True)
|
|
subprocess.run(["git", "init", repo_path], check=True, capture_output=True)
|
|
repo.is_mirror = False
|
|
|
|
await session.commit()
|
|
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(
|
|
"/{project_id}/repositories",
|
|
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(
|
|
project_id: uuid.UUID,
|
|
data: GitRepositoryCreate,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> GitRepository:
|
|
"""Create a new git repository.
|
|
|
|
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.
|
|
"""
|
|
_user = await _get_user(session, user_id)
|
|
_project = await _get_owned_project(project_id, user_id, session)
|
|
|
|
# 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"]
|
|
|
|
# Validate SSH key if provided
|
|
ssh_key_id = None
|
|
ssh_key = None
|
|
if data.ssh_key_id:
|
|
try:
|
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
|
|
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
|
if ssh_key is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
|
|
|
if remote_url:
|
|
_preflight_remote_repository(remote_url, ssh_key)
|
|
|
|
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, ssh_key)
|
|
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,
|
|
ssh_key_id=ssh_key_id,
|
|
)
|
|
session.add(repo)
|
|
await session.commit()
|
|
await session.refresh(repo)
|
|
return repo
|
|
|
|
|
|
class UpdateSSHKeyRequest(BaseModel):
|
|
ssh_key_id: str | None = None
|
|
|
|
|
|
@router.patch(
|
|
"/{project_id}/repositories/{repo_id}/ssh-key",
|
|
response_model=GitRepositoryResponse,
|
|
summary="Update repository SSH key",
|
|
description="Update the SSH key associated with a repository.",
|
|
)
|
|
async def update_repository_ssh_key(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
data: UpdateSSHKeyRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> GitRepository:
|
|
"""Update the SSH key for a repository.
|
|
|
|
Args:
|
|
project_id: UUID of the project.
|
|
repo_id: UUID of the repository.
|
|
data: Update data containing the new SSH key ID.
|
|
user_id: ID of the authenticated user.
|
|
session: Database session.
|
|
|
|
Returns:
|
|
The updated repository.
|
|
"""
|
|
_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")
|
|
|
|
# Validate SSH key if provided
|
|
if data.ssh_key_id:
|
|
try:
|
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
|
|
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
|
if ssh_key is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
|
|
|
repo.ssh_key_id = ssh_key_id
|
|
else:
|
|
repo.ssh_key_id = None
|
|
|
|
await session.commit()
|
|
await session.refresh(repo)
|
|
return repo
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
view: str = "graph",
|
|
branch: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Get commit history for a repository.
|
|
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
commit_hash: str,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Get detailed information about a specific commit.
|
|
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
# File browsing endpoints
|
|
|
|
|
|
class FileListResponse(BaseModel):
|
|
path: str
|
|
branch: str
|
|
entries: list[dict]
|
|
|
|
|
|
class FileContentResponse(BaseModel):
|
|
path: str
|
|
branch: str
|
|
content: str
|
|
size: int
|
|
encoding: str
|
|
language: str | None
|
|
is_binary: bool
|
|
last_commit: dict | None
|
|
|
|
|
|
class BranchesResponse(BaseModel):
|
|
branches: list[dict]
|
|
default_branch: str
|
|
|
|
|
|
class FileUpdateRequest(BaseModel):
|
|
path: str
|
|
branch: str
|
|
content: str
|
|
commit_message: str
|
|
|
|
|
|
class FileUpdateResponse(BaseModel):
|
|
commit_hash: str
|
|
message: str
|
|
branch: str
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
branch: str = "main",
|
|
path: str = "",
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
branch: str,
|
|
path: str,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
data: FileUpdateRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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
|
|
user = await _get_user(session, user_id)
|
|
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))
|
|
|
|
|
|
# Git Control Endpoints
|
|
|
|
|
|
class StatusResponse(BaseModel):
|
|
branch: str
|
|
modified: list[str]
|
|
added: list[str]
|
|
deleted: list[str]
|
|
untracked: list[str]
|
|
renamed: list[str]
|
|
ahead: int
|
|
behind: int
|
|
|
|
|
|
@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.",
|
|
)
|
|
async def get_repository_status(
|
|
project_id: uuid.UUID,
|
|
repo_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
class BranchCreateRequest(BaseModel):
|
|
name: str
|
|
base_branch: str = "HEAD"
|
|
|
|
|
|
class CheckoutRequest(BaseModel):
|
|
branch: str
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
data: BranchCreateRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
branch_name: str,
|
|
force: bool = False,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
data: CheckoutRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Checkout a branch.
|
|
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
class CommitRequest(BaseModel):
|
|
message: str
|
|
files: list[str] | None = None
|
|
|
|
|
|
class CommitResponse(BaseModel):
|
|
commit_hash: str
|
|
message: str
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
data: CommitRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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
|
|
user = await _get_user(session, user_id)
|
|
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))
|
|
|
|
|
|
class FetchResponse(BaseModel):
|
|
message: str
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
class PullResponse(BaseModel):
|
|
message: str
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
branch: str | None = None,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
class PushResponse(BaseModel):
|
|
message: str
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
branch: str | None = None,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|
|
|
|
|
|
class MergeRequest(BaseModel):
|
|
source_branch: str
|
|
target_branch: str | None = None
|
|
message: str | None = None
|
|
|
|
|
|
class MergeResponse(BaseModel):
|
|
commit_hash: str
|
|
message: str
|
|
|
|
|
|
@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,
|
|
repo_id: uuid.UUID,
|
|
data: MergeRequest,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
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.
|
|
"""
|
|
_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")
|
|
|
|
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))
|