c527393d2e
- Create schemas/ directory with Pydantic request/response models - tool_instance.py, tool_type.py, git_repository.py, config_profile.py - config_folder.py, tool_config.py, project.py, ssh_key.py - Update api/tool_instances.py to import CreateInstanceRequest from schemas - Update api/git_repositories.py to import from schemas - Update api/config_profiles.py to import from schemas - Update api/tool_types.py to import all schemas from schemas/tool_type.py Quality gates: Python syntax check (pass) Refs: repo-restructure Task 3.2
1051 lines
33 KiB
Python
1051 lines
33 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 sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
|
from src.schemas.git_repository import (
|
|
GitRepositoryCreate,
|
|
GitRepositoryResponse,
|
|
URLParseRequest,
|
|
URLParseResponse,
|
|
FileListResponse,
|
|
FileContentResponse,
|
|
BranchesResponse,
|
|
FileUpdateRequest,
|
|
FileUpdateResponse,
|
|
StatusResponse,
|
|
BranchCreateRequest,
|
|
CheckoutRequest,
|
|
CommitRequest,
|
|
CommitResponse,
|
|
FetchResponse,
|
|
PullResponse,
|
|
PushResponse,
|
|
MergeRequest,
|
|
MergeResponse,
|
|
)
|
|
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.utils.git_url_parser import parse_git_url
|
|
|
|
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(
|
|
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)
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
project: Project = Depends(get_owned_project),
|
|
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.
|
|
"""
|
|
|
|
# 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"]
|
|
|
|
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
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
project: Project = Depends(get_owned_project),
|
|
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.
|
|
"""
|
|
|
|
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: User = Depends(get_current_user),
|
|
project: Project = Depends(get_owned_project),
|
|
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.
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
# Git Control Endpoints
|
|
|
|
|
|
@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: 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))
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
project: Project = Depends(get_owned_project),
|
|
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.
|
|
"""
|
|
|
|
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.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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|
|
|
|
|
|
|
|
@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: User = Depends(get_current_user),
|
|
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))
|