commit-msg-Hooks für Commit-Message-Validierung
git
HEAD
Git · Hooks · Commit-Message · Code-Qualität
commit-msg-Hooks für Commit-Message-Validierung
Conventional Commits erzwingen, bevor der Commit entsteht

Wer Commit-Messages ohne Validierung durchwinkt, sammelt über Monate eine Historie aus Nachrichten wie fix und wip, die sich weder für Changelogs noch für automatisierte Releases nutzen lässt. Der commit-msg-Hook prüft jede Nachricht, bevor der Commit entsteht, und erzwingt mit commitlint und Husky ein konsistentes Format, das Tools wie semantic-release zuverlässig auswerten können.

12 Min. Lesezeit commitlint · Husky · Conventional Commits Git Hooks · CI/CD · semantic-release

1. Einordnung: Wozu der commit-msg-Hook dient

Git kennt eine ganze Reihe von Hooks, die sich grob in zwei Familien teilen: client-seitige Hooks, die lokal auf der Maschine jedes Entwicklers laufen, und server-seitige Hooks, die auf dem Remote-Repository greifen. Der commit-msg-Hook gehört zur ersten Familie und liegt im Ablauf zwischen pre-commit und post-commit: pre-commit prüft die gestagten Änderungen, bevor überhaupt eine Nachricht geschrieben wird, commit-msg prüft die fertige Nachricht, nachdem sie geschrieben wurde, aber bevor Git daraus ein Commit-Objekt erzeugt. Genau in diesem schmalen Zeitfenster lässt sich ein Commit noch verwerfen, ohne dass irgendetwas im Repository verändert wurde.

Ohne Validierung sammelt sich über Monate eine Historie aus Nachrichten wie fix, wip oder asdf an, die für niemanden mehr nachvollziehbar ist, weder für Code-Reviewer noch für Tools, die die Historie maschinell auswerten. Der commit-msg-Hook ist der früheste Punkt, an dem sich das verhindern lässt, lokal, kostenlos und ohne dass ein CI-Job erst minutenlang durchlaufen muss, um denselben Fehler zu melden.

2. Hook-Mechanik: Wann der Hook feuert und was er bekommt

Sobald git commit aufgerufen wird, egal ob mit -m "Nachricht" oder über den geöffneten Editor, schreibt Git die Commit-Message zunächst in eine temporäre Datei, meist .git/COMMIT_EDITMSG. Erst danach ruft Git den commit-msg-Hook auf und übergibt den Pfad zu dieser Datei als einzigen Parameter $1. Der Hook liest die Datei, prüft ihren Inhalt und gibt einen Exit-Code zurück: 0 bedeutet Freigabe, jeder andere Wert bricht den Commit ab, bevor das Commit-Objekt in der Objektdatenbank landet. Die Datei selbst kann der Hook sogar verändern, etwa um eine Ticket-Nummer automatisch anzuhängen.

Wichtig ist die Reihenfolge im Vergleich zu benachbarten Hooks: prepare-commit-msg läuft, bevor der Editor überhaupt geöffnet wird, und kann eine Vorlage einfügen, etwa den Branch-Namen. commit-msg läuft danach und sieht die finale, vom Entwickler bestätigte Nachricht. post-commit läuft erst, nachdem das Commit-Objekt bereits existiert, und kann den Vorgang folglich nicht mehr abbrechen. Wer Validierung will, muss also zwingend in commit-msg ansetzen, nicht in post-commit.

3. Conventional Commits mit commitlint durchsetzen

Conventional Commits ist eine Konvention für Commit-Messages nach dem Schema typ(scope): kurzbeschreibung, etwa feat(checkout): add express payment button oder fix(api): handle null response from pricing service. Die gebräuchlichen Typen sind feat, fix, docs, style, refactor, perf, test, build, ci und chore. Diese Struktur ist maschinenlesbar, was sie vom klassischen Fließtext-Commit fundamental unterscheidet: ein Skript kann anhand des Typs entscheiden, ob ein Release eine Major-, Minor- oder Patch-Version auslöst.

commitlint ist das Standardwerkzeug, um dieses Format automatisiert durchzusetzen. Mit @commitlint/cli als Ausführungswerkzeug und @commitlint/config-conventional als Regelwerk prüft commitlint jede Nachricht gegen Regeln wie type-enum (nur erlaubte Typen), subject-case (kein Großbuchstabe am Anfang des Subjects) und header-max-length (meist 100 Zeichen). Schlägt eine Regel fehl, gibt commitlint eine präzise Fehlermeldung mit der verletzten Regel aus, sodass der Entwickler sofort weiß, was zu korrigieren ist.


// commitlint.config.js - enforce Conventional Commits format
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Only allow these commit types
    'type-enum': [
      2,
      'always',
      ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'],
    ],
    // Subject must not start with an uppercase letter
    'subject-case': [2, 'always', 'lower-case'],
    // Subject must not end with a period
    'subject-full-stop': [2, 'never', '.'],
    // Header (type + scope + subject) max length
    'header-max-length': [2, 'always', 100],
    // Scope is optional but must be lowercase when present
    'scope-case': [2, 'always', 'lower-case'],
    // Body lines must be wrapped, but a single-line body is fine
    'body-max-line-length': [1, 'always', 100],
  },
};

4. Husky v9 installieren und den Hook verdrahten

Husky löst ein strukturelles Problem von Git-Hooks: .git/hooks/ wird nicht von Git selbst versioniert, jeder Klon des Repositories startet also ohne Hooks. Husky verlagert die Hook-Skripte in ein versioniertes .husky/-Verzeichnis und verlinkt core.hooksPath beim Ausführen von npm install automatisch dorthin, über das prepare-Skript in package.json. Damit hat jeder Mitarbeiter nach dem Klonen automatisch dieselben Hooks aktiv, ohne manuellen Zusatzschritt.

Seit Husky v9 reicht eine einzige ausführbare Datei .husky/commit-msg ohne das frühere Shebang-Boilerplate. Der Inhalt ruft commitlint mit dem übergebenen Dateipfad auf: npx --no -- commitlint --edit "$1". Das Flag --no von npx verhindert, dass npx bei fehlendem lokalen Paket ungefragt eine Version aus dem Registry nachlädt, was den Commit-Vorgang unnötig verlangsamen oder bei fehlender Internetverbindung sogar blockieren würde. Die Datei muss ausführbar sein, chmod +x .husky/commit-msg ist nach dem Anlegen Pflicht.


#!/usr/bin/env sh
# .husky/commit-msg - validate the commit message file against commitlint rules
# $1 is the path to the temporary commit message file (e.g. .git/COMMIT_EDITMSG)
npx --no -- commitlint --edit "$1"

{
  "name": "storefront-app",
  "private": true,
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": {
    "@commitlint/cli": "^19.3.0",
    "@commitlint/config-conventional": "^19.2.2",
    "husky": "^9.0.11"
  }
}

5. Vage Commit-Messages erkennen und ablehnen

commitlint mit der Standardkonfiguration erzwingt das Format, prüft aber nicht automatisch, ob das Subject inhaltlich aussagekräftig ist. Eine Nachricht wie fix: fix erfüllt formal jede Standardregel und wird trotzdem durchgewunken, obwohl sie keinerlei Information trägt. Nachrichten wie fix, wip, asdf oder update sind der eigentliche Kern des Problems, das commit-msg-Hooks lösen sollen, und verdienen eine zusätzliche, projektspezifische Prüfung.

Eine eigene Rule-Erweiterung für commitlint oder ein einfaches Node-Skript im selben Hook kann eine Blacklist bekannter Füllwörter sowie eine Mindestlänge für das Subject erzwingen. Praktisch bewährt sich eine Kombination aus zwei Prüfungen: erstens eine Liste generischer Begriffe wie fix, wip, update, asdf, test, tmp, die als alleinstehendes Subject abgelehnt werden, und zweitens eine Mindestlänge von etwa zehn Zeichen für den Freitextanteil nach dem Typ. Das fängt die offensichtlichsten Fälle ab, ohne kreative, aber kurze und aussagekräftige Nachrichten unnötig zu blockieren.


#!/usr/bin/env node
// scripts/validate-commit-msg.js - reject vague or placeholder commit messages
// Called from .husky/commit-msg as an extra layer next to commitlint
const fs = require('fs');

const VAGUE_SUBJECTS = ['fix', 'wip', 'asdf', 'update', 'test', 'tmp', 'stuff', 'changes'];
const MIN_SUBJECT_LENGTH = 10;

const commitMsgFile = process.argv[2];
const message = fs.readFileSync(commitMsgFile, 'utf8').trim();
const firstLine = message.split('\n')[0];

// Extract subject after "type(scope): " or "type: "
const match = firstLine.match(/^\w+(\([\w.-]+\))?!?:\s*(.+)$/);
const subject = match ? match[2].trim() : firstLine;

if (VAGUE_SUBJECTS.includes(subject.toLowerCase())) {
  console.error(`Commit rejected: "${subject}" is a placeholder message.`);
  console.error('Describe what changed and why, e.g. "fix(cart): prevent duplicate line items on retry".');
  process.exit(1);
}

if (subject.length < MIN_SUBJECT_LENGTH) {
  console.error(`Commit rejected: subject is too short (${subject.length} chars, minimum ${MIN_SUBJECT_LENGTH}).`);
  process.exit(1);
}

process.exit(0);

6. Der Notausgang: git commit --no-verify

Git bietet mit dem Flag --no-verify (kurz -n) einen bewussten Weg, alle client-seitigen Hooks für einen einzelnen Commit zu überspringen, inklusive pre-commit und commit-msg. Dieser Escape Hatch existiert aus gutem Grund: In echten Notfällen, etwa einem kritischen Produktions-Hotfix mitten in der Nacht, darf ein fehlerhaft konfigurierter Hook oder ein blockierender Linter nicht den gesamten Deployment-Prozess aufhalten. Auch für bewusste WIP-Commits auf einem privaten Feature-Branch, die vor dem Merge ohnehin per rebase -i zusammengefasst werden, ist --no-verify ein legitimes Werkzeug.

Problematisch wird es, wenn --no-verify vom Notausgang zur Gewohnheit wird. Jede Nachricht, die den Hook umgeht, kann die Kette brechen, auf der Changelog-Generierung und semantic-release aufbauen: Ein einzelner Commit ohne gültigen Typ reicht aus, damit ein automatisiertes Release-Tool ihn stillschweigend ignoriert oder, schlimmer, falsch einordnet. Die Historie wird zusätzlich schwerer überprüfbar, weil Reviewer nicht mehr am Diff allein erkennen können, ob ein Commit absichtlich unvalidiert blieb oder ob der Hook schlicht nie installiert wurde.

7. Changelog-Generierung und semantic-release

semantic-release und ähnliche Tools wie standard-version lesen die Commit-Historie seit dem letzten Release und leiten daraus die nächste Versionsnummer nach Semantic Versioning ab: ein fix:-Commit erhöht die Patch-Version, ein feat:-Commit die Minor-Version, und ein Commit mit BREAKING CHANGE: im Footer oder einem ! nach dem Typ erzwingt eine Major-Version. Aus denselben Commits generiert das Tool automatisch einen strukturierten CHANGELOG.md-Eintrag, gruppiert nach Typ, ganz ohne manuelle Pflege.

Diese Automatisierung funktioniert nur so zuverlässig wie die zugrunde liegenden Commit-Messages. Ein Commit mit der Nachricht update stuff wird von semantic-release schlicht ignoriert, weil kein bekannter Typ erkennbar ist, selbst wenn die Änderung inhaltlich ein Breaking Change war. Das Ergebnis ist ein Release, das eine wichtige Änderung verschweigt, oder im schlimmsten Fall eine Versionsnummer, die nicht zur tatsächlichen Tragweite der Änderung passt. Der commit-msg-Hook ist damit keine reine Stilfrage, sondern eine Voraussetzung dafür, dass Release-Automatisierung überhaupt korrekt funktioniert.

8. CI-Integration: commitlint auch serverseitig prüfen

Weil --no-verify lokale Hooks vollständig deaktiviert, kann sich niemand ausschließlich auf den lokalen commit-msg-Hook verlassen. Wer sicherstellen will, dass wirklich jeder gemergte Commit dem Format entspricht, ergänzt eine zweite Prüfebene in der CI-Pipeline, die unabhängig vom lokalen Setup jedes Entwicklers läuft. Eine GitHub-Actions-Action wie wagoid/commitlint-github-action oder ein einfacher commitlint --from origin/main --to HEAD-Aufruf validiert alle Commits eines Pull Requests, bevor er gemergt werden darf.

Diese CI-Prüfung ist bewusst als Ergänzung gedacht, nicht als Ersatz: Der lokale Hook gibt sofortiges Feedback beim Commit selbst, die CI-Prüfung schließt die Lücke für Fälle, in denen der Hook lokal fehlt, umgangen wurde oder schlicht nie installiert war. Wer noch strengere Garantien braucht, kann zusätzlich einen serverseitigen update- oder pre-receive-Hook auf dem Git-Server selbst einrichten, der Pushes mit ungültigen Commit-Messages von vornherein ablehnt. Das ist ein eigenständiges Thema mit eigener Betriebslogik und wird hier bewusst nicht vertieft.


# .github/workflows/commitlint.yml - validate all commits in a pull request
name: Lint commit messages
on:
  pull_request:

jobs:
  commitlint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: Validate current commit range
        run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose

9. commit-msg-Hooks im direkten Vergleich

Der Effekt eines konsequent durchgesetzten commit-msg-Hooks lässt sich am besten anhand konkreter Szenarien zeigen, in denen sich validierte und unvalidierte Commit-Historien unterscheiden.

Szenario Ohne commit-msg-Hook Mit commitlint + Husky Effekt
Commit-Message "fix" wird akzeptiert, keine Aussagekraft wird abgelehnt, type-enum fehlt Nachvollziehbare Historie
Changelog-Pflege manuell gepflegt, oft veraltet automatisch aus feat:/fix: generiert Kein manueller Pflegeaufwand
Versionierung manuell entschieden semantic-release bestimmt major/minor/patch Konsistentes SemVer
Code-Review der Historie einzelne Commits kaum verständlich type(scope): subject sofort lesbar Schnelleres Review
CI parst Commit-Typen bricht bei Freitext lautlos robust dank erzwungenem Format Keine stillen Fehler

Der gemeinsame Nenner aller Zeilen: Ohne erzwungenes Format bleibt die Interpretation der Historie Handarbeit, mit commitlint und Husky wird sie zu einem Nebenprodukt, das bei jedem Commit automatisch entsteht.

Mironsoft

Git-Workflow-Beratung, Hook-Automatisierung und CI/CD für PHP- und Magento-Teams

Commit-Messages, auf die sich eure Tools verlassen können?

Wir richten commitlint und Husky in eurem Repository ein, definieren projektspezifische Regeln gegen vage Nachrichten und verdrahten die Prüfung zusätzlich als CI-Gate, damit Changelog und Versionierung automatisiert und zuverlässig laufen.

Hook-Setup

commitlint, Husky und projektspezifische Validierungsregeln einrichten

CI-Integration

Commit-Validierung als Pull-Request-Gate in GitHub Actions oder GitLab CI

Release-Automatisierung

semantic-release und Changelog-Generierung aus sauberen Commit-Historien

10. Zusammenfassung

Der commit-msg-Hook für Commit-Message-Validierung löst ein Problem, das viele Teams erst spät bemerken: eine Commit-Historie voller fix und wip lässt sich weder für Code-Reviews noch für automatisierte Releases sinnvoll nutzen. commitlint mit @commitlint/config-conventional erzwingt das Conventional-Commits-Format über klare Regeln wie type-enum und header-max-length. Husky verdrahtet diese Prüfung über ein versioniertes .husky/commit-msg-Skript, sodass jeder Klon des Repositories automatisch dieselbe Validierung erhält, ohne manuellen Zusatzschritt.

Der Notausgang --no-verify bleibt notwendig für echte Notfälle, wird aber zum Risiko, sobald er zur Gewohnheit wird: Jeder umgangene Commit kann die Kette brechen, auf der Changelog-Generierung und semantic-release aufbauen. Eine zusätzliche CI-Prüfung schließt diese Lücke zuverlässig, unabhängig vom lokalen Setup jedes Entwicklers, und macht aus commit-msg-Validierung ein Sicherheitsnetz mit zwei Ebenen statt einer einzigen, umgehbaren Kontrolle.

commit-msg-Hooks für Commit-Message-Validierung, das Wichtigste auf einen Blick

Hook-Mechanik

Feuert nach dem Schreiben der Nachricht, vor dem Commit-Objekt. Erhält den Dateipfad als $1, Exit-Code entscheidet über Freigabe.

Conventional Commits

typ(scope): subject mit festen Typen wie feat, fix, chore. commitlint prüft gegen type-enum und Co.

Husky-Verdrahtung

.husky/commit-msg mit npx --no -- commitlint --edit "$1", aktiviert automatisch bei jedem Klon über prepare.

Bypass-Risiko

--no-verify für echte Notfälle, aber als Gewohnheit bricht es Changelog- und Release-Automatisierung.

11. FAQ: commit-msg-Hooks für Commit-Message-Validierung

1Was macht der commit-msg-Hook, im Unterschied zum pre-commit-Hook?
pre-commit prüft gestagte Änderungen vor der Nachricht, etwa mit Linting. commit-msg prüft die fertige Nachricht selbst, nachdem sie geschrieben wurde, aber vor dem Commit-Objekt.
2Wie installiere ich commitlint mit Husky?
@commitlint/cli, @commitlint/config-conventional und husky installieren, commitlint.config.js anlegen, npx husky init ausführen und in .husky/commit-msg den commitlint --edit "$1"-Aufruf hinterlegen.
3Was ist das Conventional-Commits-Format?
Ein Schema typ(scope): subject mit festen Typen wie feat, fix, docs, refactor. Macht Commits maschinenlesbar für Versionierung und Changelog-Tools.
4Warum lehnt commitlint meinen Commit ab?
Meist wegen einer verletzten Regel wie type-enum, subject-case oder header-max-length. Die Fehlermeldung nennt die konkrete verletzte Regel.
5Wie umgehe ich den Hook im Notfall mit --no-verify?
git commit --no-verify überspringt alle client-seitigen Hooks. Sinnvoll bei echten Notfällen, riskant als Gewohnheit wegen kaputter Release-Automatisierung.
6Kann der commit-msg-Hook komplett umgangen werden?
Ja, lokal jederzeit über --no-verify. Eine CI-Prüfung auf Pull-Request-Ebene oder ein serverseitiger Hook schließt diese Lücke zuverlässig.
7Funktioniert commit-msg mit git commit -m und im Editor?
Ja. In beiden Fällen landet die Nachricht zuerst in einer temporären Datei, und der Hook erhält deren Pfad als $1, unabhängig vom Eingabeweg.
8Wie hängt das mit semantic-release zusammen?
semantic-release leitet aus feat:, fix: und BREAKING CHANGE:-Markierungen automatisch Versionsnummer und Changelog ab. Ungültige Commits werden ignoriert.
9Was passiert bei git commit --amend?
Der Hook läuft erneut, da die geänderte Nachricht wieder validiert wird, bevor das überarbeitete Commit-Objekt entsteht.
10Welche commitlint-Regeln lassen sich anpassen?
Praktisch alle Regeln aus @commitlint/config-conventional, etwa type-enum, scope-enum, header-max-length und subject-case, einzeln im rules-Objekt.