feat(projets): routes /api/projects + files et git re-racinés

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-19 10:47:04 +02:00
1 parent 8d0b57ad0d
commit b781b92631
5 files changed
+170 -32

No files matched your search

+5 -1
View File
@@ -15,7 +15,10 @@ from fastapi.staticfiles import StaticFiles
from . import agent_config, coder, db, rag from . import agent_config, coder, db, rag
from .config import settings from .config import settings
from .ollama_client import ollama from .ollama_client import ollama
from .routes import benchmark, chat, config, files, git, mcp, models, sessions, shell, system from .routes import (
benchmark, chat, config, files, git, mcp, models, projects, sessions,
shell, system,
)
async def _warm_default_model() -> None: async def _warm_default_model() -> None:
@@ -78,6 +81,7 @@ app.include_router(system.router)
app.include_router(benchmark.router) app.include_router(benchmark.router)
app.include_router(git.router) app.include_router(git.router)
app.include_router(mcp.router) app.include_router(mcp.router)
app.include_router(projects.router)
@app.get("/api/health") @app.get("/api/health")
+33 -19
View File
@@ -1,4 +1,8 @@
"""Routes du workspace : arborescence, contenu, téléchargement, suppression.""" """Routes du workspace : arborescence, contenu, téléchargement, suppression.
Toutes les routes acceptent un paramètre optionnel ``project`` : la requête
est alors re-racinée sur ``workspace/<projet>`` (même confinement).
"""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -7,23 +11,31 @@ import shutil
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from ..config import settings from .. import tools as tools_mod
from ..tools import ToolError, _safe_path from ..tools import ToolError, _safe_path
router = APIRouter(prefix="/api/files", tags=["files"]) router = APIRouter(prefix="/api/files", tags=["files"])
def _tree(path: str) -> list[dict]: def _activate(project: str | None) -> None:
"""Arborescence triée (dossiers d'abord) du workspace.""" """Pose le projet actif pour la requête (400 si nom invalide)."""
try:
tools_mod.set_project(project or None)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
def _tree(path: str, root: str) -> list[dict]:
"""Arborescence triée (dossiers d'abord) de la racine active."""
items = [] items = []
for name in sorted(os.listdir(path)): for name in sorted(os.listdir(path)):
if name.startswith("."): if name.startswith("."):
continue continue
full = os.path.join(path, name) full = os.path.join(path, name)
rel = os.path.relpath(full, os.path.abspath(settings.workspace_dir)) rel = os.path.relpath(full, root)
if os.path.isdir(full): if os.path.isdir(full):
items.append({"name": name, "path": rel, "type": "dir", items.append({"name": name, "path": rel, "type": "dir",
"children": _tree(full)}) "children": _tree(full, root)})
else: else:
items.append({"name": name, "path": rel, "type": "file", items.append({"name": name, "path": rel, "type": "file",
"size": os.path.getsize(full)}) "size": os.path.getsize(full)})
@@ -33,14 +45,15 @@ def _tree(path: str) -> list[dict]:
@router.get("") @router.get("")
async def list_files() -> dict: async def list_files(project: str | None = None) -> dict:
root = os.path.abspath(settings.workspace_dir) _activate(project)
os.makedirs(root, exist_ok=True) root = tools_mod.active_root()
return {"tree": _tree(root)} return {"tree": _tree(root, root)}
@router.get("/content") @router.get("/content")
async def file_content(path: str) -> dict: async def file_content(path: str, project: str | None = None) -> dict:
_activate(project)
try: try:
target = _safe_path(path) target = _safe_path(path)
except ToolError as exc: except ToolError as exc:
@@ -52,8 +65,9 @@ async def file_content(path: str) -> dict:
@router.get("/download") @router.get("/download")
async def download_file(path: str) -> FileResponse: async def download_file(path: str, project: str | None = None) -> FileResponse:
"""Télécharge un fichier en conservant le confinement au workspace.""" """Télécharge un fichier en conservant le confinement au workspace."""
_activate(project)
try: try:
target = _safe_path(path) target = _safe_path(path)
except ToolError as exc: except ToolError as exc:
@@ -68,20 +82,20 @@ async def download_file(path: str) -> FileResponse:
@router.delete("") @router.delete("")
async def delete_file(path: str) -> dict: async def delete_file(path: str, project: str | None = None) -> dict:
"""Supprime un fichier ou un dossier (récursif) du workspace. """Supprime un fichier ou un dossier (récursif) de la racine active.
Même confinement que le téléchargement (`_safe_path`) ; la racine du Même confinement que le téléchargement (`_safe_path`) ; la racine active
workspace est refusée. Les dotfiles (.git…) ne sont jamais listés par est refusée. Les dotfiles (.git…) ne sont jamais listés par
l'arborescence, donc inaccessibles depuis l'UI. l'arborescence, donc inaccessibles depuis l'UI.
""" """
_activate(project)
try: try:
target = _safe_path(path) target = _safe_path(path)
except ToolError as exc: except ToolError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
root = os.path.abspath(settings.workspace_dir) if os.path.abspath(target) == tools_mod.active_root():
if os.path.abspath(target) == root: raise HTTPException(400, "suppression de la racine refusée")
raise HTTPException(400, "suppression de la racine du workspace refusée")
if os.path.isdir(target): if os.path.isdir(target):
shutil.rmtree(target) shutil.rmtree(target)
elif os.path.isfile(target): elif os.path.isfile(target):
+26 -12
View File
@@ -6,23 +6,32 @@ modification. Toutes les commandes sont exécutées DANS le workspace.
""" """
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from .. import tools
from ..coder import ensure_git from ..coder import ensure_git
from ..config import settings from ..config import settings
router = APIRouter(prefix="/api/git", tags=["git"]) router = APIRouter(prefix="/api/git", tags=["git"])
def _git(*args: str, timeout: int = 15) -> subprocess.CompletedProcess: def _git(
"""Exécute une commande git dans le workspace.""" *args: str, timeout: int = 15, project: str | None = None
ensure_git(settings.workspace_dir) ) -> subprocess.CompletedProcess:
"""Exécute une commande git dans le workspace ou le projet demandé."""
root = os.path.abspath(settings.workspace_dir)
if project:
if not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
root = os.path.join(root, project)
ensure_git(root)
return subprocess.run( return subprocess.run(
["git", *args], ["git", *args],
cwd=settings.workspace_dir, cwd=root,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
@@ -30,10 +39,13 @@ def _git(*args: str, timeout: int = 15) -> subprocess.CompletedProcess:
@router.get("/log") @router.get("/log")
async def git_log(limit: int = 40) -> dict: async def git_log(limit: int = 40, project: str | None = None) -> dict:
"""Historique des commits (hash court, message, auteur, date, nb fichiers).""" """Historique des commits (hash court, message, auteur, date, nb fichiers)."""
fmt = "%h%x1f%s%x1f%an%x1f%ar%x1f%H" fmt = "%h%x1f%s%x1f%an%x1f%ar%x1f%H"
proc = _git("log", f"-{max(1, min(limit, 200))}", f"--pretty=format:{fmt}") proc = _git(
"log", f"-{max(1, min(limit, 200))}", f"--pretty=format:{fmt}",
project=project,
)
if proc.returncode != 0: if proc.returncode != 0:
# Dépôt sans commit encore. # Dépôt sans commit encore.
return {"commits": []} return {"commits": []}
@@ -45,7 +57,8 @@ async def git_log(limit: int = 40) -> dict:
continue continue
short, subject, author, when, full = parts short, subject, author, when, full = parts
# Nombre de fichiers touchés par ce commit. # Nombre de fichiers touchés par ce commit.
stat = _git("show", "--stat", "--oneline", "--format=", full) stat = _git("show", "--stat", "--oneline", "--format=", full,
project=project)
files = [l for l in stat.stdout.splitlines() if "|" in l] files = [l for l in stat.stdout.splitlines() if "|" in l]
commits.append({ commits.append({
"hash": short, "hash": short,
@@ -59,14 +72,14 @@ async def git_log(limit: int = 40) -> dict:
@router.get("/diff") @router.get("/diff")
async def git_diff(hash: str | None = None) -> dict: async def git_diff(hash: str | None = None, project: str | None = None) -> dict:
"""Diff d'un commit (hash) ou des modifications non commitées si absent.""" """Diff d'un commit (hash) ou des modifications non commitées si absent."""
if hash: if hash:
if not hash.replace("-", "").isalnum(): if not hash.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide") raise HTTPException(400, "hash invalide")
proc = _git("show", "--no-color", hash) proc = _git("show", "--no-color", hash, project=project)
else: else:
proc = _git("diff", "--no-color", "HEAD") proc = _git("diff", "--no-color", "HEAD", project=project)
if proc.returncode != 0: if proc.returncode != 0:
raise HTTPException(404, "diff introuvable") raise HTTPException(404, "diff introuvable")
# Borne la taille pour ne pas noyer l'UI. # Borne la taille pour ne pas noyer l'UI.
@@ -75,6 +88,7 @@ async def git_diff(hash: str | None = None) -> dict:
class RevertRequest(BaseModel): class RevertRequest(BaseModel):
hash: str hash: str
project: str | None = None
@router.post("/revert") @router.post("/revert")
@@ -83,10 +97,10 @@ async def git_revert(req: RevertRequest) -> dict:
h = req.hash.strip() h = req.hash.strip()
if not h.replace("-", "").isalnum(): if not h.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide") raise HTTPException(400, "hash invalide")
proc = _git("revert", "--no-edit", h, timeout=30) proc = _git("revert", "--no-edit", h, timeout=30, project=req.project)
if proc.returncode != 0: if proc.returncode != 0:
# Conflit ou commit introuvable : on nettoie un éventuel revert en cours. # Conflit ou commit introuvable : on nettoie un éventuel revert en cours.
_git("revert", "--abort") _git("revert", "--abort", project=req.project)
detail = (proc.stderr or proc.stdout).strip()[:300] detail = (proc.stderr or proc.stdout).strip()[:300]
raise HTTPException(409, f"revert impossible : {detail}") raise HTTPException(409, f"revert impossible : {detail}")
return {"ok": True, "message": f"commit {h[:7]} annulé"} return {"ok": True, "message": f"commit {h[:7]} annulé"}
+60
View File
@@ -0,0 +1,60 @@
"""Routes des projets : sous-dossiers de premier niveau du workspace."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import coder, tools
from ..config import settings
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _count_files(path: str) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
total += sum(1 for f in filenames if not f.startswith("."))
return total
@router.get("")
async def list_projects() -> dict:
root = _root()
projects = []
root_files = 0
for name in sorted(os.listdir(root)):
full = os.path.join(root, name)
if name.startswith("."):
continue
if os.path.isdir(full):
projects.append({"name": name, "files": _count_files(full)})
else:
root_files += 1
return {"projects": projects, "root_files": root_files}
class CreateProject(BaseModel):
name: str
@router.post("", status_code=201)
async def create_project(req: CreateProject) -> dict:
name = req.name.strip()
if not tools.PROJECT_NAME.match(name):
raise HTTPException(400, "nom de projet invalide (a-z, 0-9, - et _)")
target = os.path.join(_root(), name)
if os.path.exists(target):
raise HTTPException(400, "ce projet existe déjà")
os.makedirs(target)
# Dépôt git par projet : commits Aider + onglet Git propres au projet.
coder.ensure_git(target)
return {"name": name}
+46
View File
@@ -0,0 +1,46 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.config import settings # noqa: E402
client = TestClient(app)
_ROOT = os.path.abspath(settings.workspace_dir)
def test_creation_et_liste():
r = client.post("/api/projects", json={"name": "routedemo"})
assert r.status_code == 201
assert os.path.isdir(os.path.join(_ROOT, "routedemo", ".git"))
names = [p["name"] for p in client.get("/api/projects").json()["projects"]]
assert "routedemo" in names
def test_nom_invalide_400():
assert client.post("/api/projects", json={"name": "../x"}).status_code == 400
assert client.post("/api/projects", json={"name": "Demo"}).status_code == 400
def test_existant_400():
client.post("/api/projects", json={"name": "dup"})
assert client.post("/api/projects", json={"name": "dup"}).status_code == 400
def test_files_re_racine():
client.post("/api/projects", json={"name": "scoped"})
with open(os.path.join(_ROOT, "scoped", "a.txt"), "w") as f:
f.write("x")
with open(os.path.join(_ROOT, "racine.txt"), "w") as f:
f.write("y")
tree = client.get("/api/files", params={"project": "scoped"}).json()["tree"]
names = [n["name"] for n in tree]
assert "a.txt" in names and "racine.txt" not in names
def test_files_projet_invalide_400():
assert client.get("/api/files", params={"project": "../x"}).status_code == 400