From b781b92631f337d2738252ab6bda96d6d0b4d7f3 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 19 Jul 2026 10:47:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(projets):=20routes=20/api/projects=20+=20f?= =?UTF-8?q?iles=20et=20git=20re-racin=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/app/main.py | 6 ++- backend/app/routes/files.py | 52 ++++++++++++++--------- backend/app/routes/git.py | 38 +++++++++++------ backend/app/routes/projects.py | 60 +++++++++++++++++++++++++++ backend/tests/test_projects_routes.py | 46 ++++++++++++++++++++ 5 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 backend/app/routes/projects.py create mode 100644 backend/tests/test_projects_routes.py diff --git a/backend/app/main.py b/backend/app/main.py index 0c1b97c..16aeaa2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,10 @@ from fastapi.staticfiles import StaticFiles from . import agent_config, coder, db, rag from .config import settings 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: @@ -78,6 +81,7 @@ app.include_router(system.router) app.include_router(benchmark.router) app.include_router(git.router) app.include_router(mcp.router) +app.include_router(projects.router) @app.get("/api/health") diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index 42dcaf5..467833b 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -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/`` (même confinement). +""" from __future__ import annotations import os @@ -7,23 +11,31 @@ import shutil from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse -from ..config import settings +from .. import tools as tools_mod from ..tools import ToolError, _safe_path router = APIRouter(prefix="/api/files", tags=["files"]) -def _tree(path: str) -> list[dict]: - """Arborescence triée (dossiers d'abord) du workspace.""" +def _activate(project: str | None) -> None: + """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 = [] for name in sorted(os.listdir(path)): if name.startswith("."): continue 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): items.append({"name": name, "path": rel, "type": "dir", - "children": _tree(full)}) + "children": _tree(full, root)}) else: items.append({"name": name, "path": rel, "type": "file", "size": os.path.getsize(full)}) @@ -33,14 +45,15 @@ def _tree(path: str) -> list[dict]: @router.get("") -async def list_files() -> dict: - root = os.path.abspath(settings.workspace_dir) - os.makedirs(root, exist_ok=True) - return {"tree": _tree(root)} +async def list_files(project: str | None = None) -> dict: + _activate(project) + root = tools_mod.active_root() + return {"tree": _tree(root, root)} @router.get("/content") -async def file_content(path: str) -> dict: +async def file_content(path: str, project: str | None = None) -> dict: + _activate(project) try: target = _safe_path(path) except ToolError as exc: @@ -52,8 +65,9 @@ async def file_content(path: str) -> dict: @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.""" + _activate(project) try: target = _safe_path(path) except ToolError as exc: @@ -68,20 +82,20 @@ async def download_file(path: str) -> FileResponse: @router.delete("") -async def delete_file(path: str) -> dict: - """Supprime un fichier ou un dossier (récursif) du workspace. +async def delete_file(path: str, project: str | None = None) -> dict: + """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 - workspace est refusée. Les dotfiles (.git…) ne sont jamais listés par + Même confinement que le téléchargement (`_safe_path`) ; la racine active + est refusée. Les dotfiles (.git…) ne sont jamais listés par l'arborescence, donc inaccessibles depuis l'UI. """ + _activate(project) try: target = _safe_path(path) except ToolError as exc: raise HTTPException(400, str(exc)) from exc - root = os.path.abspath(settings.workspace_dir) - if os.path.abspath(target) == root: - raise HTTPException(400, "suppression de la racine du workspace refusée") + if os.path.abspath(target) == tools_mod.active_root(): + raise HTTPException(400, "suppression de la racine refusée") if os.path.isdir(target): shutil.rmtree(target) elif os.path.isfile(target): diff --git a/backend/app/routes/git.py b/backend/app/routes/git.py index d659ccb..80feba2 100644 --- a/backend/app/routes/git.py +++ b/backend/app/routes/git.py @@ -6,23 +6,32 @@ modification. Toutes les commandes sont exécutées DANS le workspace. """ from __future__ import annotations +import os import subprocess from fastapi import APIRouter, HTTPException from pydantic import BaseModel +from .. import tools from ..coder import ensure_git from ..config import settings router = APIRouter(prefix="/api/git", tags=["git"]) -def _git(*args: str, timeout: int = 15) -> subprocess.CompletedProcess: - """Exécute une commande git dans le workspace.""" - ensure_git(settings.workspace_dir) +def _git( + *args: str, timeout: int = 15, project: str | None = None +) -> 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( ["git", *args], - cwd=settings.workspace_dir, + cwd=root, capture_output=True, text=True, timeout=timeout, @@ -30,10 +39,13 @@ def _git(*args: str, timeout: int = 15) -> subprocess.CompletedProcess: @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).""" 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: # Dépôt sans commit encore. return {"commits": []} @@ -45,7 +57,8 @@ async def git_log(limit: int = 40) -> dict: continue short, subject, author, when, full = parts # 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] commits.append({ "hash": short, @@ -59,14 +72,14 @@ async def git_log(limit: int = 40) -> dict: @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.""" if hash: if not hash.replace("-", "").isalnum(): raise HTTPException(400, "hash invalide") - proc = _git("show", "--no-color", hash) + proc = _git("show", "--no-color", hash, project=project) else: - proc = _git("diff", "--no-color", "HEAD") + proc = _git("diff", "--no-color", "HEAD", project=project) if proc.returncode != 0: raise HTTPException(404, "diff introuvable") # 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): hash: str + project: str | None = None @router.post("/revert") @@ -83,10 +97,10 @@ async def git_revert(req: RevertRequest) -> dict: h = req.hash.strip() if not h.replace("-", "").isalnum(): 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: # 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] raise HTTPException(409, f"revert impossible : {detail}") return {"ok": True, "message": f"commit {h[:7]} annulé"} diff --git a/backend/app/routes/projects.py b/backend/app/routes/projects.py new file mode 100644 index 0000000..aae47e5 --- /dev/null +++ b/backend/app/routes/projects.py @@ -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} diff --git a/backend/tests/test_projects_routes.py b/backend/tests/test_projects_routes.py new file mode 100644 index 0000000..5c02866 --- /dev/null +++ b/backend/tests/test_projects_routes.py @@ -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