Add git version control panel and LAN hosting support

- Git modal (⎇ Git button): commit all diagrams as JSON to diagrams/ folder,
  push to remote, view history, restore any diagram from any past commit
- Diagrams exported to diagrams/{id}_{name}.json on each commit so diffs are
  human-readable and individual diagrams can be restored independently
- backend/run.py: changed host to 0.0.0.0 for LAN access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 19:48:29 -04:00
parent e4a80dc0e6
commit 01e7b5630b
7 changed files with 450 additions and 2 deletions
+2 -1
View File
@@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy import text, inspect as sa_inspect
from .database import Base, engine, SessionLocal
from .routers import bundles, connectors, diagrams, devices, export, octopart, views, wires
from .routers import bundles, connectors, diagrams, devices, export, git_mgr, octopart, views, wires
Base.metadata.create_all(bind=engine)
@@ -70,6 +70,7 @@ app.include_router(views.router, prefix="/api/views")
app.include_router(octopart.router, prefix="/api/octopart")
app.include_router(export.router, prefix="/api/export")
app.include_router(connectors.router, prefix="/api/connectors")
app.include_router(git_mgr.router, prefix="/api/git")
FRONTEND_DIR = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..", "frontend")
+235
View File
@@ -0,0 +1,235 @@
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}