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" />} />
@@ -0,0 +1,21 @@
## 1. Backend SSH Key API
- [x] 1.1 Create `apps/api/src/api/ssh_keys.py` with endpoints for list, generate, and delete SSH keys.
- [x] 1.2 Implement Ed25519 key generation using cryptography library.
- [x] 1.3 Implement Fernet encryption for private keys.
- [x] 1.4 Add Pydantic schemas for SSHKeyCreate, SSHKeyResponse.
- [x] 1.5 Register ssh_keys router in `apps/api/src/main.py`.
- [x] 1.6 Add backend tests for SSH key CRUD operations.
## 2. Frontend SSH Keys Page
- [x] 2.1 Create `apps/web/src/api/ssh_keys.ts` with API methods.
- [x] 2.2 Create `apps/web/src/pages/ssh-keys.tsx` with key list, generate form, and delete action.
- [x] 2.3 Add `/ssh-keys` route in `apps/web/src/router.tsx`.
- [x] 2.4 Update app shell navigation to include SSH keys link.
## 3. Verification
- [x] 3.1 Run backend checks (pytest, ruff, mypy).
- [x] 3.2 Run frontend checks (npm test, typecheck, lint, build).
- [x] 3.3 Update tasks file with completed checkboxes.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-18
@@ -0,0 +1,53 @@
## Context
The platform has projects and SSH keys but no git repository management. Users need to create bare repos for their projects and optionally clone external ones.
## Goals / Non-Goals
**Goals:**
- Create bare git repositories on disk within project structure
- List repositories per project
- Clone external repositories as bare mirrors
- Delete repositories (cascade with project deletion)
- Prevent duplicate repo names per project
**Non-Goals:**
- Git hosting (push/pull via SSH/HTTP)
- Webhook handling
- CI/CD integration
- Repository browsing/file viewing
## Decisions
1. **Bare repositories only**
- Simplifies storage, no working tree needed
- Standard pattern for git servers
2. **Storage path: `/data/repos/{user_id}/{project_id}/{name}.git`**
- Isolates repos by user and project
- Predictable structure
3. **Use subprocess to run `git init --bare` and `git clone --mirror`**
- Standard git commands, no additional dependencies
- More reliable than git libraries for basic operations
4. **Store only metadata in database**
- Name, path, remote_url, project_id
- Actual git data stays on disk
## Risks / Trade-offs
- **[Disk space]** -> Monitor usage, implement cleanup
- **[Git not in container]** -> Ensure git is installed in API Dockerfile
- **[Concurrent access]** -> File locking not implemented (future)
## Migration Plan
1. Create API endpoints
2. Add git to Dockerfile
3. Create frontend
4. Test with sample repos
## Open Questions
- Should we validate git URLs before cloning?
@@ -0,0 +1,25 @@
## Why
Projects exist but users cannot create or manage git repositories. The platform needs to allow users to create bare repositories, clone external ones, and manage them within projects.
## What Changes
- Add backend API for git repository CRUD operations
- Implement bare repository initialization on disk
- Support cloning external repositories as mirrors
- Add frontend page for repository management
- Integrate with existing project structure
## Capabilities
### New Capabilities
- `git-repo-management`: Full git repository lifecycle within projects
### Modified Capabilities
- `project-management`: Include repositories in project responses
## Impact
- New API endpoints under `/projects/{id}/repositories`
- Disk storage at `/data/repos/{user_id}/{project_id}/{repo_name}.git`
- Frontend repository list and creation UI
@@ -0,0 +1,62 @@
## ADDED Requirements
### Requirement: Repository Creation
The system SHALL allow creating new bare git repositories within projects.
#### Scenario: Create repository
- GIVEN an authenticated user with a project
- WHEN they POST a repository name
- THEN a bare repo is initialized on disk at `/data/repos/{user_id}/{project_id}/{repo_name}.git`
- AND metadata is stored in the database
- AND the response includes the repository details
#### Scenario: Duplicate name prevention
- GIVEN a project with a repo named "frontend"
- WHEN the user tries to create another "frontend" repo
- THEN the system responds with 400 Bad Request
### Requirement: Repository Cloning
The system SHALL support cloning external repositories as bare mirrors.
#### Scenario: Clone repository
- GIVEN an authenticated user with a project
- WHEN they provide a valid remote URL
- THEN the system clones as a bare mirror
- AND stores it in the structured path
- AND records the remote URL in metadata
#### Scenario: Invalid URL
- GIVEN an authenticated user
- WHEN they provide an invalid or unreachable URL
- THEN the system responds with 400 Bad Request
### Requirement: Repository Listing
The system SHALL list all repositories for a project.
#### Scenario: List repositories
- GIVEN an authenticated user with a project
- WHEN they GET the repositories endpoint
- THEN all repos for that project are listed with name, path, and last push date
### Requirement: Repository Deletion
The system SHALL remove repository records and disk contents.
#### Scenario: Delete repository
- GIVEN a project owner
- WHEN they delete a repository
- THEN the database record is removed
- AND the directory on disk is removed
### Requirement: Project Cascade Delete
The system SHALL clean up repositories when a project is deleted.
#### Scenario: Cascade repository cleanup
- GIVEN a project with associated repositories
- WHEN the project owner deletes the project
- THEN all repository records for that project are removed
- AND all repository directories on disk are removed
@@ -0,0 +1,28 @@
## 1. Backend Git Repository API
- [ ] 1.1 Create `apps/api/src/api/git_repositories.py` with endpoints for list, create, clone, and delete repositories.
- [ ] 1.2 Add Pydantic schemas for GitRepositoryCreate, GitRepositoryResponse.
- [ ] 1.3 Implement bare repository initialization using `git init --bare`.
- [ ] 1.4 Implement mirror cloning using `git clone --mirror`.
- [ ] 1.5 Add ownership validation (only project owner can manage repos).
- [ ] 1.6 Add duplicate name validation per project.
- [ ] 1.7 Register router in `apps/api/src/main.py`.
- [ ] 1.8 Add backend tests for repository CRUD operations.
## 2. Frontend Git Repositories Page
- [ ] 2.1 Create `apps/web/src/api/git_repositories.ts` with API methods.
- [ ] 2.2 Create `apps/web/src/pages/git-repositories.tsx` with repo list, create form, and delete action.
- [ ] 2.3 Add route in `apps/web/src/router.tsx`.
- [ ] 2.4 Update project page to show associated repositories.
## 3. Project Integration
- [ ] 3.1 Update project deletion to cascade delete repositories.
- [ ] 3.2 Update project response to include repository count.
## 4. Verification
- [ ] 4.1 Run backend checks (pytest, ruff, mypy).
- [ ] 4.2 Run frontend checks (npm test, typecheck, lint, build).
- [ ] 4.3 Verify docker-compose config is valid.
@@ -1,21 +0,0 @@
## 1. Backend SSH Key API
- [ ] 1.1 Create `apps/api/src/api/ssh_keys.py` with endpoints for list, generate, and delete SSH keys.
- [ ] 1.2 Implement Ed25519 key generation using cryptography library.
- [ ] 1.3 Implement Fernet encryption for private keys.
- [ ] 1.4 Add Pydantic schemas for SSHKeyCreate, SSHKeyResponse.
- [ ] 1.5 Register ssh_keys router in `apps/api/src/main.py`.
- [ ] 1.6 Add backend tests for SSH key CRUD operations.
## 2. Frontend SSH Keys Page
- [ ] 2.1 Create `apps/web/src/api/ssh_keys.ts` with API methods.
- [ ] 2.2 Create `apps/web/src/pages/ssh-keys.tsx` with key list, generate form, and delete action.
- [ ] 2.3 Add `/ssh-keys` route in `apps/web/src/router.tsx`.
- [ ] 2.4 Update app shell navigation to include SSH keys link.
## 3. Verification
- [ ] 3.1 Run backend checks (pytest, ruff, mypy).
- [ ] 3.2 Run frontend checks (npm test, typecheck, lint, build).
- [ ] 3.3 Update tasks file with completed checkboxes.