Client- und Server-Hooks praxisnah verstehen
Git Hooks sind Skripte, die Git automatisch bei bestimmten Ereignissen wie Commit, Push oder Merge ausführt, lokal im Verzeichnis .git/hooks und serverseitig im blanken Repository. Dieser Artikel zeigt, wie sich Client und Server Hooks unterscheiden, wie ein pre-commit Hook Schritt für Schritt entsteht, wie Git Exit Codes interpretiert und wie core.hooksPath Hooks endlich teamweit versionierbar macht.
Inhaltsverzeichnis
- 1. Was Git Hooks sind und wo sie liegen
- 2. Client-seitige vs. serverseitige Hooks
- 3. Die wichtigsten Client-Hooks im Ueberblick
- 4. Einen pre-commit Hook Schritt für Schritt schreiben
- 5. Exit-Codes: Wie Git Hook-Ergebnisse interpretiert
- 6. Server-seitige Hooks am Beispiel pre-receive
- 7. Warum Hooks standardmäßig nicht versioniert werden
- 8. core.hooksPath: Hooks versionieren und teamweit verteilen
- 9. Hook-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was Git Hooks sind und wo sie liegen
Ein Git Hook ist ein ausführbares Skript, das Git automatisch startet, sobald ein bestimmtes Ereignis im Repository eintritt, etwa ein Commit, ein Push oder ein Checkout. Technisch gesehen sind Hooks gewöhnliche Shell-Skripte oder Programme in einer beliebigen Sprache, solange die Datei ausführbar ist und mit einer korrekten Shebang-Zeile beginnt. Git ruft sie anhand eines festen, vordefinierten Namens wie pre-commit oder pre-push auf, sobald die passende Aktion stattfindet, ganz ohne zusätzliche Registrierung oder Konfigurationsdatei.
Jedes mit git init oder git clone erzeugte Repository enthält bereits ein Verzeichnis .git/hooks mit einer Reihe von Beispielskripten, die die Endung .sample tragen und deshalb standardmäßig inaktiv sind. Um einen Hook zu aktivieren, genügt es, die .sample-Endung zu entfernen und die Datei mit chmod +x ausführbar zu machen. Diese Einfachheit ist gleichzeitig die größte Stärke von Hooks: Es gibt kein Plugin-System, keine externe Abhängigkeit und keine zusätzliche Lernkurve, nur eine ausführbare Datei mit dem richtigen Namen am richtigen Ort im Dateisystem.
# View the .git/hooks directory of a freshly initialized repo
ls -la .git/hooks
# List available sample hooks (all carry the .sample extension)
ls .git/hooks/*.sample
# Activate a sample hook: strip .sample and make it executable
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
# Verify Git recognizes the hook as executable
test -x .git/hooks/pre-commit && echo "pre-commit is active"
2. Client-seitige vs. serverseitige Hooks
Git unterscheidet grundsätzlich zwischen Client-Hooks, die auf dem Rechner eines einzelnen Entwicklers laufen, und Server-Hooks, die auf dem Repository ausgeführt werden, das Pushes empfängt, meist ein zentrales bare Repository auf einem Git-Server wie GitLab, Gitea oder einem selbst gehosteten Server. Zu den Client-Hooks gehören unter anderem pre-commit, commit-msg, pre-push, post-checkout und post-merge. Zu den Server-Hooks gehören pre-receive, update und post-receive, die alle beim Empfang eines Pushes auf dem Zielrepository ablaufen, nicht beim Entwickler.
Der praktische Unterschied ist entscheidend: Client-Hooks geben schnelles, lokales Feedback, können aber jederzeit mit git commit --no-verify umgangen werden oder fehlen komplett, weil sie wie im nächsten Abschnitt beschrieben standardmäßig nicht mit dem Repository geklont werden. Server-Hooks dagegen laufen auf Infrastruktur, die vom Team und nicht vom einzelnen Entwickler kontrolliert wird, und lassen sich von einem Entwickler-Client aus nicht umgehen. Wer eine Regel wirklich durchsetzen will, etwa das Verbot von Force-Pushes auf main, muss sie serverseitig implementieren, während Client-Hooks vor allem der Komfort- und Früherkennungsebene dienen.
3. Die wichtigsten Client-Hooks im Ueberblick
pre-commit läuft, bevor der Commit-Editor geöffnet wird, und ist der klassische Ort für Linting, Formatierungsprüfungen oder das Verbot von Debug-Ausgaben wie var_dump in gestagten PHP-Dateien. commit-msg läuft direkt danach und erhält als einziges Argument den Pfad zu einer temporären Datei mit der bereits eingegebenen Commit-Nachricht, ideal, um Formate wie Conventional Commits oder eine Pflicht-Ticket-Referenz zu erzwingen, bevor der Commit tatsächlich erstellt wird.
pre-push läuft, bevor Objekte zum Remote übertragen werden, und erhält über stdin eine Liste der betroffenen lokalen und remote Refs, was ihn zum passenden Ort für einen schnellen Testlauf oder eine lokale Prüfung gegen geschützte Branches macht. post-checkout läuft nach einem Branch-Wechsel und eignet sich, um auf neue Datenbank-Migrationen hinzuweisen. post-merge läuft nach einem erfolgreichen Merge oder Pull und wird häufig genutzt, um automatisch composer install auszuführen, wenn sich composer.lock im Merge geändert hat.
4. Einen pre-commit Hook Schritt für Schritt schreiben
Ein pre-commit Hook erhält keine Kommandozeilenargumente, sondern muss selbst ermitteln, welche Dateien gestaged sind. Der Standardbefehl dafür ist git diff --cached --name-only --diff-filter=ACM, der nur hinzugefügte, kopierte und geänderte Dateien liefert und gelöschte Dateien bewusst ausschließt, da ein Linter auf einer nicht mehr existierenden Datei ohnehin fehlschlagen würde. Aus dieser Dateiliste filtert man üblicherweise nach Endung, etwa nur .php-Dateien, bevor ein externes Tool wie phpcs oder php -l pro Datei aufgerufen wird.
Wichtig ist, den Hook mit der korrekten Shebang-Zeile zu beginnen, etwa #!/usr/bin/env bash, und am Ende explizit mit einem Exit-Code zu terminieren, wie im nächsten Abschnitt beschrieben. Ein häufiger Anfängerfehler ist, ausschließlich Dateien im Working Directory zu prüfen statt der tatsächlich gestagten Version, was bei teilweise gestagten Dateien mit git add -p zu falschen Ergebnissen führt. Die robuste Lösung prüft den gestagten Inhalt direkt über git show :datei.php statt die Datei einfach von der Festplatte zu lesen.
#!/usr/bin/env bash
set -euo pipefail
echo "Running pre-commit checks..."
# Collect staged PHP files (added, copied, modified), ignore deleted files
staged_php_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')
if [ -z "$staged_php_files" ]; then
echo "No staged PHP files, skipping lint."
exit 0
fi
exit_code=0
for file in $staged_php_files; do
# Lint the staged content, not the working-directory version
if ! php -l "$file" > /dev/null 2>&1; then
echo "Syntax error in: $file"
exit_code=1
fi
# Block accidental debug output in committed code
if git show ":$file" | grep -nE 'var_dump\(|dd\(|print_r\(' > /dev/null; then
echo "Debug output found in: $file"
exit_code=1
fi
done
if [ "$exit_code" -ne 0 ]; then
echo "pre-commit checks failed, commit aborted."
fi
exit $exit_code
5. Exit-Codes: Wie Git Hook-Ergebnisse interpretiert
Git wertet nach jedem Hook ausschließlich den Exit-Code aus, nicht die Textausgabe. Ein Exit-Code von 0 bedeutet Erfolg, Git fährt mit der Operation fort. Jeder andere Exit-Code, egal ob 1, 2 oder ein beliebiger anderer Wert zwischen 1 und 255, gilt als Fehlschlag und bricht bei den meisten Client-Hooks die zugehörige Git-Operation vollständig ab. Ein pre-commit Hook mit Exit-Code ungleich null verhindert also den Commit komplett, der Nutzer landet zurück im Terminal, ohne dass ein neuer Commit entsteht.
Nicht alle Hooks können die Operation blockieren: post-commit, post-checkout und post-merge laufen erst, nachdem die eigentliche Aktion bereits abgeschlossen ist, ihr Exit-Code hat deshalb keinen Einfluss mehr auf das Ergebnis, sondern dient nur der Information oder Folgeaktionen. Ein häufiger Fehler beim Schreiben eigener Hooks ist, sich implizit auf den Exit-Code des letzten Befehls zu verlassen, obwohl ein Zwischenbefehl bereits fehlgeschlagen ist. set -euo pipefail am Skriptanfang und ein expliziter exit-Aufruf am Ende schaffen hier Klarheit und verhindern, dass ein Hook fälschlicherweise Erfolg meldet.
#!/usr/bin/env bash
# Minimal example: explicit exit codes instead of relying on the last command
check_branch_name() {
local branch
branch=$(git symbolic-ref --short HEAD)
if [[ "$branch" =~ ^(feature|bugfix|hotfix)/.+ ]]; then
return 0
fi
echo "Branch name '$branch' does not match feature|bugfix|hotfix/*"
return 1
}
if check_branch_name; then
exit 0
else
# Non-zero exit code tells Git to abort the operation
exit 1
fi
6. Server-seitige Hooks am Beispiel pre-receive
pre-receive läuft auf dem Zielrepository, bevor irgendeine Ref aktualisiert wird, und ist der mächtigste Server-Hook, weil er einen kompletten Push atomar annehmen oder ablehnen kann, inklusive aller enthaltenen Commits. Git liefert über stdin pro aktualisierter Referenz eine Zeile mit drei durch Leerzeichen getrennten Werten: dem alten Objekt-Hash, dem neuen Objekt-Hash und dem vollständigen Ref-Namen wie refs/heads/main. Der Hook liest diese Zeilen und kann anhand der Werte beliebige Regeln prüfen, bevor Git die Referenzen tatsächlich aktualisiert.
Typische Einsatzzwecke sind das Verbot von Force-Pushes auf geschützte Branches, das serverseitige Erzwingen von Commit-Message-Konventionen, die Prüfung auf signierte Commits oder das Verhindern von direkten Pushes auf main außerhalb eines Merge-Request-Workflows. Der entscheidende Vorteil gegenüber Client-Hooks: Ein pre-receive Hook kann nicht mit --no-verify umgangen werden, weil er gar nicht auf dem Rechner des Entwicklers läuft, sondern ausschließlich auf dem Server, den das Team kontrolliert. Bei gehosteten Plattformen wie GitHub oder GitLab.com ist direkter Zugriff auf pre-receive meist eingeschränkt, weshalb dort Branch-Protection-Regeln oder serverseitige CI-Checks dieselbe Rolle übernehmen.
#!/usr/bin/env bash
# Server-side hook: runs on the bare repository before refs are updated
zero_sha="0000000000000000000000000000000000000000"
while read -r old_sha new_sha ref_name; do
# Reject deletions and force-pushes on the protected main branch
if [ "$ref_name" = "refs/heads/main" ]; then
if [ "$new_sha" = "$zero_sha" ]; then
echo "Rejected: deleting the main branch is not allowed."
exit 1
fi
# A force-push means the new history does not contain the old tip
if [ "$old_sha" != "$zero_sha" ] && ! git merge-base --is-ancestor "$old_sha" "$new_sha"; then
echo "Rejected: force-push to main is not allowed."
exit 1
fi
fi
done
exit 0
7. Warum Hooks standardmäßig nicht versioniert werden
Das Verzeichnis .git selbst ist niemals Teil des versionierten Projektinhalts, es ist der interne Metadaten-Speicher von Git und wird beim Klonen eines Repositories vollständig neu erzeugt, nicht kopiert. Alles, was in .git/hooks liegt, gilt deshalb als lokale, maschinenspezifische Konfiguration, genauso wie .git/config oder Remote-Tracking-Informationen. Klont ein neuer Kollege das Repository, erhält er ein frisches .git/hooks Verzeichnis mit ausschließlich den Standard-Beispieldateien, unabhängig davon, welche Hooks im Ursprungsrepository aktiv waren.
Diese Eigenschaft ist keine Einschränkung von Git, sondern folgt konsequent aus dem Zweck von .git: Hooks können beliebigen Code auf dem Zielsystem ausführen, und ein automatisch aktiviertes, aus dem Repository geklontes Skript wäre ein erhebliches Sicherheitsrisiko, vergleichbar mit dem automatischen Ausführen von heruntergeladenem Code. Aus genau diesem Grund gibt es keinen eingebauten Mechanismus, um Hooks aus .git/hooks zu versionieren. Ein manueller Workaround, etwa Skripte in einem README zu dokumentieren und Entwickler zu bitten, sie von Hand zu kopieren, ist fehleranfällig und wird in der Praxis regelmäßig vergessen, sobald ein neuer Hook hinzukommt oder ein bestehender sich ändert.
8. core.hooksPath: Hooks versionieren und teamweit verteilen
Die Git-Konfigurationsoption core.hooksPath, verfügbar seit Git 2.9, löst das Problem direkt an der Wurzel: Sie weist Git an, Hooks nicht mehr in .git/hooks zu suchen, sondern in einem beliebigen anderen Verzeichnis, das ganz normal Teil des versionierten Projekts sein kann, etwa .githooks/ im Projekt-Root. Sobald git config core.hooksPath .githooks gesetzt ist, ruft Git bei jedem passenden Ereignis die Skripte aus diesem Verzeichnis auf, exakt nach denselben Namens- und Ausführbarkeits-Regeln wie zuvor bei .git/hooks.
Der verbleibende manuelle Schritt ist die einmalige Ausführung des git config Befehls nach jedem Klonen, was sich über ein Setup-Skript, einen Makefile-Task oder eine Anweisung im README automatisieren lässt. Für Node.js- beziehungsweise npm-basierte Projekte übernehmen Tools wie Husky genau diese Aufgabe automatisch über ein prepare-Skript in der package.json, das bei jedem npm install läuft und das eigentliche Konfigurieren von core.hooksPath unsichtbar für den Entwickler erledigt. Wie Husky in Kombination mit lint-staged genau funktioniert und wie sich Hooks damit teamweit ohne manuellen Schritt durchsetzen lassen, behandelt ein eigener Folgeartikel im Detail.
# Versioned hooks directory inside the repository
mkdir -p .githooks
# Move the existing pre-commit hook into the versioned directory
mv .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
# Tell Git to look here instead of .git/hooks
git config core.hooksPath .githooks
# Commit the hooks directory like any other project file
git add .githooks
git commit -m "Add versioned pre-commit hook via core.hooksPath"
# Every teammate runs this once after cloning
git config core.hooksPath .githooks
9. Hook-Strategien im Vergleich
Die drei praxisrelevanten Strategien, um Hooks im Team konsistent einzusetzen, unterscheiden sich vor allem in Versionierbarkeit, Umgehbarkeit und Setup-Aufwand. Die folgende Uebersicht fasst zusammen, wann welcher Ansatz die richtige Wahl ist.
| Ansatz | Im Repo versioniert | Umgehbar mit --no-verify | Setup-Aufwand |
|---|---|---|---|
| Manuelles .git/hooks | Nein | Ja | Keiner, aber wirkungslos nach jedem Klon |
| core.hooksPath | Ja | Ja | Einmaliger git config Befehl pro Klon |
| Husky / lint-staged (npm) | Ja | Ja | Automatisch über npm install |
| Server-seitig (pre-receive) | Ja, im Server-Setup | Nein | Zugriff auf Git-Server erforderlich |
In der Praxis ergänzen sich Client- und Server-Hooks: Client-Hooks wie pre-commit geben sofortiges Feedback direkt im Editor-Workflow, während serverseitige pre-receive Hooks als letzte, nicht umgehbare Instanz Regeln durchsetzen, die wirklich verbindlich sein müssen. core.hooksPath macht dabei den entscheidenden Unterschied zwischen einem Hook, der nur auf einer einzigen Maschine existiert, und einem Hook, der Teil der Projekthistorie und für jeden Klon sofort verfügbar ist.
Mironsoft
Git-Hooks-Setup, CI/CD-Automatisierung und Entwickler-Schulungen für Magento-Teams
Git Hooks zuverlässig im Team etablieren?
Wir richten versionierte Git Hooks über core.hooksPath oder Husky für euer Team ein, implementieren serverseitige pre-receive Regeln für geschützte Branches und schulen Entwickler im sicheren Umgang mit lokaler Automatisierung.
Hook-Setup & core.hooksPath
Versionierte pre-commit, commit-msg und pre-push Hooks für euer Repository
Server-Side Enforcement
pre-receive Regeln für geschützte Branches und Commit-Konventionen
Entwickler-Schulung
Praxis-Workshop zu Git Hooks, Exit-Codes und CI/CD-Integration
10. Zusammenfassung
Git Hooks lösen ein zentrales Automatisierungsproblem im Entwickleralltag: wiederkehrende Prüfungen wie Linting, Commit-Message-Formate oder Test-Läufe müssen nicht mehr manuell ausgeführt werden, sondern laufen automatisch bei Commit, Push oder Merge. Client-Hooks wie pre-commit, commit-msg und pre-push geben schnelles, lokales Feedback direkt im Terminal, können aber mit --no-verify umgangen werden und fehlen standardmäßig nach jedem frischen Klon, weil .git/hooks nie Teil des versionierten Projektinhalts ist.
core.hooksPath schließt genau diese Lücke, indem es Git anweist, Hooks aus einem versionierten Projektverzeichnis wie .githooks/ zu laden, statt aus dem nicht versionierten .git/hooks. Für wirklich verbindliche Regeln, die kein Entwickler umgehen können soll, bleibt ein serverseitiger pre-receive Hook auf dem Git-Server die einzige zuverlässige Instanz. Wer Client-Hooks für schnelles Feedback und Server-Hooks für verbindliche Durchsetzung kombiniert, deckt beide Ebenen der Automatisierung sauber ab, unabhängig davon, ob Tools wie Husky oder ein manuell gepflegtes core.hooksPath Setup zum Einsatz kommen.
Git Hooks Grundlagen, Das Wichtigste auf einen Blick
Client vs. Server
Client-Hooks laufen lokal und sind umgehbar, Server-Hooks wie pre-receive laufen auf dem Git-Server und sind verbindlich.
Exit-Codes zählen
Exit-Code 0 erlaubt die Operation, jeder andere Wert bricht sie bei den meisten Hooks vollständig ab.
Nicht versioniert
.git/hooks wird beim Klonen nie mitkopiert, weil .git selbst kein versionierter Projektinhalt ist.
core.hooksPath als Fix
Zeigt Git auf ein versioniertes Verzeichnis wie .githooks/, damit Hooks mit dem Repository mitreisen.