feat: add git control endpoints (status, branch, commit, fetch, pull, push, merge)

- Add GitStatus dataclass and git control utilities
- Add endpoints:
  - GET /status - working directory status
  - POST /branches - create branch
  - DELETE /branches/{name} - delete branch
  - POST /checkout - checkout branch
  - POST /commit - commit changes
  - POST /fetch - fetch from remote
  - POST /pull - pull updates
  - POST /push - push changes
  - POST /merge - merge branches

Quality gates: ruff ✓, mypy ✓
This commit is contained in:
Fusion
2026-05-19 14:49:22 +02:00
parent 955dadc604
commit 762e0de44c
2 changed files with 613 additions and 0 deletions
+331
View File
@@ -20,6 +20,17 @@ from src.utils.git_files import (
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
@@ -466,3 +477,323 @@ async def update_repository_file(
)
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)
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."""
_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")
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."""
_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}")
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."""
_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")
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."""
_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)
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."""
_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)
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."""
_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)
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."""
_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)
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."""
_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)
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."""
_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))
+282
View File
@@ -0,0 +1,282 @@
"""Git control utilities for repository operations."""
import subprocess
from dataclasses import dataclass, field
from typing import Any
def _run_git_command(repo_path: str, *args: str) -> str:
"""Run a git command in the repository directory."""
result = subprocess.run(
["git", *args],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Git command failed: {result.stderr}")
return result.stdout
@dataclass
class GitStatus:
"""Represents the working directory status."""
branch: str
modified: list[str] = field(default_factory=list)
added: list[str] = field(default_factory=list)
deleted: list[str] = field(default_factory=list)
untracked: list[str] = field(default_factory=list)
renamed: list[str] = field(default_factory=list)
ahead: int = 0
behind: int = 0
def get_status(repo_path: str) -> GitStatus:
"""Get the working directory status.
Args:
repo_path: Path to the git repository
Returns:
GitStatus with changes
"""
# Get current branch
try:
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
except RuntimeError:
branch = "HEAD"
status = GitStatus(branch=branch)
# Get status with porcelain format
try:
output = _run_git_command(repo_path, "status", "--porcelain", "--branch")
except RuntimeError:
return status
for line in output.strip().split("\n"):
if not line:
continue
# Branch info line starts with ##
if line.startswith("## "):
branch_info = line[3:]
# Parse ahead/behind info
if "[ahead " in branch_info:
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
status.ahead = int(ahead_str.split(",")[0])
if "[behind " in branch_info:
behind_str = branch_info.split("[behind ")[1].split("]")[0]
status.behind = int(behind_str.split(",")[0])
continue
# Parse status code
if len(line) < 3:
continue
index_status = line[0]
worktree_status = line[1]
filename = line[3:]
# Untracked files
if index_status == "?" and worktree_status == "?":
status.untracked.append(filename)
continue
# Added files
if index_status == "A" or worktree_status == "A":
status.added.append(filename)
continue
# Deleted files
if index_status == "D" or worktree_status == "D":
status.deleted.append(filename)
continue
# Renamed files
if index_status == "R" or worktree_status == "R":
status.renamed.append(filename)
continue
# Modified files
if index_status == "M" or worktree_status == "M":
status.modified.append(filename)
continue
return status
def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
"""Create a new branch.
Args:
repo_path: Path to the git repository
name: Branch name
base_branch: Base branch to create from (default: HEAD)
Raises:
RuntimeError: If branch creation fails
"""
_run_git_command(repo_path, "branch", name, base_branch)
def delete_branch(repo_path: str, name: str, force: bool = False) -> None:
"""Delete a branch.
Args:
repo_path: Path to the git repository
name: Branch name
force: Force delete even if not merged
Raises:
RuntimeError: If branch deletion fails
"""
flag = "-D" if force else "-d"
_run_git_command(repo_path, "branch", flag, name)
def checkout_branch(repo_path: str, name: str) -> None:
"""Checkout a branch.
Args:
repo_path: Path to the git repository
name: Branch name
Raises:
RuntimeError: If checkout fails
"""
_run_git_command(repo_path, "checkout", name)
def commit_changes(
repo_path: str,
message: str,
author_name: str,
author_email: str,
files: list[str] | None = None,
) -> str:
"""Commit changes to the repository.
Args:
repo_path: Path to the git repository
message: Commit message
author_name: Author name
author_email: Author email
files: Specific files to commit (None = all staged)
Returns:
Commit hash
Raises:
RuntimeError: If commit fails
"""
# Stage files if specified
if files:
for file in files:
_run_git_command(repo_path, "add", file)
else:
_run_git_command(repo_path, "add", "-A")
# Commit
_run_git_command(
repo_path,
"commit",
"-m",
message,
f"--author={author_name} <{author_email}>",
)
# Return commit hash
return _run_git_command(repo_path, "rev-parse", "HEAD").strip()
def fetch(repo_path: str) -> None:
"""Fetch from remote.
Args:
repo_path: Path to the git repository
Raises:
RuntimeError: If fetch fails
"""
_run_git_command(repo_path, "fetch", "--all")
def pull(repo_path: str, branch: str | None = None) -> None:
"""Pull updates from remote.
Args:
repo_path: Path to the git repository
branch: Branch to pull (default: current branch)
Raises:
RuntimeError: If pull fails
"""
args = ["pull"]
if branch:
args.extend(["origin", branch])
_run_git_command(repo_path, *args)
def push(repo_path: str, branch: str | None = None) -> None:
"""Push changes to remote.
Args:
repo_path: Path to the git repository
branch: Branch to push (default: current branch)
Raises:
RuntimeError: If push fails
"""
args = ["push"]
if branch:
args.extend(["origin", branch])
_run_git_command(repo_path, *args)
def merge(
repo_path: str,
source_branch: str,
target_branch: str | None = None,
message: str | None = None,
) -> str:
"""Merge a branch into the current branch.
Args:
repo_path: Path to the git repository
source_branch: Branch to merge from
target_branch: Branch to merge into (default: current branch)
message: Merge commit message
Returns:
Merge commit hash
Raises:
RuntimeError: If merge fails (including conflicts)
"""
# Checkout target branch if specified
if target_branch:
checkout_branch(repo_path, target_branch)
# Merge
args = ["merge", source_branch]
if message:
args.extend(["-m", message])
_run_git_command(repo_path, *args)
# Return merge commit hash
return _run_git_command(repo_path, "rev-parse", "HEAD").strip()
def get_current_branch(repo_path: str) -> str:
"""Get the current branch name.
Args:
repo_path: Path to the git repository
Returns:
Current branch name
"""
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()