--- status: accepted date: 2026-04-29 decision-makers: R&D Lead tags: [process] --- # Pre-commit hooks and Conventional Commits ## Context and Problem Statement Without local enforcement, trivial issues — unformatted code, lint errors, ill-formed commit messages — only surface in CI. Each round-trip costs minutes of feedback latency and pollutes the commit history with fixup noise. We need a lightweight, well-known mechanism that catches these issues at commit time and enforces a consistent commit message format, while staying fast enough not to discourage frequent commits. Which tooling do we adopt for git pre-commit checks and commit message validation? ## Decision Drivers * Fast feedback: catch trivial issues locally; CI is defense in depth, not the first line. * Consistency of code style and commit history across contributors. * Conventional commit history that machines can read (later automation: changelog, release notes, semver bumps). * Mainstream tooling — no exotic shell hooks, no single-maintainer projects. * Easy onboarding: no extra runtime to manage beyond Node/pnpm. ## Considered Options * **Husky + lint-staged + commitlint with Conventional Commits.** (Chosen.) * No git hooks — rely on CI. * `pre-commit` (Python framework). * `lefthook` (Go single-binary hook manager). * Custom shell scripts in `.git/hooks/`. ## Decision Outcome Chosen option: **Husky + lint-staged + commitlint, with [Conventional Commits](https://www.conventionalcommits.org/) as the commit-message specification**. Hooks installed under `.husky/`: | Hook | Action | | --- | --- | | `pre-commit` | `pnpm exec lint-staged` — runs lint and format on staged files only | | `commit-msg` | `pnpm exec commitlint --edit "$1"` — validates the commit message against Conventional Commits | Configuration: - `package.json`: `"prepare": "husky"` script (Husky 9+ pattern), `lint-staged` config block. - `commitlint.config.cjs`: `module.exports = { extends: ['@commitlint/config-conventional'] }`. Scope of `lint-staged`: only fast checks on staged files (lint, format). Type-check and tests stay in `pnpm nx affected -t lint test build` in CI — running the full graph on every commit would slow commits unacceptably and discourage frequent commits. CI re-runs the same checks (defense in depth — hooks can be bypassed with `--no-verify`). ### Consequences * Good, because trivial issues are caught locally and don't pollute PR history. * Good, because Conventional Commits gives us a machine-readable history — enables automated changelogs, semver inference, and release tooling without retroactive cleanup. * Good, because Husky / lint-staged / commitlint are the de facto standard in JS/TS projects — wide community, abundant documentation, low surprise factor. * Good, because lint-staged keeps commit time low (only changed files are checked). * Bad, because hooks can be bypassed (`git commit --no-verify`); CI must remain authoritative. * Bad, because Husky 9 changed its installation pattern (no `husky install`, just `husky`); contributors with stale instructions can be confused. Mitigated by the setup guide. * Bad, because Conventional Commits adds a small learning curve; mitigated by IDE plugins, `commitlint` error messages, and a one-page contributor cheatsheet (future doc). ### Confirmation * `package.json` declares `husky`, `lint-staged`, `@commitlint/cli`, `@commitlint/config-conventional` as `devDependencies`. * `package.json` has a `"prepare": "husky"` script. * `.husky/pre-commit` runs `pnpm exec lint-staged` and is executable. * `.husky/commit-msg` runs `pnpm exec commitlint --edit "$1"` and is executable. * `commitlint.config.cjs` exists at the workspace root and extends `@commitlint/config-conventional`. * CI pipeline (future ADR) re-runs lint and format checks on every push, plus a Conventional Commits validation on the PR commit range. ## Pros and Cons of the Options ### Husky + lint-staged + commitlint (chosen) * Good, because the trio is the de facto standard in the JS/TS ecosystem. * Good, because each tool is single-purpose and composable. * Good, because the configuration lives with the repo and is versioned with the code. * Bad, because three packages instead of one — slightly more dependency surface. ### No git hooks * Good, because zero local setup. * Bad, because every trivial issue costs a CI round-trip. Wasteful and noisy. ### `pre-commit` (Python) * Good, because language-agnostic, used in mixed-language repos (Python + JS + Go). * Bad, because adds a Python runtime dependency to a Node-only project — extra setup for contributors, especially under WSL where Python toolchains are not always uniform. ### lefthook * Good, because single Go binary, very fast, parallel hook execution, declarative YAML config. * Bad, because smaller community than Husky in JS/TS land; less prior art and fewer answers when something breaks. * Status: kept on the watch list for re-evaluation if commit performance becomes a pain. ### Custom shell scripts in `.git/hooks/` * Good, because zero dependency. * Bad, because hooks aren't versioned with the repo by default; a wrapper layer is needed anyway. Bricolage. ## More Information * Husky: https://typicode.github.io/husky/ * lint-staged: https://github.com/lint-staged/lint-staged * commitlint: https://commitlint.js.org/ * Conventional Commits: https://www.conventionalcommits.org/ * Related: future quality / CI ADR will re-run these checks as defense in depth.