"""Versionen: Schnappschüsse des Arbeitsordners und die Brücke zu git.

Ein Schnappschuss ist eine Zeile (snapshots) plus eine Zeile je Datei
(snapshot_dateien); die Inhalte liegen inhaltsadressiert in der Ablage, gleiche
Bytes nur einmal - über Schnappschüsse, Dokumente und Profilbilder hinweg. Kein
Zweig, kein Merge: eine Kette je Projekt, wie „Speichern unter Nummer“. Was git
ausschließen würde, schließen wir auch aus: IGNORIERT der Werkstatt, Verweise
und die Muster des .gitignore im Wurzelverzeichnis (ponytail: Namen, Globs,
Ordner mit „/“ - keine Negation, keine Verankerung; ohne .gitignore ginge .env
mit auf GitHub).

Die git-Brücke ruft den installierten git-Befehl mit festen Argumenten - keine
Shell, keine Eingaben aus der Oberfläche in argv (das Repository kommt aus
Project.github_repo, das die Anwendung mit einem Regexp prüft), Nachrichten über
stdin, globale git-Konfiguration abgeschaltet, Schreiben nur in
ARBEIT/<pid>.git und Temporärordner, tar aus git archive gefiltert. Deshalb
hängt sie nicht an MINERVA_BEFEHLE - dessen Zweck sind beliebige Befehle. Der
GitHub-Token wandert über eine Umgebungsvariable in einen eingebetteten
credential.helper: nie in argv, nie auf die Platte, nie in .git/config.
"""
from __future__ import annotations

import asyncio
import fnmatch
import hashlib
import io
import mimetypes
import os
import shutil
import tarfile
import tempfile
from datetime import datetime
from pathlib import Path

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select

from web import ablage, agenten, konnektoren, module
from web.app import _mit_zone, job_json, modul_noetig, person, senden, sitzung, zugriff
from web.db import Job, Project, Snapshot, SnapshotDatei, User
from web.erweiterungen.werkstatt import ARBEIT, _sha, _sichtbar, _text, arbeitsordner, pfad_sicher, umgebung

LEER_SHA = hashlib.sha256(b"").hexdigest()
DATEIEN_MAX = 5000
IMPORT_MAX = 100
GIT = shutil.which("git")
GIT_ZEIT = 600
PROTOKOLL = ["-c", "protocol.allow=never", "-c", "protocol.https.allow=always",
             "-c", "protocol.file.allow=always"]
router = APIRouter()


# -- Baum des Arbeitsordners -----------------------------------------------
def _muster(basis: Path) -> list[str]:
    datei = basis / ".gitignore"
    if not datei.is_file():
        return []
    return [z.strip() for z in datei.read_text("utf-8", "replace").splitlines()
            if z.strip() and not z.strip().startswith(("#", "!"))]


def _ignoriert(rel: str, name: str, ist_ordner: bool, muster: list[str]) -> bool:
    for m in muster:
        nur_ordner = m.endswith("/")
        if nur_ordner and not ist_ordner:
            continue
        ziel = m.rstrip("/").lstrip("/")
        if "/" in ziel:                              # pfadbezogen
            if fnmatch.fnmatch(rel, ziel) or rel.startswith(ziel + "/"):
                return True
        elif fnmatch.fnmatch(name, ziel):
            return True
    return False


def _baum(basis: Path, speichern: bool) -> tuple[dict[str, tuple[str, int, bool]], int]:
    """{pfad: (sha, groesse, ausfuehrbar)}, Zahl übersprungener Dateien. Erst
    zählen (413 bevor etwas geschrieben wird), dann lesen; mit speichern=True
    wandern die Inhalte in die Ablage, ohne (Status) nur der Hash."""
    muster = _muster(basis)
    dateien: list[tuple[str, Path]] = []
    for wurzel, ordner, namen in os.walk(basis):
        w = Path(wurzel)
        rel_w = w.relative_to(basis).as_posix() if w != basis else ""
        voll = (lambda n: f"{rel_w}/{n}" if rel_w else n)
        ordner[:] = [o for o in ordner if _sichtbar(w / o, True) and not _ignoriert(voll(o), o, True, muster)]
        for n in sorted(namen):
            if not _sichtbar(w / n, True) or _ignoriert(voll(n), n, False, muster):
                continue
            dateien.append((voll(n), w / n))
            if len(dateien) > DATEIEN_MAX:
                raise HTTPException(status_code=413, detail=f"Mehr als {DATEIEN_MAX} Dateien - "
                                    "bitte .gitignore ergänzen")
    baum, uebersprungen = {}, 0
    for rel, p in dateien:
        try:
            st = p.stat()
            if st.st_size > ablage.MAX_BYTES:
                uebersprungen += 1
                continue
            roh = p.read_bytes()
        except OSError:
            uebersprungen += 1
            continue
        sha = LEER_SHA if not roh else (ablage.speichern(roh) if speichern else _sha(roh))
        baum[rel] = (sha, len(roh), bool(st.st_mode & 0o111))
    return baum, uebersprungen


def _baum_sha(baum: dict) -> str:
    zeilen = [f"{rel}\0{sha}\0{'x' if x else '-'}" for rel, (sha, _, x) in sorted(baum.items())]
    return hashlib.sha256("\n".join(zeilen).encode()).hexdigest()


def _gleich(s, pid: int, baum_sha: str) -> Snapshot | None:
    return s.scalar(select(Snapshot).where(Snapshot.project_id == pid, Snapshot.baum_sha == baum_sha)
                    .order_by(Snapshot.nr.desc()))


def schnappschuss(s, p: Project, nutzer_id: int | None, message: str, quelle: str = "manuell",
                  baum: dict | None = None, uebersprungen: int = 0, git_commit: str | None = None,
                  created_at: datetime | None = None) -> Snapshot | None:
    """Neuer Schnappschuss; ein übergebener Baum muss gespeichert sein. Ohne
    Änderung: automatisch → None, von Hand → 409, aus git → immer (eigener Commit)."""
    if baum is None:
        baum, uebersprungen = _baum(arbeitsordner(p), speichern=True)
    bs = _baum_sha(baum)
    if quelle != "git":
        gleich = _gleich(s, p.id, bs)
        if gleich and quelle == "auto":
            return None
        if gleich:
            raise HTTPException(status_code=409, detail=f"Keine Änderungen - entspricht Schnappschuss #{gleich.nr}")
    nr = (s.scalar(select(func.max(Snapshot.nr)).where(Snapshot.project_id == p.id)) or 0) + 1
    sn = Snapshot(project_id=p.id, nr=nr, message=(message or "").strip()[:300], quelle=quelle,
                  git_commit=git_commit, baum_sha=bs, dateien=len(baum),
                  bytes=sum(g for _, g, _ in baum.values()), uebersprungen=uebersprungen,
                  created_by=nutzer_id)
    if created_at:
        sn.created_at = created_at
    s.add(sn)
    s.flush()
    s.add_all([SnapshotDatei(snapshot_id=sn.id, pfad=rel, sha=sha, groesse=g, ausfuehrbar=x)
               for rel, (sha, g, x) in sorted(baum.items())])
    s.commit()
    s.refresh(sn)
    return sn


def _zeilen(s, sn: Snapshot) -> dict[str, tuple[str, int, bool]]:
    return {z.pfad: (z.sha, z.groesse, z.ausfuehrbar) for z in sn.dateiliste}


def _vergleich(alt: dict, neu: dict) -> dict:
    return {"neu": sorted(p for p in neu if p not in alt),
            "geaendert": sorted(p for p in neu if p in alt and (alt[p][0] != neu[p][0] or alt[p][2] != neu[p][2])),
            "geloescht": sorted(p for p in alt if p not in neu)}


def _blob(sha: str) -> bytes:
    return b"" if sha == LEER_SHA else ablage.lesen(sha).read_bytes()


def _schreiben(ziel: Path, sha: str, ausfuehrbar: bool) -> None:
    ziel.parent.mkdir(parents=True, exist_ok=True)
    teil = ziel.with_name(ziel.name + ".teil")
    teil.write_bytes(_blob(sha))
    teil.replace(ziel)
    if ausfuehrbar:
        ziel.chmod(ziel.stat().st_mode | 0o111)


def wiederherstellen(s, p: Project, nutzer: User, sn: Snapshot, pfad: str | None = None) -> dict:
    """Ganzer Baum (Dateien, die es im Schnappschuss nicht gibt, verschwinden;
    Ignoriertes bleibt) oder eine Datei - vorher ein automatischer Schnappschuss."""
    basis = arbeitsordner(p)
    baum, ueb = _baum(basis, speichern=True)
    vorher = schnappschuss(s, p, nutzer.id, f"vor Wiederherstellung von #{sn.nr}", "auto", baum, ueb)
    ziel = _zeilen(s, sn)
    if pfad:
        if pfad not in ziel:
            raise HTTPException(status_code=404, detail="Datei nicht im Schnappschuss")
        sha, _, x = ziel[pfad]
        _schreiben(pfad_sicher(basis, pfad), sha, x)
        return {"geschrieben": 1, "geloescht": 0, "vorher": vorher.nr if vorher else None}
    geschrieben = 0
    for rel, (sha, _, x) in ziel.items():
        if rel not in baum or baum[rel][0] != sha or baum[rel][2] != x:
            _schreiben(pfad_sicher(basis, rel), sha, x)
            geschrieben += 1
    geloescht = 0
    for rel in baum:
        if rel not in ziel:
            (basis / rel).unlink(missing_ok=True)
            geloescht += 1
    return {"geschrieben": geschrieben, "geloescht": geloescht, "vorher": vorher.nr if vorher else None}


def schnappschuss_json(sn: Snapshot) -> dict:
    return {"id": sn.id, "nr": sn.nr, "message": sn.message, "label": sn.label, "quelle": sn.quelle,
            "git_commit": sn.git_commit, "dateien": sn.dateien, "bytes": sn.bytes,
            "uebersprungen": sn.uebersprungen, "created_by": person(sn.autor),
            "created_at": _mit_zone(sn.created_at)}


def _projekt(s, nutzer: User, pid: int, mindestens: str = "member") -> tuple[Project, str]:
    p, rolle = zugriff(s, nutzer, "project", pid, mindestens)
    if not module.wirksam(s, nutzer).get("werkstatt"):
        raise HTTPException(status_code=403, detail="Ohne die Werkstatt gibt es keinen Arbeitsordner "
                            "und damit keine Versionen")
    return p, rolle


def _snapshot(s, pid: int, nr: int) -> Snapshot:
    sn = s.scalar(select(Snapshot).where(Snapshot.project_id == pid, Snapshot.nr == nr))
    if not sn:
        raise HTTPException(status_code=404, detail="Schnappschuss nicht gefunden")
    return sn


# -- Endpunkte: Schnappschüsse ---------------------------------------------
class SchnappschussEingabe(BaseModel):
    message: str = Field(default="", max_length=300)


class LabelEingabe(BaseModel):
    label: str | None = Field(default=None, max_length=60)


class WiederherstellenEingabe(BaseModel):
    pfad: str | None = Field(default=None, max_length=1000)


class GitEingabe(BaseModel):
    quelle: str = Field(default="github", pattern="^(github|ordner)$")


@router.get("/api/projects/{pid}/versionen")
def versionen_liste(pid: int, nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    _projekt(s, nutzer, pid)
    return [schnappschuss_json(sn) for sn in s.scalars(
        select(Snapshot).where(Snapshot.project_id == pid).order_by(Snapshot.nr.desc()))]


@router.post("/api/projects/{pid}/versionen", status_code=201)
async def versionen_anlegen(pid: int, daten: SchnappschussEingabe,
                            nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    p = _projekt(s, nutzer, pid)[0]
    baum, ueb = await asyncio.to_thread(_baum, arbeitsordner(p), True)
    sn = schnappschuss(s, p, nutzer.id, daten.message, "manuell", baum, ueb)
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "schnappschuss", "nr": sn.nr})
    return schnappschuss_json(sn)


@router.get("/api/projects/{pid}/versionen/status")
async def versionen_status(pid: int, nr: int | None = None,
                           nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    """Der Arbeitsordner gegen einen Schnappschuss (Vorgabe: der letzte)."""
    p = _projekt(s, nutzer, pid)[0]
    baum, _ = await asyncio.to_thread(_baum, arbeitsordner(p), False)
    sn = _snapshot(s, pid, nr) if nr else s.scalar(
        select(Snapshot).where(Snapshot.project_id == pid).order_by(Snapshot.nr.desc()))
    alt = _zeilen(s, sn) if sn else {}
    return {"nr": sn.nr if sn else 0, "gleich": bool(sn) and sn.baum_sha == _baum_sha(baum),
            "dateien": len(baum), **_vergleich(alt, baum)}


@router.get("/api/projects/{pid}/versionen/git")
def git_status(pid: int, nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    p = _projekt(s, nutzer, pid)[0]
    commits = s.scalar(select(func.count()).select_from(Snapshot)
                       .where(Snapshot.project_id == pid, Snapshot.git_commit.isnot(None))) or 0
    offen = s.scalar(select(func.count()).select_from(Snapshot)
                     .where(Snapshot.project_id == pid, Snapshot.git_commit.is_(None))) or 0
    laeuft = s.scalar(select(func.count()).select_from(Job).where(
        Job.kind == "git", Job.project_id == pid, Job.status == "running")) or 0
    return {"git": bool(GIT), "repo": (_repo(pid) / "HEAD").exists(), "offen": offen,
            "commits": commits, "remote": p.github_repo, "konnektor": bool(_token(s, nutzer)),
            "eigenes_git": (arbeitsordner(p) / ".git").is_dir(), "laeuft": bool(laeuft)}


@router.post("/api/projects/{pid}/versionen/git/{aktion}", status_code=201)
async def git_starten(pid: int, aktion: str, daten: GitEingabe | None = None,
                      nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    """Export, Push und Import laufen als Auftrag mit Protokoll - neben der
    Warteschlange, wie Befehle; ein git-Auftrag je Projekt zugleich."""
    if aktion not in ("export", "push", "import"):
        raise HTTPException(status_code=404, detail="export, push oder import")
    quelle = daten.quelle if daten else "github"
    p = _projekt(s, nutzer, pid, "manager" if aktion in ("push", "import") else "member")[0]
    if not GIT:
        raise HTTPException(status_code=503, detail="git fehlt auf diesem Rechner")
    if aktion == "push" or (aktion == "import" and quelle == "github"):
        if not p.github_repo:
            raise HTTPException(status_code=422, detail="Dem Projekt fehlt das GitHub-Repository")
        if not _token(s, nutzer):
            raise HTTPException(status_code=422, detail="Kein aktiver GitHub-Konnektor")
    if aktion == "import" and quelle == "ordner" and not (arbeitsordner(p) / ".git").is_dir():
        raise HTTPException(status_code=422, detail="Der Arbeitsordner hat kein eigenes .git")
    if s.scalar(select(func.count()).select_from(Job).where(
            Job.kind == "git", Job.project_id == pid, Job.status == "running")):
        raise HTTPException(status_code=409, detail="Ein git-Auftrag läuft schon")
    job = agenten.einreihen(s, "git", nutzer.id, f"Git: {aktion}", {"aktion": aktion, "quelle": quelle},
                            pid, status="running")
    agenten.sofort(job.id)
    s.refresh(job)
    return job_json(job)


@router.get("/api/projects/{pid}/versionen/{nr}")
def versionen_lesen(pid: int, nr: int, nutzer: User = Depends(modul_noetig("versionen")),
                    s=Depends(sitzung)):
    _projekt(s, nutzer, pid)
    sn = _snapshot(s, pid, nr)
    return {**schnappschuss_json(sn), "dateien": [
        {"pfad": z.pfad, "sha": z.sha, "groesse": z.groesse, "ausfuehrbar": z.ausfuehrbar}
        for z in sorted(sn.dateiliste, key=lambda z: z.pfad)]}


@router.get("/api/projects/{pid}/versionen/{nr}/datei")
def versionen_datei(pid: int, nr: int, pfad: str, nutzer: User = Depends(modul_noetig("versionen")),
                    s=Depends(sitzung)):
    _projekt(s, nutzer, pid)
    sn = _snapshot(s, pid, nr)
    zeile = next((z for z in sn.dateiliste if z.pfad == pfad), None)
    if not zeile:
        raise HTTPException(status_code=404, detail="Datei nicht im Schnappschuss")
    try:
        roh = _blob(zeile.sha)
    except FileNotFoundError:
        raise HTTPException(status_code=410, detail="Der Inhalt fehlt in der Ablage")
    text = _text(roh)
    if text is not None:
        return {"pfad": pfad, "inhalt": text, "sha": zeile.sha, "groesse": zeile.groesse}
    typ = mimetypes.guess_type(pfad)[0] or "application/octet-stream"
    return FileResponse(ablage.lesen(zeile.sha), media_type=ablage.ausliefertyp(typ),
                        filename=Path(pfad).name)


@router.get("/api/projects/{pid}/versionen/{nr}/unterschied")
def versionen_unterschied(pid: int, nr: int, gegen: int | None = None,
                          nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    """Was #nr gegenüber #gegen ändert (Vorgabe: der Vorgänger, 0 = nichts)."""
    _projekt(s, nutzer, pid)
    sn = _snapshot(s, pid, nr)
    gegen = nr - 1 if gegen is None else gegen
    alt = _zeilen(s, _snapshot(s, pid, gegen)) if gegen > 0 else {}
    return {"nr": nr, "gegen": gegen, **_vergleich(alt, _zeilen(s, sn))}


@router.post("/api/projects/{pid}/versionen/{nr}/wiederherstellen")
async def versionen_wiederherstellen(pid: int, nr: int, daten: WiederherstellenEingabe | None = None,
                                     nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    p = _projekt(s, nutzer, pid, "manager")[0]
    sn = _snapshot(s, pid, nr)
    pfad = daten.pfad if daten and daten.pfad else None
    ergebnis = wiederherstellen(s, p, nutzer, sn, pfad)
    await senden(f"projekt:{pid}", {"projekt": pid, "art": "arbeitsordner"})
    return ergebnis


@router.patch("/api/projects/{pid}/versionen/{nr}")
def versionen_markieren(pid: int, nr: int, daten: LabelEingabe,
                        nutzer: User = Depends(modul_noetig("versionen")), s=Depends(sitzung)):
    _projekt(s, nutzer, pid)
    sn = _snapshot(s, pid, nr)
    sn.label = (daten.label or "").strip()[:60] or None
    s.commit()
    return schnappschuss_json(sn)


# -- git-Brücke ------------------------------------------------------------
def _repo(pid: int) -> Path:
    return ARBEIT / f"{pid}.git"


def _fernadresse(p: Project) -> str:
    return f"https://github.com/{p.github_repo}.git"


def _token(s, nutzer: User) -> str | None:
    c = konnektoren._aktiv(s, nutzer, "github")
    return (c.config or {}).get("token") if c else None


def _anmeldung(token: str) -> tuple[list[str], dict]:
    helfer = '!f() { echo username=x-access-token; echo "password=$MINERVA_GIT_TOKEN"; }; f'
    return ["-c", f"credential.helper={helfer}"], {"MINERVA_GIT_TOKEN": token}


async def _git(job: Job, args: list[str], cwd: Path | None = None, env: dict | None = None,
               stdin: bytes | None = None, roh: bool = False, zeit: int = GIT_ZEIT):
    """Ein git-Aufruf mit festen Argumenten; Ausgabe ins Protokoll des Auftrags,
    Fehler → 502 mit Rückgabewert. Fragen stellt git nie (GIT_TERMINAL_PROMPT=0)."""
    um = {**umgebung(), "GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_NOSYSTEM": "1",
          "GIT_CONFIG_GLOBAL": os.devnull, "LC_ALL": "C.UTF-8", **(env or {})}
    proc = await asyncio.create_subprocess_exec(
        GIT, *args, cwd=str(cwd) if cwd else None, env=um,
        stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
    try:
        out, err = await asyncio.wait_for(proc.communicate(stdin), zeit)
    except (TimeoutError, asyncio.TimeoutError, asyncio.CancelledError):
        proc.kill()
        await proc.wait()
        raise
    fehler = err.decode("utf-8", "replace").strip()
    if fehler:
        await agenten._log(job.id, fehler)
    if proc.returncode:
        raise HTTPException(status_code=502, detail=f"git {args[0] if args[0] != '--git-dir' else args[2]} "
                            f"scheiterte ({proc.returncode})")
    if roh:
        return out
    text = out.decode("utf-8", "replace")
    return text


async def _repo_sicherstellen(job: Job, pid: int) -> Path:
    repo = _repo(pid)
    if not (repo / "HEAD").exists():
        await _git(job, ["init", "--bare", "-q", "-b", "main", str(repo)])
        await agenten._log(job.id, f"Repository angelegt: {repo.name}")
    return repo


async def _export(s, job: Job, p: Project) -> int:
    """Jeder Schnappschuss ohne git_commit wird ein Commit - in Reihenfolge, mit
    Autor und Datum des Schnappschusses. ponytail: jeder Baum wird voll
    ausgeschrieben; Plumbing (hash-object, update-index), wenn das je zu langsam wird."""
    repo = await _repo_sicherstellen(job, p.id)
    offen = s.scalars(select(Snapshot).where(Snapshot.project_id == p.id, Snapshot.git_commit.is_(None))
                      .order_by(Snapshot.nr)).all()
    for sn in offen:
        tmp = Path(tempfile.mkdtemp(prefix="minerva-git-", dir=ARBEIT))
        try:
            for rel, (sha, _, x) in _zeilen(s, sn).items():
                _schreiben(tmp / rel, sha, x)
            basis = ["--git-dir", str(repo), "--work-tree", str(tmp)]
            await _git(job, basis + ["add", "-A", "-f", "."], cwd=tmp)
            datum = sn.created_at.isoformat()
            name = sn.autor.anzeigename if sn.autor else "Minerva"
            email = sn.autor.email if sn.autor else "minerva@localhost"
            await _git(job, basis + ["-c", f"user.name={name}", "-c", f"user.email={email}", "commit",
                                     "-q", "--allow-empty", "-F", "-"], cwd=tmp,
                       env={"GIT_AUTHOR_DATE": datum, "GIT_COMMITTER_DATE": datum},
                       stdin=(sn.message or f"Schnappschuss #{sn.nr}").encode())
            sn.git_commit = (await _git(job, ["--git-dir", str(repo), "rev-parse", "HEAD"])).strip()
            s.commit()
            await agenten._log(job.id, f"#{sn.nr} → {sn.git_commit[:7]} {sn.message}")
        finally:
            shutil.rmtree(tmp, ignore_errors=True)
    return len(offen)


async def _push(job: Job, p: Project, token: str) -> None:
    args, env = _anmeldung(token)
    await _git(job, ["--git-dir", str(_repo(p.id))] + PROTOKOLL + args + ["push", "-q", _fernadresse(p), "main:main"],
               env=env)
    await agenten._log(job.id, f"main nach {p.github_repo} übertragen")


async def _import(s, job: Job, p: Project, quelle: str, token: str | None, nutzer_id: int) -> int:
    """Neue Commits der Quelle werden Schnappschüsse (quelle git); der
    Arbeitsordner bleibt unberührt - wiederhergestellt wird ausdrücklich."""
    repo = await _repo_sicherstellen(job, p.id)
    if quelle == "ordner":
        adresse, anm = str(arbeitsordner(p)), ([], {})
    else:
        adresse, anm = _fernadresse(p), _anmeldung(token or "")
    basis = ["--git-dir", str(repo)]
    await _git(job, basis + PROTOKOLL + anm[0] + ["fetch", "-q", adresse, "HEAD"], env=anm[1])
    shas = (await _git(job, basis + ["rev-list", "--reverse", "--first-parent", "-n", str(IMPORT_MAX),
                                     "FETCH_HEAD"])).split()
    bekannt = set(s.scalars(select(Snapshot.git_commit).where(
        Snapshot.project_id == p.id, Snapshot.git_commit.isnot(None))))
    neu = 0
    for sha in shas:
        if sha in bekannt:
            continue
        kopf = await _git(job, basis + ["show", "-s", "--format=%aI%n%B", sha])
        datum, _, nachricht = kopf.partition("\n")
        tar = await _git(job, basis + ["archive", "--format=tar", sha], roh=True)
        tmp = Path(tempfile.mkdtemp(prefix="minerva-import-", dir=ARBEIT))
        try:
            with tarfile.open(fileobj=io.BytesIO(tar)) as t:
                t.extractall(tmp, filter="data")      # keine Verweise, kein Ausbruch
            baum, ueb = await asyncio.to_thread(_baum, tmp, True)
        finally:
            shutil.rmtree(tmp, ignore_errors=True)
        try:
            wann = datetime.fromisoformat(datum.strip())
        except ValueError:
            wann = None
        titel = (nachricht.strip().splitlines() or [f"Commit {sha[:7]}"])[0]
        schnappschuss(s, p, nutzer_id, titel, "git", baum, ueb, git_commit=sha, created_at=wann)
        neu += 1
        await agenten._log(job.id, f"{sha[:7]} → Schnappschuss: {titel[:60]}")
    # main nachziehen, wenn die Quelle eine Fortsetzung ist
    try:
        await _git(job, basis + ["rev-parse", "-q", "--verify", "refs/heads/main"])
        await _git(job, basis + ["merge-base", "--is-ancestor", "main", "FETCH_HEAD"])
        await _git(job, basis + ["update-ref", "refs/heads/main", "FETCH_HEAD"])
    except HTTPException:
        if not (repo / "refs" / "heads" / "main").exists():
            await _git(job, basis + ["update-ref", "refs/heads/main", "FETCH_HEAD"])
        else:
            await agenten._log(job.id, "Verläufe weichen ab - der Export bleibt lokal, kein Fast-Forward")
    return neu


async def _git_job(s, nutzer: User, job: Job):
    aktion = str(job.params.get("aktion", ""))
    quelle = str(job.params.get("quelle", "github"))
    p = zugriff(s, nutzer, "project", job.project_id, "manager" if aktion in ("push", "import") else "member")[0]
    if not GIT:
        raise HTTPException(status_code=503, detail="git fehlt auf diesem Rechner")
    token = _token(s, nutzer) if aktion == "push" or (aktion == "import" and quelle == "github") else None
    if aktion in ("push",) or (aktion == "import" and quelle == "github"):
        if not p.github_repo or not token:
            raise HTTPException(status_code=422, detail="GitHub-Repository oder Konnektor fehlt")
    s.commit()
    if aktion == "export":
        return "done", {"neu": await _export(s, job, p)}
    if aktion == "push":
        neu = await _export(s, job, p)
        await _push(job, p, token)
        return "done", {"neu": neu, "gepusht": True}
    if aktion == "import":
        neu = await _import(s, job, p, quelle, token, nutzer.id)
        await senden(f"projekt:{p.id}", {"projekt": p.id, "art": "schnappschuss"})
        return "done", {"neu": neu}
    raise HTTPException(status_code=422, detail="Unbekannte git-Aktion")


AUSFUEHRER = {"git": _git_job}
EREIGNISSE = {"projekt_geloescht": lambda pid: shutil.rmtree(_repo(pid), ignore_errors=True)}
