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]>
350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""PostToolUse-Hook: committet und pusht jede Änderung an der veröffentlichten Anweisungsschicht.
|
|
|
|
Ersteller: Pascal Michel
|
|
Erstellt: 2026-09-17
|
|
|
|
GELTUNGSBEREICH
|
|
Ausgelöst wird nur, wenn tool_input.file_path innerhalb der Vault-Wurzel auf
|
|
CLAUDE.md, README.md, AI/Regelwerk/**, .claude/agents/**, .claude/skills/**,
|
|
.claude/settings.json oder .vault-tools/** zeigt. Die Wurzel ist
|
|
ausschließlich der Ordner zwei Ebenen über dieser Datei; weder
|
|
CLAUDE_PROJECT_DIR noch das Feld cwd der Hook-Eingabe werden gelesen, weil
|
|
cwd einem cd im Bash-Werkzeug folgt (dieselbe Begründung wie in
|
|
regelwerk_nach_edit.py).
|
|
|
|
Was tatsächlich gepusht wird, entscheidet aber nicht diese Liste, sondern die
|
|
.gitignore der Vault-Wurzel. Sie arbeitet als Positivliste: ausgeschlossen
|
|
ist alles, eingeschlossen nur, was dort ausdrücklich wieder aufgenommen ist.
|
|
Die Liste hier bestimmt nur, ob der Hook überhaupt anläuft — sie ist eine
|
|
Abkürzung, kein zweiter Filter. Wer den Umfang ändern will, ändert die
|
|
.gitignore.
|
|
|
|
Still bleibt der Hook in drei Lagen: die Eingabe taugt nicht (leer, kein
|
|
JSON-Objekt, kein file_path als Zeichenkette); der Pfad liegt außerhalb des
|
|
Geltungsbereichs; oder es gibt nichts zu committen. Jede andere Lage —
|
|
fehlendes Token, fehlendes Remote, gescheiterter Commit, gescheiterter Push,
|
|
Zeitüberschreitung — ist ein Befund und wird gemeldet.
|
|
|
|
VERHALTEN BEI GESCHEITERTEM PUSH
|
|
Der Commit bleibt lokal stehen und der Befund geht in den Kontext. Der
|
|
nächste Lauf schiebt ihn mit, weil `git push` alles bis HEAD überträgt. Ein
|
|
gescheiterter Push verliert also keine Arbeit, er verzögert sie.
|
|
|
|
NEBENLÄUFIGKEIT
|
|
Zwei schnell aufeinanderfolgende Edits starten zwei Hook-Läufe. Ohne Sperre
|
|
kollidieren sie an der index.lock von git. Deshalb eine eigene Sperrdatei mit
|
|
O_EXCL; wer sie nicht bekommt, wartet bis SPERRE_WARTE_S und läuft danach
|
|
selbst. Findet er nichts mehr zu committen, bleibt er still — der andere Lauf
|
|
hat seine Änderung dann bereits mitgenommen.
|
|
|
|
BENANNTE LÜCKEN
|
|
* Ob tool_name Edit, Write oder MultiEdit ist, prüft der Matcher in
|
|
settings.json, nicht dieses Skript.
|
|
* Der Hook prüft den Inhalt nicht. Er hat keinen Geheimnis-Scan und keine
|
|
Freigabe. Was in einer eingeschlossenen Datei steht, geht heraus, sobald
|
|
sie geschrieben wird — auch halbfertig. Das ist die ausdrückliche Vorgabe
|
|
(Pascal, 2026-09-17), und es ist der Grund, warum die .gitignore eine
|
|
Positivliste ist und keine Ausschlussliste.
|
|
* Pfade werden ueber realpath aufgeloest. Ein Symlink von ausserhalb auf
|
|
eine eingeschlossene Datei loest den Hook deshalb aus, ein Symlink von
|
|
innen nach aussen nicht.
|
|
* Ein Push wird nicht wiederholt. Nachgeholt wird er beim nächsten Lauf.
|
|
|
|
Exit ist immer 0. Gemeldet wird über additionalContext plus systemMessage, wie
|
|
beim Nachbar-Hook regelwerk_nach_edit.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
SKRIPT = Path(__file__).resolve()
|
|
|
|
# Unter dem timeout von 60 s, den der settings.json-Eintrag setzen soll.
|
|
TIMEOUT_GIT_S = 15
|
|
TIMEOUT_PUSH_S = 40
|
|
SPERRE_WARTE_S = 25
|
|
SPERRE_TAKT_S = 0.4
|
|
MAX_ZEILEN = 30
|
|
MAX_ZEICHEN = 3000
|
|
MAX_DATEIEN_IN_MELDUNG = 12
|
|
|
|
# Kleinbuchstaben und Schrägstrich vorwärts, verglichen gegen normiere().
|
|
GELTUNG_DATEIEN = ("claude.md", "readme.md", ".gitignore", ".gitattributes")
|
|
GELTUNG_ORDNER = (
|
|
"ai/regelwerk/",
|
|
".claude/agents/",
|
|
".claude/skills/",
|
|
".vault-tools/",
|
|
)
|
|
GELTUNG_EINZELN = (".claude/settings.json",)
|
|
|
|
ASKPASS_REL = Path(".vault-tools") / "auto-push" / "askpass.sh"
|
|
SPERRE_REL = Path(".vault-tools") / "auto-push" / ".sperre"
|
|
|
|
|
|
def lies_stdin() -> bytes:
|
|
if sys.stdin is None or sys.stdin.isatty():
|
|
return b""
|
|
return sys.stdin.buffer.read()
|
|
|
|
|
|
def lies_eingabe(roh: bytes) -> object:
|
|
# errors=replace: ein kaputtes Byte im Pfad soll laut enden, nicht still.
|
|
text = roh.decode("utf-8-sig", errors="replace")
|
|
if not text.strip():
|
|
return None
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def bestimme_wurzel() -> Path:
|
|
return SKRIPT.parents[2]
|
|
|
|
|
|
def normiere(pfad: str) -> str:
|
|
# realpath, nicht abspath: Windows liefert denselben Ort mal als
|
|
# "C:/Users/Pascal Michel/..." und mal als 8.3-Kurznamen
|
|
# "C:/Users/PASCAL~1/...". Ein Vergleich mit abspath haelt die beiden fuer
|
|
# verschiedene Pfade, und der Hook bleibt dann still statt zu pushen --
|
|
# im Fallkatalog vom 2026-09-17 sind daran sieben Faelle gescheitert.
|
|
return os.path.normpath(os.path.realpath(pfad)).replace("\\", "/").lower().rstrip("/")
|
|
|
|
|
|
def im_geltungsbereich(wurzel: Path, datei: str) -> bool:
|
|
kandidat = datei.replace("\\", "/")
|
|
try:
|
|
if not os.path.isabs(kandidat):
|
|
kandidat = os.path.join(str(wurzel), kandidat)
|
|
pfad = normiere(kandidat)
|
|
basis = normiere(str(wurzel))
|
|
except (ValueError, OSError):
|
|
return False
|
|
# Mit Trenner vergleichen, sonst träfe "<wurzel>-CLAUDE.md" neben dem Vault.
|
|
if not pfad.startswith(basis + "/"):
|
|
return False
|
|
rel = pfad[len(basis) + 1:]
|
|
return (
|
|
rel in GELTUNG_DATEIEN
|
|
or rel in GELTUNG_EINZELN
|
|
or rel.startswith(GELTUNG_ORDNER)
|
|
)
|
|
|
|
|
|
def dekodiere(roh: bytes | str | None) -> str:
|
|
if roh is None:
|
|
return ""
|
|
if isinstance(roh, str):
|
|
return roh
|
|
return roh.decode("utf-8", errors="replace")
|
|
|
|
|
|
class GitFehler(Exception):
|
|
"""Ein git-Aufruf ist gescheitert; die Meldung ist der Befund."""
|
|
|
|
def __init__(self, kopf: str, rumpf: str) -> None:
|
|
super().__init__(kopf)
|
|
self.kopf = kopf
|
|
self.rumpf = rumpf
|
|
|
|
|
|
def git(wurzel: Path, *argumente: str, timeout: int = TIMEOUT_GIT_S) -> str:
|
|
umgebung = dict(
|
|
os.environ,
|
|
PYTHONIOENCODING="utf-8",
|
|
GIT_TERMINAL_PROMPT="0",
|
|
GIT_ASKPASS=str(wurzel / ASKPASS_REL),
|
|
GIT_CONFIG_NOSYSTEM="0",
|
|
)
|
|
befehl = ["git", "-C", str(wurzel), *argumente]
|
|
try:
|
|
lauf = subprocess.run(
|
|
befehl,
|
|
env=umgebung,
|
|
stdin=subprocess.DEVNULL,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired as abgelaufen:
|
|
teil = (dekodiere(abgelaufen.stdout) + dekodiere(abgelaufen.stderr)).strip()
|
|
raise GitFehler(
|
|
f"git {argumente[0]} nach {timeout} s abgebrochen",
|
|
f"`git {' '.join(argumente)}` lief länger als {timeout} s.\n"
|
|
f"Bis dahin ausgegeben:\n{teil or '(nichts)'}",
|
|
) from abgelaufen
|
|
except OSError as fehler:
|
|
raise GitFehler(
|
|
f"git nicht startbar: {type(fehler).__name__}",
|
|
f"`git {' '.join(argumente)}` ließ sich nicht starten: "
|
|
f"{type(fehler).__name__}: {fehler}",
|
|
) from fehler
|
|
|
|
if lauf.returncode != 0:
|
|
ausgabe = (dekodiere(lauf.stdout) + "\n" + dekodiere(lauf.stderr)).strip()
|
|
raise GitFehler(
|
|
f"git {argumente[0]}: Exit {lauf.returncode}",
|
|
f"`git {' '.join(argumente)}` endete mit Exit {lauf.returncode}.\n"
|
|
f"{ausgabe or '(keine Ausgabe)'}",
|
|
)
|
|
return dekodiere(lauf.stdout)
|
|
|
|
|
|
def hole_sperre(sperre: Path) -> int | None:
|
|
"""Gibt den Dateideskriptor zurück, oder None, wenn die Sperre belegt bleibt."""
|
|
frist = time.monotonic() + SPERRE_WARTE_S
|
|
while True:
|
|
try:
|
|
return os.open(str(sperre), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
except OSError as fehler:
|
|
if fehler.errno not in (errno.EEXIST, errno.EACCES):
|
|
raise
|
|
# Eine Sperre, die älter ist als die Wartezeit, stammt aus einem
|
|
# abgestürzten Lauf. Sie wird entfernt, nicht ewig respektiert.
|
|
try:
|
|
if time.time() - sperre.stat().st_mtime > SPERRE_WARTE_S * 2:
|
|
sperre.unlink(missing_ok=True)
|
|
continue
|
|
except OSError:
|
|
pass
|
|
if time.monotonic() >= frist:
|
|
return None
|
|
time.sleep(SPERRE_TAKT_S)
|
|
|
|
|
|
def gib_sperre_frei(deskriptor: int, sperre: Path) -> None:
|
|
try:
|
|
os.close(deskriptor)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
sperre.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def baue_meldung(dateien: list[str]) -> str:
|
|
rest = len(dateien) - MAX_DATEIEN_IN_MELDUNG
|
|
sichtbar = dateien[:MAX_DATEIEN_IN_MELDUNG]
|
|
kopf = (
|
|
f"Automatischer Push: {len(dateien)} Datei(en) geändert"
|
|
if len(dateien) != 1
|
|
else f"Automatischer Push: {dateien[0]}"
|
|
)
|
|
rumpf = "\n".join(f"- {d}" for d in sichtbar)
|
|
if rest > 0:
|
|
rumpf += f"\n- … und {rest} weitere"
|
|
return (
|
|
f"{kopf}\n\n{rumpf}\n\n"
|
|
"Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.\n"
|
|
"\n"
|
|
"Co-Authored-By: Claude Opus 5 <[email protected]>\n"
|
|
)
|
|
|
|
|
|
def arbeite(wurzel: Path) -> tuple[str, str] | None:
|
|
"""None, wenn nichts zu tun war; sonst (systemMessage, Rumpf)."""
|
|
if not (wurzel / ".git").exists():
|
|
raise GitFehler(
|
|
"Auto-Push: kein Git-Repo",
|
|
f"{wurzel} ist kein Git-Repository. Der Hook ist eingetragen, kann "
|
|
"aber nichts pushen. Das ist ein Befund, keine Entwarnung.",
|
|
)
|
|
if not (wurzel / ASKPASS_REL).is_file():
|
|
raise GitFehler(
|
|
"Auto-Push: askpass-Helfer fehlt",
|
|
f"{wurzel / ASKPASS_REL} fehlt. Ohne ihn fragt git nach einem "
|
|
"Passwort und der Push hängt oder scheitert.",
|
|
)
|
|
if not os.environ.get("GITEA_TOKEN", "").strip():
|
|
raise GitFehler(
|
|
"Auto-Push: GITEA_TOKEN nicht gesetzt",
|
|
"Die Umgebungsvariable GITEA_TOKEN ist leer oder fehlt. Der Commit "
|
|
"wird trotzdem gemacht, der Push scheitert. Variable setzen und "
|
|
"eine beliebige Regelwerk-Datei erneut speichern.",
|
|
)
|
|
|
|
git(wurzel, "add", "-A")
|
|
gestaged = [z for z in git(wurzel, "diff", "--cached", "--name-only").splitlines() if z.strip()]
|
|
if not gestaged:
|
|
return None
|
|
|
|
git(wurzel, "commit", "-m", baue_meldung(gestaged))
|
|
git(wurzel, "push", "origin", "HEAD", timeout=TIMEOUT_PUSH_S)
|
|
return None
|
|
|
|
|
|
def kuerze(text: str) -> str:
|
|
zeilen = text.splitlines()
|
|
rest = len(zeilen) - MAX_ZEILEN
|
|
if rest > 0:
|
|
zeilen = zeilen[:MAX_ZEILEN] + [f"[... {rest} weitere Zeilen gekürzt]"]
|
|
ergebnis = "\n".join(zeilen)
|
|
if len(ergebnis) > MAX_ZEICHEN:
|
|
ergebnis = ergebnis[:MAX_ZEICHEN] + f"\n[... gekürzt auf {MAX_ZEICHEN} Zeichen]"
|
|
return ergebnis
|
|
|
|
|
|
def melde(system: str, kontext: str) -> None:
|
|
ausgabe = {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "PostToolUse",
|
|
"additionalContext": kuerze(kontext),
|
|
},
|
|
"systemMessage": " ".join(system.split()),
|
|
}
|
|
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
|
|
sys.stdout.flush()
|
|
|
|
|
|
def main(roh: bytes | None = None) -> int:
|
|
try:
|
|
daten = lies_eingabe(lies_stdin() if roh is None else roh)
|
|
if not isinstance(daten, dict):
|
|
return 0
|
|
eingabe = daten.get("tool_input")
|
|
if not isinstance(eingabe, dict):
|
|
return 0
|
|
datei = eingabe.get("file_path")
|
|
if not isinstance(datei, str) or not datei.strip():
|
|
return 0
|
|
|
|
wurzel = bestimme_wurzel()
|
|
if not im_geltungsbereich(wurzel, datei):
|
|
return 0
|
|
|
|
sperre = wurzel / SPERRE_REL
|
|
deskriptor = hole_sperre(sperre)
|
|
if deskriptor is None:
|
|
# Ein anderer Lauf arbeitet noch. Er nimmt diese Änderung mit,
|
|
# weil er `git add -A` über den ganzen Baum fährt.
|
|
return 0
|
|
try:
|
|
ergebnis = arbeite(wurzel)
|
|
finally:
|
|
gib_sperre_frei(deskriptor, sperre)
|
|
if ergebnis is not None:
|
|
melde(*ergebnis)
|
|
return 0
|
|
except GitFehler as fehler:
|
|
melde(fehler.kopf, fehler.rumpf)
|
|
return 0
|
|
except Exception as fehler: # noqa: BLE001 - ein Absturz ist selbst ein Befund
|
|
melde(
|
|
f"Auto-Push abgebrochen: {type(fehler).__name__}",
|
|
f"Der Auto-Push-Hook ist unerwartet abgebrochen: "
|
|
f"{type(fehler).__name__}: {fehler}",
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|