Regeln, die kein Entwickler lokal umgehen kann
Client-seitige Git Hooks sind bequem, aber jeder Entwickler kann sie mit --no-verify umgehen, löschen oder einfach nie installieren. Server-Side Hooks laufen dagegen auf dem zentralen Git-Server, wo niemand direkten Zugriff hat, und werden damit zum einzigen echten Durchsetzungspunkt für Force-Push-Schutz, Dateigrößen-Limits und Branch-Namenskonventionen im Team.
Inhaltsverzeichnis
- 1. Warum Server-Side Hooks der eigentliche Durchsetzungspunkt sind
- 2. Die drei Hook-Typen im Git-Push-Ablauf
- 3. pre-receive: Push blockieren, bevor sich etwas ändert
- 4. update: Regeln pro Branch durchsetzen
- 5. post-receive: Benachrichtigungen und CI-Trigger
- 6. GitLab Push Rules als Managed-Alternative
- 7. GitHub Rulesets und Branch Protection
- 8. Praxisbeispiele: Force-Push, Dateigröße, Branch-Namen
- 9. Client-Side vs. Server-Side im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Server-Side Hooks der eigentliche Durchsetzungspunkt sind
Client-Side Hooks wie pre-commit und commit-msg liegen im lokalen .git/hooks-Verzeichnis oder werden über Husky per npm install eingerichtet. Genau darin liegt ihre strukturelle Schwäche: Jeder Entwickler kann sie mit git commit --no-verify umgehen, die Skripte löschen oder ein frisches Repository klonen, ohne dass Husky jemals installiert wurde. Client-Side Hooks sind damit eine Komfort- und Frühwarnschicht, keine Sicherheitsgrenze - sie fangen Tippfehler und offensichtliche Fehler ab, bevor überhaupt gepusht wird, aber sie garantieren nichts.
Server-Side Hooks laufen dagegen auf dem zentralen Git-Server, sei es ein selbst gehosteter bare-Repository-Server, eine GitLab-Instanz oder ein GitHub-Enterprise-Server. Kein Entwickler hat direkten Zugriff auf dieses Verzeichnis, kann die Skripte nicht lokal deaktivieren und kann den Push nicht ohne Umweg über administrative Rechte manipulieren. Genau das macht Server-Side Hooks zum einzigen Ort, an dem Regeln wie Force-Push-Verbote, Dateigrößen-Limits oder Branch-Namenskonventionen tatsächlich für jeden Client gelten, unabhängig davon, ob er über die Kommandozeile, ein IDE-Plugin oder ein CI-System pusht.
2. Die drei Hook-Typen im Git-Push-Ablauf
Git bietet auf der Server-Seite drei relevante Hook-Typen, die in einer festen Reihenfolge während eines git push ausgeführt werden: pre-receive, update und post-receive. Alle drei liegen im hooks/-Verzeichnis des Remote-Repositorys und müssen ausführbar sein, um zu greifen. Der entscheidende Unterschied liegt im Zeitpunkt der Ausführung und in der Fähigkeit, den Push tatsächlich abzulehnen.
pre-receive läuft genau einmal für den gesamten Push, bevor irgendeine Referenz aktualisiert wird, und erhält über stdin Zeilen im Format <alte-sha> <neue-sha> <ref-name> für jeden betroffenen Branch oder Tag. update läuft dagegen einmal pro zu aktualisierender Referenz und erhält drei positionale Argumente: Refname, alte SHA und neue SHA. post-receive schließlich läuft erst, nachdem alle Referenzen bereits aktualisiert wurden - er eignet sich für Benachrichtigungen und CI-Trigger, kann den Push aber nicht mehr zurückweisen, weil die Änderung zu diesem Zeitpunkt bereits im Repository steht.
3. pre-receive: Push blockieren, bevor sich etwas ändert
Der pre-receive-Hook ist der mächtigste Ansatzpunkt für harte Validierungen, weil er als einziger Hook den gesamten Push in einem Rutsch sieht und ihn mit einem Nicht-Null-Exit-Code komplett ablehnen kann, ohne dass auch nur eine einzige Referenz aktualisiert wird. Das macht ihn ideal für Prüfungen, die alle betroffenen Branches gemeinsam betrachten müssen, etwa ein globales Force-Push-Verbot auf geschützte Branches oder eine Prüfung, ob überhaupt ein Merge-Commit im Push enthalten ist.
Technisch liest das Skript die stdin-Zeilen zeilenweise und prüft für jede Referenz, ob die alte SHA ein Vorfahre der neuen SHA ist. Ist das nicht der Fall, handelt es sich um einen Force-Push, der die Historie umschreibt statt sie zu erweitern. git merge-base --is-ancestor <alte-sha> <neue-sha> ist genau dafür gebaut: Der Befehl gibt Exit-Code 0 zurück, wenn die alte SHA ein Vorfahre ist, und ungleich 0, wenn nicht. Läuft die Prüfung auf einem geschützten Branch wie main oder production und schlägt sie fehl, gibt das Skript eine Fehlermeldung nach stderr aus und beendet sich mit Exit-Code 1, was Git den kompletten Push ablehnen lässt.
#!/usr/bin/env bash
# pre-receive hook: reject force-pushes to protected branches
set -euo pipefail
readonly PROTECTED_BRANCHES="main|production|release/.*"
while read -r old_sha new_sha ref_name; do
branch="${ref_name#refs/heads/}"
# Skip branch deletions and non-matching branches
[[ "$new_sha" =~ ^0+$ ]] && continue
[[ "$branch" =~ ^($PROTECTED_BRANCHES)$ ]] || continue
# New branch creation on a protected name is fine
[[ "$old_sha" =~ ^0+$ ]] && continue
# Force-push means old_sha is NOT an ancestor of new_sha
if ! git merge-base --is-ancestor "$old_sha" "$new_sha" 2>/dev/null; then
echo "REJECTED: force-push to protected branch '$branch' is not allowed" >&2
echo "History rewrites on $branch must go through a reviewed process" >&2
exit 1
fi
done
exit 0
4. update: Regeln pro Branch durchsetzen
Während pre-receive alle Referenzen im Push gemeinsam sieht, wird update für jede einzelne Referenz separat aufgerufen, mit dem Refname, der alten SHA und der neuen SHA als positionale Argumente $1, $2 und $3. Das macht ihn zum natürlichen Ort für Regeln, die pro Branch entschieden werden, ohne den gesamten Push-Kontext betrachten zu müssen, etwa eine Prüfung der Branch-Namenskonvention beim Anlegen eines neuen Branches.
Ein typisches Beispiel: Ein Team erzwingt, dass neue Branches einem festen Präfix-Schema folgen, etwa feature/*, bugfix/* oder hotfix/*, damit CI-Pipelines und Deployment-Skripte anhand des Namens automatisch das richtige Verhalten wählen können. Der update-Hook prüft den Refname mit einem regulären Ausdruck und lehnt die Referenz mit Exit-Code ungleich 0 ab, wenn sie nicht passt - anders als bei pre-receive betrifft die Ablehnung dann aber nur diese eine Referenz, nicht zwingend den gesamten Push, falls mehrere Referenzen gleichzeitig aktualisiert werden.
#!/usr/bin/env bash
# update hook: enforce branch naming convention
# Arguments: $1 = refname, $2 = old_sha, $3 = new_sha
set -euo pipefail
refname="$1"
old_sha="$2"
new_sha="$3"
# Only enforce naming on newly created branches
if [[ "$old_sha" =~ ^0+$ ]] && [[ "$refname" =~ ^refs/heads/ ]]; then
branch="${refname#refs/heads/}"
# Allow protected long-lived branches by exact name
case "$branch" in
main|develop|production) exit 0 ;;
esac
if ! [[ "$branch" =~ ^(feature|bugfix|hotfix|release)/[a-z0-9._-]+$ ]]; then
echo "REJECTED: branch '$branch' violates naming convention" >&2
echo "Use one of: feature/<name>, bugfix/<name>, hotfix/<name>, release/<name>" >&2
exit 1
fi
fi
exit 0
5. post-receive: Benachrichtigungen und CI-Trigger
post-receive läuft, nachdem alle Referenzen bereits erfolgreich aktualisiert wurden, und erhält wie pre-receive die Liste der Änderungen über stdin. Der entscheidende Unterschied: Dieser Hook kann den Push nicht mehr zurückweisen, egal welchen Exit-Code er zurückgibt - die Commits sind zu diesem Zeitpunkt bereits fester Bestandteil des Repositorys. Das macht ihn ungeeignet für Validierung, aber ideal für alles, was nach einer erfolgreichen Änderung passieren soll.
Typische Anwendungsfälle sind das Auslösen einer CI-Pipeline über einen Webhook-Aufruf, das Versenden einer Slack- oder E-Mail-Benachrichtigung an das Team, das Aktualisieren eines Deployment-Dashboards oder das Triggern eines automatischen Deployments bei Pushes auf main. Weil post-receive nach dem eigentlichen Push-Vorgang läuft, blockiert ein langsamer Webhook-Aufruf zwar die Antwort an den Client, gefährdet aber nicht mehr die Integrität des Repositorys - im Zweifel sollte der Hook zeitaufwändige Arbeit asynchron im Hintergrund anstoßen, statt den Client minutenlang auf eine Antwort warten zu lassen.
6. GitLab Push Rules als Managed-Alternative
Wer eine GitLab-Instanz betreibt, muss pre-receive und update nicht zwangsläufig als rohes Shell-Skript pflegen. GitLab bietet mit Push Rules eine UI-gestützte Konfigurationsebene, die intern serverseitig durchgesetzt wird und damit dieselbe Sicherheitsgarantie wie ein selbst geschriebener pre-receive-Hook bietet. Push Rules lassen sich pro Projekt oder global auf Instanz-Ebene definieren und decken unter anderem reguläre Ausdrücke für erlaubte Branch-Namen, Commit-Message-Formate, maximale Dateigröße und das Verbot von Force-Pushes auf bestimmte Branches ab.
Der Vorteil gegenüber einem eigenen Skript liegt in der Wartbarkeit: Änderungen an den Regeln erfordern keinen Zugriff auf den Server und kein Deployment eines neuen Hook-Skripts, sondern werden über die Projekteinstellungen gepflegt und sind versioniert im Audit-Log sichtbar. Für komplexere Logik, die über reguläre Ausdrücke hinausgeht, etwa eine Prüfung gegen eine externe Datenbank oder ein Ticket-System, bleibt ein klassischer pre-receive-Hook auf selbst gehosteten GitLab-Instanzen weiterhin verfügbar und lässt sich mit Push Rules kombinieren.
# GitLab Push Rules configuration (Project Settings > Repository > Push Rules)
# These map to fields in the GitLab API / gitlab.rb for self-managed instances
push_rules:
# Reject commits that don't match this branch naming pattern
branch_name_regex: "^(feature|bugfix|hotfix|release)/[a-z0-9._-]+$"
# Reject force-pushes on protected branches (also configurable via
# Settings > Repository > Protected Branches > Allowed to force push: No)
deny_delete_tag: true
member_check: true
# Reject files above this size in newly pushed commits (MB)
max_file_size: 20
# Reject commit messages that don't reference a ticket ID
commit_message_regex: "^(feat|fix|chore|docs)(\\(.+\\))?: .+ \\[[A-Z]+-[0-9]+\\]$"
# Reject pushes that add files matching these paths (e.g. secrets)
file_name_regex: "(^|/)\\.env$|\\.pem$|id_rsa$"
7. GitHub Rulesets und Branch Protection
Rohe pre-receive-Hook-Skripte, wie sie GitLab und selbst gehostete Git-Server unterstützen, gibt es bei GitHub nur in GitHub Enterprise Server, der selbst gehosteten Variante. Auf GitHub.com, dem SaaS-Angebot, gibt es keinen Zugriff auf den Git-Server selbst und damit keine Möglichkeit, eigene Hook-Skripte zu deployen. Stattdessen stellt GitHub.com mit Repository Rulesets und dem älteren Branch Protection eine vergleichbare, aber deklarative Alternative bereit, die serverseitig ohne eigenen Code durchgesetzt wird.
Rulesets lassen sich über die Weboberfläche, per gh api oder als Infrastructure-as-Code über Terraform verwalten und decken unter anderem Pflicht-Statuschecks vor dem Merge, Force-Push-Verbote, das Verbot von Löschungen geschützter Branches und die Anforderung signierter Commits ab. Anders als bei GitLab Push Rules gibt es hier keine Möglichkeit, beliebige Shell-Logik einzubinden - wer komplexere serverseitige Prüfungen braucht, muss diese über verpflichtende Status-Checks abbilden, die von einer externen CI-Pipeline oder GitHub Action gesetzt werden, bevor ein Merge erlaubt ist.
# Create a GitHub repository ruleset via gh CLI (GitHub.com or Enterprise Cloud)
# Enforces required status checks and blocks force-pushes on main
gh api repos/mironsoft/shop-backend/rulesets \
--method POST \
-f name='protect-main' \
-f target='branch' \
-f enforcement='active' \
-f 'conditions[ref_name][include][]=refs/heads/main' \
-f 'rules[][type]=deletion' \
-f 'rules[][type]=non_fast_forward' \
-f 'rules[][type]=required_status_checks' \
-f 'rules[][parameters][required_status_checks][][context]=ci/tests' \
-f 'rules[][parameters][required_status_checks][][context]=ci/phpstan' \
-f 'rules[][parameters][strict_required_status_checks_policy]=true'
# non_fast_forward = reject force-pushes server-side, equivalent to
# checking old_sha is an ancestor of new_sha in a raw pre-receive hook
8. Praxisbeispiele: Force-Push, Dateigröße, Branch-Namen
Neben Force-Push-Schutz und Branch-Namenskonventionen ist die Ablehnung großer Dateien einer der häufigsten Anwendungsfälle für pre-receive. Binärdateien, versehentlich committete .env-Dateien mit Zugangsdaten oder große Media-Assets sollen erst gar nicht ins Repository gelangen, statt später mühsam mit git filter-repo aus der Historie entfernt zu werden. Der Hook nutzt dafür git diff-tree, um die im Push neu hinzugekommenen Objekte zu ermitteln, und git cat-file -s, um deren Größe zu prüfen, ohne den vollständigen Blob-Inhalt laden zu müssen.
Alle drei Beispiele, Force-Push-Schutz, Branch-Namen und Dateigrößen-Limits, teilen dasselbe Grundprinzip: Die Prüfung inspiziert ausschließlich Metadaten und Objekte, die Git dem Hook über stdin, Argumente oder Git-Kommandos direkt zur Verfügung stellt, ohne dass der Client-seitige Zustand relevant ist. Das ist der entscheidende Unterschied zu einem Linter, der im pre-commit-Hook läuft: Server-Side Hooks sehen nur, was tatsächlich im Repository landen würde, nicht, was ein Entwickler lokal im Arbeitsverzeichnis liegen hat.
#!/usr/bin/env bash
# pre-receive hook: reject files above a size threshold
set -euo pipefail
readonly MAX_SIZE_BYTES=$((10 * 1024 * 1024)) # 10 MB
while read -r old_sha new_sha ref_name; do
[[ "$new_sha" =~ ^0+$ ]] && continue
base_sha="$old_sha"
[[ "$base_sha" =~ ^0+$ ]] && base_sha="$(git rev-list --max-parents=0 "$new_sha" | tail -1)"
# List new blob objects introduced by this push
while read -r blob_sha; do
size=$(git cat-file -s "$blob_sha")
if (( size > MAX_SIZE_BYTES )); then
path=$(git rev-list --objects "$base_sha..$new_sha" | grep "$blob_sha" | cut -d' ' -f2-)
echo "REJECTED: '$path' is ${size} bytes, exceeds ${MAX_SIZE_BYTES} byte limit" >&2
exit 1
fi
done < <(git rev-list --objects "$base_sha..$new_sha" \
| git cat-file --batch-check='%(objectname) %(objecttype)' \
| awk '$2 == "blob" {print $1}')
done
exit 0
9. Client-Side vs. Server-Side im direkten Vergleich
Client-Side Hooks, selbst geschriebene Server-Side Hooks und Managed-Lösungen wie GitLab Push Rules oder GitHub Rulesets lösen ähnliche Probleme mit sehr unterschiedlichen Garantien. Die folgende Übersicht zeigt, warum die Wahl der Ebene keine Stilfrage, sondern eine Sicherheitsentscheidung ist.
| Dimension | Client-Side Hooks | Eigener Server-Side Hook | GitLab/GitHub Managed Rules |
|---|---|---|---|
| Lokal umgehbar | Ja, --no-verify oder löschen | Nein | Nein |
| Gilt für alle Clients | Nur wenn installiert (Husky + npm install) | Ja, immer | Ja, immer |
| Setup-Aufwand | Gering, aber pro Entwickler nötig | Hoch, eigenes Skript + Server-Zugriff | Gering, UI-Konfiguration |
| Freie Skript-Logik | Voll frei | Voll frei | Begrenzt auf vordefinierte Regeln |
| Frühzeitiges Feedback | Sofort, vor dem Commit | Erst beim Push | Erst beim Push |
| Wartung bei Regeländerung | Rollout an alle Entwickler nötig | Deployment auf Git-Server nötig | Sofort über Projekteinstellungen |
Die Tabelle macht deutlich, dass Client-Side und Server-Side Hooks keine Konkurrenten, sondern komplementäre Schichten sind. Client-Side Hooks liefern schnelles Feedback direkt beim Committen und sparen unnötige, von vornherein abgelehnte Pushes. Server-Side Hooks oder Managed Rules stellen sicher, dass diese Regeln tatsächlich für jeden gelten, unabhängig davon, ob die lokale Konfiguration korrekt oder überhaupt vorhanden ist.
Mironsoft
Git-Workflows, Server-Hooks und CI/CD-Absicherung für Entwicklerteams
Push-Validierung, die wirklich für jeden gilt?
Wir richten Server-Side Hooks und Managed Rules für euer Team ein, von Force-Push-Schutz über Branch-Konventionen bis zu Dateigrößen-Limits - zugeschnitten auf GitLab, GitHub oder euren selbst gehosteten Git-Server.
Hook-Entwicklung
pre-receive, update und post-receive Skripte für individuelle Regeln
GitLab/GitHub Setup
Push Rules, Rulesets und Branch Protection sauber konfigurieren
CI/CD-Integration
Pflicht-Statuschecks und automatisierte Freigabe-Workflows
10. Zusammenfassung
Server-Side Hooks lösen ein Problem, das Client-Side Hooks strukturell nicht lösen können: die verlässliche Durchsetzung von Regeln, unabhängig davon, wie ein Entwickler pusht. pre-receive sieht den gesamten Push vor jeder Änderung und kann ihn komplett ablehnen, etwa bei einem Force-Push auf main oder bei zu großen Dateien. update prüft jede Referenz einzeln, etwa gegen eine Branch-Namenskonvention. post-receive läuft nach der Änderung und eignet sich für Benachrichtigungen und CI-Trigger, kann den Push aber nicht mehr zurückweisen.
Wer keine eigenen Shell-Skripte pflegen will, findet in GitLab Push Rules und GitHub Rulesets eine deklarative, UI-gestützte Alternative mit derselben serverseitigen Durchsetzungsgarantie. Die beste Strategie kombiniert beide Ebenen: Client-Side Hooks für schnelles Feedback direkt beim Committen, Server-Side Hooks oder Managed Rules als unumgehbare Absicherung dahinter. Nur die Kombination stellt sicher, dass Regeln nicht nur dokumentiert, sondern tatsächlich durchgesetzt werden.
Server-Side Hooks - Das Wichtigste auf einen Blick
Echter Durchsetzungspunkt
Server-Side Hooks laufen dort, wo kein Entwickler direkten Zugriff hat - im Gegensatz zu --no-verify bei Client-Side Hooks.
pre-receive vs. update vs. post-receive
pre-receive prüft den gesamten Push, update prüft pro Referenz, post-receive läuft danach und kann nicht mehr ablehnen.
Force-Push-Schutz
git merge-base --is-ancestor erkennt zuverlässig, ob ein Push die Historie umschreibt.
Managed Alternativen
GitLab Push Rules und GitHub Rulesets ersetzen rohe Skripte durch UI-Konfiguration mit derselben Garantie.