feat: implement git repository management

- Add backend API for git repository CRUD (create, list, delete)
- Support bare repository initialization and mirror cloning
- Add cascade delete for repositories when project is deleted
- Add frontend page for repository management per project
- Update project page with link to repositories
- Add repo_base_path to config
- Quality gates: ruff, mypy, typecheck, lint, build all pass
This commit is contained in:
Fusion
2026-05-18 15:47:42 +02:00
parent b179319601
commit 4e2edb1d93
27 changed files with 623 additions and 23 deletions
+188
View File
@@ -0,0 +1,188 @@
import os
import shutil
import subprocess
import uuid
from typing import Annotated
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.jwt_service import decode_access_token
from src.config import Settings
from src.database import SessionLocal
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.user import User
router = APIRouter(prefix="/projects", tags=["git-repositories"])
async def get_db_session():
async with SessionLocal() as session:
yield session
async def get_current_user_id(
access_token: Annotated[str | None, Cookie()] = None,
) -> uuid.UUID:
if not access_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
try:
claims = decode_access_token(settings=Settings(), token=access_token)
return uuid.UUID(str(claims["sub"]))
except Exception:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
return user
async def _get_owned_project(
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
) -> Project:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
if project.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
return project
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
base = Settings().repo_base_path or "/data/repos"
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
class GitRepositoryCreate(BaseModel):
name: str
remote_url: str | None = None
class GitRepositoryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
path: str
project_id: uuid.UUID
owner_id: uuid.UUID
is_mirror: bool
remote_url: str | None
last_push: str | None
created_at: str | None
@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED)
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:
_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")
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 data.remote_url:
# Clone as mirror
try:
result = subprocess.run(
["git", "clone", "--mirror", data.remote_url, repo_path],
capture_output=True,
text=True,
timeout=300,
)
if result.returncode != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"failed to clone repository: {result.stderr}",
)
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")
else:
# Init bare repo
try:
result = subprocess.run(
["git", "init", "--bare", repo_path],
capture_output=True,
text=True,
check=True,
)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
repo = GitRepository(
name=data.name,
path=repo_path,
project_id=project_id,
owner_id=user_id,
is_mirror=bool(data.remote_url),
remote_url=data.remote_url,
)
session.add(repo)
await session.commit()
await session.refresh(repo)
return repo
@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse])
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]:
_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)
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:
_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)
+12
View File
@@ -1,3 +1,5 @@
import os
import shutil
import uuid
from typing import Annotated
@@ -9,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.jwt_service import decode_access_token
from src.config import Settings
from src.database import SessionLocal
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.user import User
@@ -135,6 +138,15 @@ async def delete_project(
) -> Response:
await _get_user(session, user_id)
project = await _get_owned_project(project_id, user_id, session)
# Delete repositories from disk and database
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
repositories = result.scalars().all()
for repo in repositories:
if os.path.exists(repo.path):
shutil.rmtree(repo.path)
await session.delete(repo)
await session.delete(project)
await session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+3
View File
@@ -45,6 +45,9 @@ class Settings(BaseSettings):
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 7
# Repository storage
repo_base_path: str = "/data/repos"
model_config = SettingsConfigDict(env_file=".env", extra="ignore", populate_by_name=True)
@property
+2
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from src.api.auth import router as auth_router
from src.api.git_repositories import router as git_repositories_router
from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.users import router as users_router
@@ -11,4 +12,5 @@ app.include_router(auth_router)
app.include_router(projects_router)
app.include_router(users_router)
app.include_router(ssh_keys_router)
app.include_router(git_repositories_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+35
View File
@@ -0,0 +1,35 @@
import { apiClient } from "./client";
export interface GitRepository {
id: string;
name: string;
path: string;
project_id: string;
owner_id: string;
is_mirror: boolean;
remote_url: string | null;
last_push: string | null;
created_at: string | null;
}
export interface GitRepositoryCreate {
name: string;
remote_url?: string;
}
export async function listRepositories(projectId: string): Promise<GitRepository[]> {
const response = await apiClient.get(`/projects/${projectId}/repositories`);
return response.data;
}
export async function createRepository(
projectId: string,
data: GitRepositoryCreate
): Promise<GitRepository> {
const response = await apiClient.post(`/projects/${projectId}/repositories`, data);
return response.data;
}
export async function deleteRepository(projectId: string, repoId: string): Promise<void> {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
}
-1
View File
@@ -5,7 +5,6 @@ import { useAuth } from "../state/auth";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/projects", label: "Projects" },
{ to: "/repositories", label: "Repositories" },
{ to: "/ssh-keys", label: "SSH Keys" },
{ to: "/settings", label: "Settings" }
];
+185
View File
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {
createRepository,
deleteRepository,
listRepositories,
type GitRepositoryCreate,
} from "../api/git_repositories";
import type { GitRepository } from "../api/git_repositories";
type RepoStatus = "loading" | "ready" | "error";
export const GitRepositoriesPage = () => {
const { projectId } = useParams<{ projectId: string }>();
const [status, setStatus] = useState<RepoStatus>("loading");
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [formName, setFormName] = useState("");
const [formRemoteUrl, setFormRemoteUrl] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
setStatus("loading");
try {
const data = await listRepositories(projectId);
setRepositories(data);
setStatus("ready");
} catch {
setRepositories([]);
setStatus("error");
}
}, [projectId]);
useEffect(() => {
void loadRepositories();
}, [loadRepositories]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Repository name is required");
return;
}
if (!projectId) return;
try {
const input: GitRepositoryCreate = {
name: formName.trim(),
remote_url: formRemoteUrl.trim() || undefined,
};
await createRepository(projectId, input);
setShowCreate(false);
setFormName("");
setFormRemoteUrl("");
await loadRepositories();
} catch {
setFormError("Failed to create repository");
}
};
const handleDelete = async (repoId: string) => {
if (!projectId) return;
try {
await deleteRepository(projectId, repoId);
setDeleteConfirmId(null);
await loadRepositories();
} catch {
setDeleteConfirmId(null);
}
};
const isEmpty = status === "ready" && repositories.length === 0;
return (
<section className="stack">
<div className="page-header">
<h1>Repositories</h1>
<button className="primary-button" onClick={() => setShowCreate(true)} type="button">
New Repository
</button>
</div>
{status === "loading" && <p className="muted">Loading repositories...</p>}
{status === "error" && (
<div className="card stack">
<p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
Retry
</button>
</div>
)}
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>}
{status === "ready" && repositories.length > 0 && (
<div className="repository-list">
{repositories.map((repo) => (
<article className="card repository-card" key={repo.id}>
<div className="repository-info">
<h3>{repo.name}</h3>
{repo.is_mirror && repo.remote_url && (
<p className="muted">Mirror of {repo.remote_url}</p>
)}
<p className="muted">{repo.path}</p>
</div>
<div className="repository-actions">
{deleteConfirmId === repo.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={() => void handleDelete(repo.id)}
type="button"
>
Delete
</button>
<button
className="ghost-button"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={() => setDeleteConfirmId(repo.id)}
type="button"
>
Delete
</button>
)}
</div>
</article>
))}
</div>
)}
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Create Repository</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="repository-name"
/>
</label>
<label className="form-field">
Remote URL (optional)
<input
type="text"
value={formRemoteUrl}
onChange={(e) => setFormRemoteUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
/>
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
Cancel
</button>
<button className="primary-button" type="submit">
Create
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};
+5
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
createProject,
deleteProject,
@@ -134,6 +136,9 @@ export const ProjectsPage = () => {
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<Link className="ghost-button" to={`/projects/${project.id}/repositories`}>
Repositories
</Link>
<button
className="ghost-button"
onClick={() => openEdit(project)}
+2 -1
View File
@@ -6,6 +6,7 @@ import { DashboardPage } from "./pages/dashboard";
import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder";
import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects";
import { GitRepositoriesPage } from "./pages/git-repositories";
import { SSHKeysPage } from "./pages/ssh-keys";
export const AppRouter = () => {
@@ -22,7 +23,7 @@ export const AppRouter = () => {
>
<Route index element={<DashboardPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="repositories" element={<PlaceholderPage title="Repositories" />} />
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />