Der Vault-Root pusht ab jetzt nach jeder Änderung an CLAUDE.md, AI/Regelwerk/, .claude/agents/, .claude/skills/, .claude/settings.json oder .vault-tools/ selbsttätig nach Gitea. - .vault-tools/auto-push/auto_push.py: PostToolUse-Hook, committet und pusht. Fehlendes Token, fehlendes Remote und gescheiterter Push sind Befunde und gehen in den Kontext, nicht ins Schweigen. - .vault-tools/auto-push/askpass.py: reicht GITEA_TOKEN an git weiter, damit es weder in .git/config noch in einer Kommandozeile steht. - .vault-tools/auto-push/faelle_auto_push.py: 28 Fälle gegen ein echtes Wegwerf-Repo mit echtem Remote, alle bestanden. Der Lauf hat zwei Fehler gefunden, die jetzt behoben sind: die Pfadauflösung scheiterte an Windows-Kurznamen, und die Laufzeitsperre landete im Repo. - AI/Regelwerk/veroeffentlichung.md und ein Abschnitt im Stamm halten die Regel fest. Co-Authored-By: Claude Opus 5 <[email protected]>
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""Fährt die Angriffsliste gegen auto_push.py, je Fall auf einem Wegwerf-Vault.
|
|
|
|
Ersteller: Pascal Michel
|
|
Erstellt: 2026-09-17
|
|
|
|
Jeder Fall bekommt einen eigenen Wegwerf-Vault: ein echtes Git-Repo mit einem
|
|
zweiten Repo als bare-Remote daneben. Damit ist der Push echt — es wird
|
|
wirklich übertragen, nur eben in ein Verzeichnis statt über das Netz. Ein
|
|
Auto-Push, der nur gegen eine Attrappe geprüft wurde, belegt nichts über den
|
|
Fall, der zählt: dass die Änderung nachher drüben liegt.
|
|
|
|
Der Hook wird für jeden Fall in den Wegwerf-Vault kopiert, weil er seine
|
|
Vault-Wurzel ausschließlich aus dem eigenen Skriptpfad bestimmt.
|
|
|
|
Grundlinie zuerst: eine geänderte Datei im Geltungsbereich muss nach dem Lauf
|
|
im Remote liegen. Läuft die Grundlinie nicht, misst der Rest nichts.
|
|
|
|
Aufruf:
|
|
python .vault-tools/auto-push/faelle_auto_push.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
HIER = Path(__file__).resolve().parent
|
|
HOOK = HIER / "auto_push.py"
|
|
ASKPASS_PY = HIER / "askpass.py"
|
|
|
|
# Die .gitignore des echten Vaults, gekürzt auf das, was die Fälle brauchen.
|
|
GITIGNORE = """/*
|
|
!/.gitignore
|
|
!/CLAUDE.md
|
|
!/AI/
|
|
/AI/*
|
|
!/AI/Regelwerk/
|
|
!/.vault-tools/
|
|
.vault-tools/auto-push/.sperre
|
|
__pycache__/
|
|
*.pyc
|
|
"""
|
|
|
|
|
|
def raeume(pfad: Path) -> None:
|
|
"""rmtree, das auch schreibgeschuetzte git-Objektdateien wegbekommt."""
|
|
|
|
def erzwinge(funktion, ziel, _fehler):
|
|
try:
|
|
os.chmod(ziel, stat.S_IWRITE)
|
|
funktion(ziel)
|
|
except OSError:
|
|
pass
|
|
|
|
shutil.rmtree(pfad, onexc=erzwinge)
|
|
|
|
|
|
def git(wo: Path, *argumente: str) -> subprocess.CompletedProcess[bytes]:
|
|
return subprocess.run(
|
|
["git", "-C", str(wo), *argumente],
|
|
capture_output=True,
|
|
stdin=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def baue_vault(basis: Path) -> tuple[Path, Path]:
|
|
"""Legt Arbeits-Vault und bare-Remote an und macht den ersten Commit."""
|
|
vault = basis / "vault"
|
|
remote = basis / "remote.git"
|
|
(vault / "AI" / "Regelwerk").mkdir(parents=True)
|
|
(vault / ".vault-tools" / "auto-push").mkdir(parents=True)
|
|
(vault / "Inbox").mkdir()
|
|
|
|
(vault / ".gitignore").write_text(GITIGNORE, encoding="utf-8")
|
|
(vault / "CLAUDE.md").write_text("# Stamm\n", encoding="utf-8")
|
|
(vault / "AI" / "Regelwerk" / "probe.md").write_text("# Probe\n", encoding="utf-8")
|
|
# Eine Datei, die niemals herausgehen darf.
|
|
(vault / "Inbox" / "geheim.md").write_text("Kundendaten\n", encoding="utf-8")
|
|
|
|
shutil.copy2(HOOK, vault / ".vault-tools" / "auto-push" / "auto_push.py")
|
|
shutil.copy2(ASKPASS_PY, vault / ".vault-tools" / "auto-push" / "askpass.py")
|
|
shim = vault / ".vault-tools" / "auto-push" / "askpass.sh"
|
|
shim.write_bytes(b"#!/bin/sh\necho unbenutzt\n")
|
|
|
|
subprocess.run(["git", "init", "-q", "--bare", str(remote)], check=True)
|
|
git(vault, "init", "-q")
|
|
git(vault, "symbolic-ref", "HEAD", "refs/heads/main")
|
|
git(vault, "config", "user.name", "Fall")
|
|
git(vault, "config", "user.email", "[email protected]")
|
|
git(vault, "config", "commit.gpgsign", "false")
|
|
git(vault, "remote", "add", "origin", remote.as_uri())
|
|
git(vault, "add", "-A")
|
|
git(vault, "commit", "-q", "-m", "Grundstand")
|
|
git(vault, "push", "-q", "-u", "origin", "main")
|
|
return vault, remote
|
|
|
|
|
|
def lauf_hook(vault: Path, eingabe: dict, umgebung_extra: dict | None = None) -> tuple[int, str, str]:
|
|
umgebung = dict(os.environ, PYTHONIOENCODING="utf-8", GITEA_TOKEN="egal")
|
|
umgebung.update(umgebung_extra or {})
|
|
lauf = subprocess.run(
|
|
[sys.executable, str(vault / ".vault-tools" / "auto-push" / "auto_push.py")],
|
|
input=json.dumps(eingabe).encode("utf-8"),
|
|
capture_output=True,
|
|
env=umgebung,
|
|
timeout=90,
|
|
check=False,
|
|
)
|
|
return (
|
|
lauf.returncode,
|
|
lauf.stdout.decode("utf-8", errors="replace"),
|
|
lauf.stderr.decode("utf-8", errors="replace"),
|
|
)
|
|
|
|
|
|
def remote_dateien(remote: Path) -> set[str]:
|
|
lauf = git(remote, "ls-tree", "-r", "--name-only", "main")
|
|
if lauf.returncode != 0:
|
|
return set()
|
|
return {z for z in lauf.stdout.decode("utf-8", errors="replace").splitlines() if z}
|
|
|
|
|
|
def remote_commits(remote: Path) -> int:
|
|
lauf = git(remote, "rev-list", "--count", "main")
|
|
if lauf.returncode != 0:
|
|
return -1
|
|
return int(lauf.stdout.decode().strip() or "0")
|
|
|
|
|
|
@dataclass
|
|
class Fall:
|
|
name: str
|
|
beschreibung: str
|
|
|
|
|
|
def main() -> int:
|
|
basis = Path(tempfile.mkdtemp(prefix="auto-push-faelle-"))
|
|
gesamt = 0
|
|
bestanden = 0
|
|
|
|
def pruefe(name: str, ok: bool, text: str) -> None:
|
|
nonlocal gesamt, bestanden
|
|
gesamt += 1
|
|
bestanden += int(ok)
|
|
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {name:34} {text}")
|
|
|
|
try:
|
|
# --- Grundlinie -------------------------------------------------
|
|
vault, remote = baue_vault(basis / "grundlinie")
|
|
vorher = remote_commits(remote)
|
|
(vault / "AI" / "Regelwerk" / "probe.md").write_text("# Probe geaendert\n", encoding="utf-8")
|
|
code, aus, fehler = lauf_hook(vault, {"tool_input": {"file_path": str(vault / "AI" / "Regelwerk" / "probe.md")}})
|
|
nachher = remote_commits(remote)
|
|
pruefe(
|
|
"grundlinie",
|
|
code == 0 and nachher == vorher + 1 and not aus.strip(),
|
|
f"Exit {code}, Commits {vorher}->{nachher}, Ausgabe {len(aus)} Z. {fehler[:80]}",
|
|
)
|
|
|
|
# --- Ausgeschlossene Datei geht nie heraus ----------------------
|
|
drueben = remote_dateien(remote)
|
|
pruefe(
|
|
"positivliste haelt",
|
|
not any(d.startswith("Inbox/") for d in drueben),
|
|
f"Remote fuehrt {sorted(drueben)}",
|
|
)
|
|
pruefe(
|
|
"sperrdatei bleibt drinnen",
|
|
not any(d.endswith(".sperre") for d in drueben),
|
|
f"Remote fuehrt {sorted(drueben)}",
|
|
)
|
|
|
|
# --- Lauf ohne Aenderung bleibt still ---------------------------
|
|
vorher = remote_commits(remote)
|
|
code, aus, _ = lauf_hook(vault, {"tool_input": {"file_path": str(vault / "CLAUDE.md")}})
|
|
pruefe(
|
|
"nichts geaendert -> still",
|
|
code == 0 and not aus.strip() and remote_commits(remote) == vorher,
|
|
f"Exit {code}, Ausgabe {len(aus)} Z., Commits {remote_commits(remote)}",
|
|
)
|
|
|
|
# --- Eingaben, die nichts ausloesen duerfen ---------------------
|
|
stille_faelle = [
|
|
("eingabe leer", b""),
|
|
("eingabe kein json", b"{kaputt"),
|
|
("eingabe kein objekt", b"[1,2,3]"),
|
|
("eingabe null", b"null"),
|
|
("kein tool_input", b'{"tool_name":"Edit"}'),
|
|
("tool_input keine map", b'{"tool_input":"x"}'),
|
|
("kein file_path", b'{"tool_input":{"content":"x"}}'),
|
|
("file_path keine zeichenkette", b'{"tool_input":{"file_path":42}}'),
|
|
("file_path leer", b'{"tool_input":{"file_path":" "}}'),
|
|
]
|
|
for name, roh in stille_faelle:
|
|
(vault / "CLAUDE.md").write_text(f"# Stamm {name}\n", encoding="utf-8")
|
|
vorher = remote_commits(remote)
|
|
lauf = subprocess.run(
|
|
[sys.executable, str(vault / ".vault-tools" / "auto-push" / "auto_push.py")],
|
|
input=roh,
|
|
capture_output=True,
|
|
env=dict(os.environ, PYTHONIOENCODING="utf-8", GITEA_TOKEN="egal"),
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
aus = lauf.stdout.decode("utf-8", errors="replace")
|
|
pruefe(
|
|
name,
|
|
lauf.returncode == 0 and not aus.strip() and remote_commits(remote) == vorher,
|
|
f"Exit {lauf.returncode}, Ausgabe {len(aus)} Z.",
|
|
)
|
|
git(vault, "checkout", "--", "CLAUDE.md")
|
|
|
|
# --- Pfade ausserhalb des Geltungsbereichs ----------------------
|
|
aussen = [
|
|
("pfad ausserhalb vault", str(basis / "woanders.md")),
|
|
("nachbarordner mit praefix", str(vault) + "-anders/CLAUDE.md"),
|
|
("inbox-notiz loest nicht aus", str(vault / "Inbox" / "geheim.md")),
|
|
]
|
|
for name, pfad in aussen:
|
|
(vault / "CLAUDE.md").write_text(f"# Stamm {name}\n", encoding="utf-8")
|
|
vorher = remote_commits(remote)
|
|
code, aus, _ = lauf_hook(vault, {"tool_input": {"file_path": pfad}})
|
|
pruefe(
|
|
name,
|
|
code == 0 and not aus.strip() and remote_commits(remote) == vorher,
|
|
f"Exit {code}, Commits unveraendert: {remote_commits(remote) == vorher}",
|
|
)
|
|
git(vault, "checkout", "--", "CLAUDE.md")
|
|
|
|
# --- Pfadformen, die ausloesen muessen --------------------------
|
|
formen = [
|
|
("relativer pfad", "AI/Regelwerk/probe.md"),
|
|
("backslash-pfad", str(vault / "AI" / "Regelwerk" / "probe.md").replace("/", "\\")),
|
|
("pfad mit punktsegment", str(vault / "AI" / ".." / "AI" / "Regelwerk" / "probe.md")),
|
|
("grossschreibung", str(vault / "AI" / "Regelwerk" / "probe.md").upper()),
|
|
("vault-tools zaehlt mit", str(vault / ".vault-tools" / "auto-push" / "notiz.md")),
|
|
]
|
|
for nummer, (name, pfad) in enumerate(formen):
|
|
ziel = vault / "AI" / "Regelwerk" / "probe.md"
|
|
ziel.write_text(f"# Probe Form {nummer}\n", encoding="utf-8")
|
|
vorher = remote_commits(remote)
|
|
code, aus, fehler = lauf_hook(vault, {"tool_input": {"file_path": pfad}})
|
|
pruefe(
|
|
name,
|
|
code == 0 and remote_commits(remote) == vorher + 1,
|
|
f"Exit {code}, Commits {vorher}->{remote_commits(remote)} {aus[:60]}",
|
|
)
|
|
|
|
# --- Reihenfolge: erste und letzte Datei einer Sammeländerung ---
|
|
for position in ("erste", "letzte"):
|
|
vault2, remote2 = baue_vault(basis / f"reihenfolge-{position}")
|
|
for i in range(5):
|
|
(vault2 / "AI" / "Regelwerk" / f"a{i}.md").write_text(f"# {i}\n", encoding="utf-8")
|
|
ausloeser = "a0.md" if position == "erste" else "a4.md"
|
|
code, aus, _ = lauf_hook(vault2, {"tool_input": {"file_path": str(vault2 / "AI" / "Regelwerk" / ausloeser)}})
|
|
dateien = remote_dateien(remote2)
|
|
fehlt = [f"AI/Regelwerk/a{i}.md" for i in range(5) if f"AI/Regelwerk/a{i}.md" not in dateien]
|
|
pruefe(
|
|
f"reihenfolge {position}",
|
|
code == 0 and not fehlt,
|
|
f"Exit {code}, fehlend im Remote: {fehlt or 'nichts'}",
|
|
)
|
|
|
|
# --- Fehlende Voraussetzungen sind Befunde ----------------------
|
|
vault3, remote3 = baue_vault(basis / "ohne-token")
|
|
(vault3 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
|
|
code, aus, _ = lauf_hook(vault3, {"tool_input": {"file_path": str(vault3 / "CLAUDE.md")}}, {"GITEA_TOKEN": ""})
|
|
pruefe(
|
|
"kein token -> befund",
|
|
code == 0 and "GITEA_TOKEN" in aus and remote_commits(remote3) == 1,
|
|
f"Exit {code}, meldet: {'ja' if 'GITEA_TOKEN' in aus else 'NEIN'}",
|
|
)
|
|
|
|
vault4, remote4 = baue_vault(basis / "ohne-askpass")
|
|
(vault4 / ".vault-tools" / "auto-push" / "askpass.sh").unlink()
|
|
(vault4 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
|
|
code, aus, _ = lauf_hook(vault4, {"tool_input": {"file_path": str(vault4 / "CLAUDE.md")}})
|
|
pruefe(
|
|
"kein askpass -> befund",
|
|
code == 0 and "askpass" in aus.lower(),
|
|
f"Exit {code}, meldet: {'ja' if 'askpass' in aus.lower() else 'NEIN'}",
|
|
)
|
|
|
|
vault5, _ = baue_vault(basis / "ohne-git")
|
|
raeume(vault5 / ".git")
|
|
(vault5 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
|
|
code, aus, _ = lauf_hook(vault5, {"tool_input": {"file_path": str(vault5 / "CLAUDE.md")}})
|
|
pruefe(
|
|
"kein repo -> befund",
|
|
code == 0 and "Git-Repo" in aus,
|
|
f"Exit {code}, meldet: {'ja' if 'Git-Repo' in aus else 'NEIN'}",
|
|
)
|
|
|
|
vault6, remote6 = baue_vault(basis / "remote-weg")
|
|
raeume(remote6)
|
|
(vault6 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
|
|
code, aus, _ = lauf_hook(vault6, {"tool_input": {"file_path": str(vault6 / "CLAUDE.md")}})
|
|
lokal = git(vault6, "rev-list", "--count", "HEAD").stdout.decode().strip()
|
|
pruefe(
|
|
"push scheitert -> befund, commit bleibt",
|
|
code == 0 and "push" in aus.lower() and lokal == "2",
|
|
f"Exit {code}, lokale Commits {lokal}, meldet: {'ja' if 'push' in aus.lower() else 'NEIN'}",
|
|
)
|
|
|
|
# --- Belegte Sperre bleibt still --------------------------------
|
|
vault7, remote7 = baue_vault(basis / "sperre")
|
|
sperre = vault7 / ".vault-tools" / "auto-push" / ".sperre"
|
|
sperre.write_bytes(b"")
|
|
(vault7 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
|
|
vorher = remote_commits(remote7)
|
|
code, aus, _ = lauf_hook(vault7, {"tool_input": {"file_path": str(vault7 / "CLAUDE.md")}})
|
|
pruefe(
|
|
"belegte sperre -> still",
|
|
code == 0 and not aus.strip() and remote_commits(remote7) == vorher,
|
|
f"Exit {code}, Ausgabe {len(aus)} Z.",
|
|
)
|
|
|
|
print()
|
|
print(f"{bestanden} von {gesamt} Faellen bestanden.")
|
|
return 0 if bestanden == gesamt else 1
|
|
finally:
|
|
shutil.rmtree(basis, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
except AttributeError:
|
|
pass
|
|
raise SystemExit(main())
|