Git-Hooks versionieren statt manuell konfigurieren
Lokale Git-Hooks landen in .git/hooks und werden nie mit dem Repository versioniert, wodurch jeder Entwickler sie einzeln einrichten muss und Teams faktisch keine verlässliche Durchsetzung von Codequalität vor dem Commit haben. Husky verteilt Hooks automatisch über npm, lint-staged beschränkt Prüfungen auf geänderte Dateien und commitlint erzwingt saubere Commit-Nachrichten, ganz ohne manuelle Einrichtung.
Inhaltsverzeichnis
- 1. Das Problem: unversionierte lokale Git-Hooks im Team
- 2. Was Husky ist und wie es Hooks automatisch installiert
- 3. lint-staged: nur geänderte Dateien prüfen statt das ganze Repository
- 4. Praktisches Setup für ein gemischtes PHP/JS-Projekt
- 5. commit-msg Hook mit commitlint für Conventional Commits
- 6. PHPCS, PHP-CS-Fixer, ESLint und Prettier je Dateityp konfigurieren
- 7. CI vs. lokales Hook-Verhalten und die --no-verify Fluchttür
- 8. Typische Fallstricke im Team-Alltag
- 9. Manuelle Hooks vs. core.hooksPath vs. Husky+lint-staged im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem: unversionierte lokale Git-Hooks im Team
Git legt jedes lokal geklonte Repository standardmäßig mit einem .git/hooks-Verzeichnis an, das Beispiel-Skripte für pre-commit, commit-msg und weitere Ereignisse enthält. Dieses Verzeichnis liegt außerhalb der versionierten Dateien: Git schließt .git/ grundsätzlich von jeder Übertragung aus, sodass ein einmal lokal eingerichteter Hook nie im Repository landet und nie bei einem anderen Entwickler ankommt. Wer Codequalität vor dem Commit erzwingen will, etwa durch automatisches Linting oder Formatierung, müsste diesen Hook auf jeder einzelnen Entwicklermaschine von Hand kopieren und ausführbar machen.
In der Praxis bedeutet das: Ein Team von fünf Entwicklern hat im besten Fall fünf manuell gepflegte, garantiert irgendwann auseinanderlaufende Kopien desselben Skripts, im schlechtesten Fall hat nur eine Person überhaupt einen aktiven Hook. Ein alternativer Ansatz ist git config core.hooksPath, mit dem sich ein projekteigenes, versioniertes Verzeichnis als Hook-Quelle konfigurieren lässt. Das löst das Versionierungsproblem, aber nicht das Verteilungsproblem: Jeder Entwickler muss den Befehl trotzdem einmal manuell ausführen, und nichts erinnert neue Teammitglieder daran, es überhaupt zu tun. Genau diese Lücke schließt Husky, indem es die Einrichtung an einen ohnehin notwendigen Schritt koppelt: die Installation der npm-Abhängigkeiten.
2. Was Husky ist und wie es Hooks automatisch installiert
Husky ist ein schlankes npm-Paket, das Git-Hooks als reguläre, versionierte Dateien im Projekt verwaltet, üblicherweise im Verzeichnis .husky/. Statt Skripte manuell nach .git/hooks zu kopieren, setzt Husky beim Setup einmalig core.hooksPath auf dieses versionierte Verzeichnis. Jede Datei darin, etwa .husky/pre-commit, wird von Git ab diesem Zeitpunkt wie ein nativer Hook behandelt und bei jedem passenden Ereignis automatisch ausgeführt.
Der entscheidende Kniff liegt im npm-eigenen prepare-Script. npm führt das Script, das unter scripts.prepare in der package.json hinterlegt ist, automatisch nach jedem npm install aus, sofern kein --production-Flag oder NODE_ENV=production gesetzt ist. Trägt man dort husky ein, richtet sich der Hook-Pfad für jeden Entwickler und jede frische Checkout-Umgebung automatisch ein, sobald er den ohnehin notwendigen Installationsschritt durchführt. Kein zusätzlicher Befehl, kein Punkt in der Onboarding-Checkliste, der vergessen werden kann.
# Husky als Dev-Dependency installieren
npm install --save-dev husky
# Husky initialisieren: legt .husky/ an und setzt das prepare-Script
npx husky init
# package.json enthält danach automatisch:
# "scripts": { "prepare": "husky" }
# Einen konkreten Hook anlegen, ausführbares Shell-Skript
echo "npx lint-staged" > .husky/pre-commit
chmod +x .husky/pre-commit
# Hook versionieren, damit er im Team ankommt
git add .husky/pre-commit package.json
git commit -m "chore: add husky pre-commit hook"
3. lint-staged: nur geänderte Dateien prüfen statt das ganze Repository
Ein naiver pre-commit-Hook, der bei jedem Commit den kompletten Linter und Formatter über das gesamte Repository laufen lässt, wird in einem gewachsenen Magento- oder Hyvä-Projekt mit tausenden PHP- und JavaScript-Dateien schnell unbenutzbar langsam. Ein Commit, der nur eine einzelne .phtml-Datei ändert, würde trotzdem minutenlang auf einen vollständigen PHPCS-Lauf über den kompletten app/code-Baum warten. Genau dieses Problem löst lint-staged: Es liest die tatsächlich für den Commit vorgemerkten, also gestagten Dateien aus dem Git-Index aus und wendet die konfigurierten Befehle ausschließlich auf diese Teilmenge an.
Die Konfiguration erfolgt über Glob-Muster als Schlüssel und einen oder mehrere Befehle als Wert, entweder in einer eigenen lint-staged.config.js oder direkt unter dem Schlüssel lint-staged in der package.json. Für Muster, deren Linter Dateien automatisch reparieren können, etwa ESLint mit --fix oder PHP-CS-Fixer, fügt lint-staged die reparierten Dateien anschließend automatisch wieder zum Commit hinzu. Damit landet nie unformatierter Code im Repository, ohne dass ein Entwickler den Formatter manuell aufrufen muss.
{
"lint-staged": {
"*.php": [
"php vendor/bin/phpcbf --standard=Magento2",
"php vendor/bin/phpcs --standard=Magento2"
],
"*.{js,ts}": [
"eslint --fix",
"prettier --write"
],
"*.{css,pcss}": [
"stylelint --fix"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}
}
4. Praktisches Setup für ein gemischtes PHP/JS-Projekt
Ein typisches Magento-2-Projekt mit Hyvä-Theme mischt PHP-Code in app/code mit JavaScript, Alpine.js-Komponenten und Tailwind-CSS im Theme-Verzeichnis. Beide Welten brauchen eigene Werkzeuge, sollen aber über denselben einen pre-commit-Hook laufen. Die Lösung ist, dass Husky nur den Einstiegspunkt bereitstellt, nämlich den Aufruf von npx lint-staged, während lint-staged selbst die Verzweigung nach Dateityp übernimmt und die passenden PHP- beziehungsweise JS-Werkzeuge aufruft.
Wichtig ist, dass alle in lint-staged referenzierten Befehle auch ohne globale Installation funktionieren. PHP-Werkzeuge liegen deshalb über Composer in vendor/bin, JavaScript-Werkzeuge über npm in node_modules/.bin, und beide Verzeichnisse müssen entsprechend im PATH auffindbar sein, was lint-staged und npx standardmäßig sicherstellen. Für Projekte, in denen PHP in einem Docker-Container läuft, etwa im Mark-Shust-Setup, ruft der Hook stattdessen den entsprechenden Wrapper wie bin/phpcs auf, damit der Hook auf dem Host läuft, PHPCS aber im Container ausgeführt wird und exakt dieselbe PHP-Version wie die Produktion prüft.
#!/usr/bin/env sh
# .husky/pre-commit
# Ein einziger Einstiegspunkt, lint-staged übernimmt die Verzweigung
# nach Dateityp und ruft PHP- bzw. JS-Werkzeuge gezielt auf.
npx lint-staged
# Docker-Variante (Mark Shust Setup): PHP-Werkzeuge im Container ausführen,
# damit lokale PHP-Version nicht von der Produktion abweicht.
# In diesem Fall referenziert lint-staged "bin/phpcbf" statt "vendor/bin/phpcbf".
5. commit-msg Hook mit commitlint für Conventional Commits
Husky beschränkt sich nicht auf pre-commit. Jeder von Git unterstützte Hook-Name, etwa commit-msg, pre-push oder post-merge, kann als eigene Datei in .husky/ abgelegt werden. Für die Vereinheitlichung von Commit-Nachrichten kombiniert man Husky mit commitlint: Der commit-msg-Hook erhält von Git den Pfad zur temporären Datei mit der eingegebenen Commit-Nachricht als Argument und reicht diesen an commitlint weiter, das die Nachricht gegen ein konfigurierbares Regelwerk prüft.
Der verbreitetste Standard ist Conventional Commits, bei dem jede Nachricht mit einem Typ wie feat, fix, chore oder refactor beginnt, gefolgt von einem optionalen Scope und einer knappen Beschreibung. Das erzwingt keine stilistische Vorliebe, sondern macht Commit-Historien maschinenlesbar: automatisch generierte Changelogs, semantische Versionierung und gezielte Release-Notes lassen sich direkt aus der Commit-Historie ableiten, wenn jede Nachricht demselben Muster folgt. Ohne durchgesetzten Standard driftet eine Commit-Historie über Monate zu einer unstrukturierten Sammlung aus „fix“, „wip“ und „asdf“.
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
["feat", "fix", "chore", "docs", "refactor", "test", "perf", "style"]
],
"subject-case": [2, "never", ["upper-case", "start-case"]],
"header-max-length": [2, "always", 100]
}
}
6. PHPCS, PHP-CS-Fixer, ESLint und Prettier je Dateityp konfigurieren
Jedes Werkzeug in der lint-staged-Kette braucht eine eigene, projekteigene Konfigurationsdatei, damit es unabhängig vom Hook auch in der IDE und in der CI-Pipeline dieselben Regeln anwendet. Für PHP in Magento-Projekten ist phpcs.xml mit dem Magento2-Coding-Standard als Basis üblich, ergänzt um projekteigene Ausnahmen für generierten Code. PHP-CS-Fixer ergänzt PHPCS dort, wo automatische Korrektur gewünscht ist, etwa bei Einrückung, Import-Sortierung und Leerzeilen-Konventionen, während PHPCS eher als reiner Prüfer für Regeln eingesetzt wird, die sich nicht automatisch sicher beheben lassen.
Auf der JavaScript-Seite übernimmt ESLint mit einer projektweiten eslint.config.js die strukturelle Prüfung von Alpine.js-Komponenten und sonstigem Frontend-Code, während Prettier ausschließlich für Formatierung zuständig ist und über eslint-config-prettier von den Stilregeln des Linters entkoppelt wird, um widersprüchliche Korrekturen zu vermeiden. Für Tailwind-bezogenes CSS und PostCSS-Dateien im Theme-Verzeichnis übernimmt Stylelint dieselbe Rolle wie ESLint für JavaScript. In allen Konfigurationen gehören generierte Verzeichnisse wie vendor/, var/, pub/static/ und node_modules/ konsequent in eine Ignore-Datei, sonst prüft der Hook versehentlich Code, den niemand von Hand geschrieben hat.
7. CI vs. lokales Hook-Verhalten und die --no-verify Fluchttür
Git-Hooks sind ein rein lokaler Mechanismus. Ein pre-commit-Hook läuft auf der Maschine, auf der der Commit erzeugt wird, niemals nachträglich beim Push oder in der CI-Pipeline. Das bedeutet zweierlei: Erstens muss die CI-Pipeline dieselben Prüfungen unabhängig noch einmal ausführen, denn ein Hook, der lokal übersprungen oder gar nicht installiert wurde, hinterlässt in der Pipeline keinerlei Spur. Zweitens sollte man sich niemals ausschließlich auf Hooks als Qualitätssicherung verlassen, sie sind ein schneller Feedback-Mechanismus für den Entwickler, kein Ersatz für einen serverseitigen Gate-Check vor dem Merge.
Git bietet mit dem Flag --no-verify bei git commit und git push eine bewusste Fluchttür, die alle Hooks für genau diesen einen Befehl überspringt. Das ist legitim für echte Notfälle, etwa einen dringenden Hotfix, dessen Linting-Fehler bereits bekannt und geplant zu beheben sind, wird in der Praxis aber auch gerne missbraucht, um lästige Prüfungen dauerhaft zu umgehen. Genau deshalb ist der zweite, unabhängige CI-Check kein optionales Extra, sondern die eigentliche Durchsetzungsebene: Ein Pull Request mit fehlgeschlagenem Lint-Check in der Pipeline lässt sich nicht mergen, unabhängig davon, ob der Autor lokal --no-verify verwendet hat oder nicht.
# .github/workflows/lint.yml
# CI führt dieselben Checks unabhängig von lokalen Hooks erneut aus
name: Lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# npm ci statt npm install: reproduzierbar, führt kein prepare
# für Husky sinnvoll aus, da CI keine Git-Hooks benötigt
- run: npm ci --ignore-scripts
- run: npx eslint . --max-warnings=0
- run: npx stylelint "**/*.{css,pcss}"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
- run: composer install --no-interaction --prefer-dist
# Vollständiger Lauf über das gesamte Verzeichnis, nicht nur
# gestagte Dateien, denn CI prüft den kompletten Diff-Stand
- run: php vendor/bin/phpcs --standard=Magento2 app/code
- run: npx commitlint --from=origin/main --to=HEAD
8. Typische Fallstricke im Team-Alltag
Der häufigste Fallstrick betrifft Teammitglieder, die ein bestehendes Repository klonen, aber danach keinen npm install ausführen, etwa weil sie nur an PHP-Code arbeiten und die JavaScript-Abhängigkeiten für ihre Aufgabe irrelevant erscheinen. Ohne den durch npm install ausgelösten prepare-Schritt setzt Husky core.hooksPath nie, und der Hook existiert für diese Person schlicht nicht, ohne dass eine Fehlermeldung darauf hinweist. Eine README-Notiz allein reicht nicht: Ein postinstall-Hinweis oder eine CI-Prüfung, die kontrolliert, ob core.hooksPath in frischen Checkouts korrekt gesetzt werden kann, macht das Problem sichtbar, statt es stillschweigend zu tolerieren.
Ein zweiter, subtilerer Fallstrick betrifft die Ausführbarkeit von Hook-Dateien nach einem Checkout, insbesondere unter Windows oder bei bestimmten CI-Checkout-Aktionen, die das ausführbare Bit nicht zuverlässig erhalten. Husky ab Version 8 kompensiert das größtenteils durch schlanke, POSIX-kompatible Shell-Wrapper, aber Zeilenenden bleiben ein Risiko: Eine mit Windows-Zeilenenden (CRLF) gespeicherte Hook-Datei kann auf einer Linux-Maschine mit dem Fehler bad interpreter fehlschlagen. Ein projekteigener .gitattributes-Eintrag mit .husky/* text eol=lf verhindert das zuverlässig. In CI-Umgebungen wiederum will man Husky-Hooks meist explizit deaktivieren, etwa über die Umgebungsvariable HUSKY=0, da CI ohnehin keine interaktiven Commits erzeugt und ein fehlschlagender prepare-Schritt sonst unnötig ganze Installationsschritte zum Absturz bringen kann.
9. Manuelle Hooks vs. core.hooksPath vs. Husky+lint-staged im Vergleich
Alle drei Ansätze lösen dasselbe Grundproblem, Codequalität vor dem Commit zu prüfen, unterscheiden sich aber erheblich darin, wie zuverlässig sie tatsächlich bei jedem Teammitglied ankommen und wie viel manuellen Aufwand sie beim Onboarding verursachen.
| Aspekt | Manuelle .git/hooks | core.hooksPath | Husky + lint-staged |
|---|---|---|---|
| Versionierung im Repository | Nicht möglich, .git/ wird nie übertragen | Möglich, eigenes Verzeichnis wird versioniert | Vollständig versioniert in .husky/ |
| Einrichtung nach Clone | Manuelles Kopieren pro Entwickler | Manueller git-config-Befehl nötig | Automatisch über npm-Prepare-Script |
| Team-weite Konsistenz | Driftet garantiert auseinander | Konsistent, wenn Setup befolgt wird | Konsistent, an npm install gekoppelt |
| Geschwindigkeit bei großen Repos | Abhängig vom Skriptinhalt | Abhängig vom Skriptinhalt | Schnell dank lint-staged, nur geänderte Dateien |
| Fehlerhinweis bei fehlendem Setup | Keiner, Hook existiert einfach nicht | Keiner ohne zusätzliches Tooling | Nur wenn npm install ausgeführt wird |
In der Praxis ist die Kombination aus Husky und lint-staged kein reines Komfort-Feature, sondern die einzige der drei Varianten, die Hook-Verteilung an einen bereits erzwungenen Schritt koppelt: Ohne npm install funktioniert kein JavaScript-Tooling im Projekt, also wird auch der Hook praktisch nie übersprungen. Der verbleibende Rest an Zuverlässigkeit muss trotzdem über eine unabhängige CI-Prüfung abgesichert werden, denn kein clientseitiger Mechanismus ersetzt eine serverseitige Kontrolle vor dem Merge.
Mironsoft
Git-Workflows, Tooling-Setup und CI/CD-Pipelines für PHP- und Magento-Teams
Codequalität im Team endlich verlässlich durchsetzen?
Wir richten Husky, lint-staged und commitlint für euren PHP- und Magento-Stack ein, verbinden sie mit PHPCS, ESLint und einer passenden CI-Pipeline und sorgen dafür, dass die Prüfungen bei jedem Teammitglied automatisch ankommen.
Hook-Setup
Husky, lint-staged und commitlint versioniert im Repository einrichten
Linter-Konfiguration
PHPCS, PHP-CS-Fixer, ESLint und Stylelint aufeinander abstimmen
CI-Integration
Serverseitige Gate-Checks als Ergänzung zu lokalen Hooks aufbauen
10. Zusammenfassung
Husky und lint-staged lösen ein Problem, das viele Teams jahrelang ignorieren: Lokale Git-Hooks aus .git/hooks lassen sich nicht versionieren und erreichen deshalb ohne manuellen Zusatzaufwand nie zuverlässig alle Teammitglieder. Husky koppelt die Hook-Einrichtung an das npm-eigene prepare-Script und damit an einen Schritt, den ohnehin jeder Entwickler ausführen muss. lint-staged sorgt dafür, dass Pre-Commit-Prüfungen schnell bleiben, indem sie nur auf tatsächlich geänderte, gestagte Dateien angewendet werden, statt bei jedem Commit das gesamte Repository zu scannen. commitlint ergänzt beide um vereinheitlichte Commit-Nachrichten nach dem Conventional-Commits-Standard.
Wichtig bleibt das Bewusstsein für die Grenzen dieses Ansatzes: Git-Hooks sind ein rein lokaler, clientseitiger Mechanismus, den Entwickler mit --no-verify bewusst umgehen können und der komplett fehlt, wenn niemand npm install ausgeführt hat. Eine unabhängige CI-Pipeline, die dieselben Linter, Formatter und commitlint-Regeln serverseitig erneut ausführt, bleibt deshalb die eigentliche Durchsetzungsebene. Husky und lint-staged sind der schnelle, komfortable erste Filter, nicht der letzte.
Husky und lint-staged im Team, das Wichtigste auf einen Blick
Automatische Installation
Husky im npm prepare-Script eintragen, dann setzt jeder npm install die Hooks automatisch für das Team.
Nur geänderte Dateien
lint-staged beschränkt PHPCS, ESLint und Prettier auf gestagte Dateien, statt das gesamte Repository zu prüfen.
Commit-Nachrichten
commit-msg-Hook plus commitlint erzwingt Conventional Commits und macht die Historie maschinenlesbar.
CI als zweite Ebene
Hooks sind lokal und mit --no-verify umgehbar. CI muss dieselben Checks unabhängig erneut ausführen.