import json import re import subprocess from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from .. import models from ..database import get_db router = APIRouter(tags=["git"]) # ── Helpers ─────────────────────────────────────────────────────────────────── def _run(*args, cwd: Path) -> subprocess.CompletedProcess: return subprocess.run( ["git", *args], capture_output=True, text=True, cwd=str(cwd), timeout=30, ) def _repo_root() -> Path: backend_dir = Path(__file__).parent.parent.parent r = _run("rev-parse", "--show-toplevel", cwd=backend_dir) if r.returncode != 0: raise HTTPException(500, "Not in a git repository") return Path(r.stdout.strip()) def _diagrams_dir(repo_root: Path) -> Path: d = repo_root / "diagrams" d.mkdir(exist_ok=True) return d def _safe_name(diagram_id: int, name: str) -> str: safe = re.sub(r"[^\w\-]", "_", name) return f"{diagram_id:04d}_{safe}.json" def _diagram_to_dict(diagram, db: Session) -> dict: return { "id": diagram.id, "name": diagram.name, "description": diagram.description or "", "devices": [ { "id": d.id, "device_type": d.device_type, "label": d.label, "reference": d.reference, "x": d.x, "y": d.y, "width": d.width, "height": d.height, "properties": d.properties, "pins": d.pins, } for d in diagram.devices ], "wires": [ { "id": w.id, "label": w.label, "from_device_id": w.from_device_id, "from_pin": w.from_pin, "to_device_id": w.to_device_id, "to_pin": w.to_pin, "color_primary": w.color_primary, "color_stripe": w.color_stripe, "gauge": w.gauge, "length": w.length, "length_unit": w.length_unit, "notes": w.notes, "waypoints": w.waypoints or [], "twisted_pair": bool(w.twisted_pair), "twist_pitch": w.twist_pitch, "shielded": bool(w.shielded), "show_size_label": bool(w.show_size_label), } for w in diagram.wires ], } def _import_data(data: dict, db: Session, name_override: Optional[str] = None) -> models.Diagram: new_diag = models.Diagram( name=name_override or data.get("name", "Restored Diagram"), description=data.get("description", ""), ) db.add(new_diag) db.flush() id_map = {} for dev in data.get("devices", []): nd = models.Device( diagram_id=new_diag.id, device_type=dev.get("device_type", "generic"), label=dev.get("label", ""), reference=dev.get("reference", ""), x=dev.get("x", 100), y=dev.get("y", 100), width=dev.get("width", 120), height=dev.get("height", 60), properties=dev.get("properties", {}), pins=dev.get("pins", []), ) db.add(nd) db.flush() if "id" in dev: id_map[dev["id"]] = nd.id for w in data.get("wires", []): nw = models.Wire( diagram_id=new_diag.id, label=w.get("label", ""), from_device_id=id_map.get(w.get("from_device_id")), from_pin=w.get("from_pin", ""), to_device_id=id_map.get(w.get("to_device_id")), to_pin=w.get("to_pin", ""), color_primary=w.get("color_primary", "#FF0000"), color_stripe=w.get("color_stripe"), gauge=w.get("gauge", "18 AWG"), length=w.get("length"), length_unit=w.get("length_unit", "in"), notes=w.get("notes", ""), waypoints=w.get("waypoints", []), twisted_pair=w.get("twisted_pair", False), twist_pitch=w.get("twist_pitch", 16.0), shielded=w.get("shielded", False), show_size_label=w.get("show_size_label", False), ) db.add(nw) db.commit() db.refresh(new_diag) return new_diag # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/status") def git_status(): repo_root = _repo_root() branch_r = _run("rev-parse", "--abbrev-ref", "HEAD", cwd=repo_root) branch = branch_r.stdout.strip() if branch_r.returncode == 0 else "unknown" status_r = _run("status", "--porcelain", cwd=repo_root) changed = [l for l in status_r.stdout.splitlines() if l.strip()] ahead_r = _run("rev-list", "--count", "@{u}..HEAD", cwd=repo_root) ahead = int(ahead_r.stdout.strip()) if ahead_r.returncode == 0 and ahead_r.stdout.strip().isdigit() else 0 return { "branch": branch, "is_clean": len(changed) == 0, "changed_count": len(changed), "commits_ahead": ahead, } @router.get("/log") def git_log(): repo_root = _repo_root() r = _run("log", "--pretty=format:%H|%h|%s|%an|%ai", "-30", cwd=repo_root) if r.returncode != 0: return [] entries = [] for line in r.stdout.splitlines(): parts = line.split("|", 4) if len(parts) == 5: entries.append({ "hash": parts[0], "short": parts[1], "message": parts[2], "author": parts[3], "date": parts[4], }) return entries class CommitBody(BaseModel): message: str @router.post("/commit") def git_commit(body: CommitBody, db: Session = Depends(get_db)): if not body.message.strip(): raise HTTPException(400, "Commit message required") repo_root = _repo_root() diagrams_dir = _diagrams_dir(repo_root) # Export all diagrams to diagrams/ folder diagrams = db.query(models.Diagram).all() for diagram in diagrams: data = _diagram_to_dict(diagram, db) fname = _safe_name(diagram.id, diagram.name) (diagrams_dir / fname).write_text(json.dumps(data, indent=2), encoding="utf-8") add_r = _run("add", "diagrams/", cwd=repo_root) if add_r.returncode != 0: raise HTTPException(500, f"git add failed: {add_r.stderr}") commit_r = _run("commit", "-m", body.message.strip(), cwd=repo_root) if commit_r.returncode != 0: out = commit_r.stdout + commit_r.stderr if "nothing to commit" in out: return {"status": "nothing_to_commit", "output": "Nothing to commit — diagrams unchanged since last commit"} raise HTTPException(500, f"git commit failed: {commit_r.stderr}") return {"status": "committed", "output": commit_r.stdout.strip()} @router.post("/push") def git_push(): repo_root = _repo_root() r = _run("push", cwd=repo_root) if r.returncode != 0: raise HTTPException(500, f"git push failed: {r.stderr.strip()}") return {"status": "pushed", "output": (r.stdout + r.stderr).strip()} @router.get("/history/{commit_hash}") def git_history_files(commit_hash: str): repo_root = _repo_root() r = _run("ls-tree", "--name-only", commit_hash, "diagrams/", cwd=repo_root) if r.returncode != 0: return {"files": []} files = [f for f in r.stdout.splitlines() if f.endswith(".json")] return {"files": files} class RestoreBody(BaseModel): commit_hash: str filepath: str # e.g. "diagrams/0001_My_Diagram.json" name: Optional[str] = None @router.post("/restore") def git_restore(body: RestoreBody, db: Session = Depends(get_db)): repo_root = _repo_root() r = _run("show", f"{body.commit_hash}:{body.filepath}", cwd=repo_root) if r.returncode != 0: raise HTTPException(404, f"File not found in that commit") try: data = json.loads(r.stdout) except json.JSONDecodeError: raise HTTPException(500, "Invalid JSON in git history") short_hash = body.commit_hash[:7] name = body.name or f"{data.get('name', 'Diagram')} (restored {short_hash})" new_diag = _import_data(data, db, name_override=name) return {"id": new_diag.id, "name": new_diag.name}