Context
Without automated checks before commits, several quality issues slip through:
- Inconsistent Formatting: Code style diverges across commits, creating noisy diffs and merge conflicts
- Broken Builds in CI: Linting errors, type errors, or malformed files fail in CI after pushing, slowing down the feedback loop
- Documentation Drift: Markdown/MDX files with syntax errors or frontmatter issues break the site build
- Wasted CI Resources: Running full linting suites in CI for every commit when issues could be caught locally
- Context Switching Cost: Discovering issues post-push requires switching back to fix trivial problems
I need a pre-commit workflow that:
- Runs Fast: Only check changed files, not the entire codebase
- Integrates with Mise: Build on top of the task system established in ADR 004: Mise, not duplicate configuration
- Supports Multiple Languages: TypeScript, Terraform, Markdown, MDX, YAML across a monorepo
- Fails Early: Catch errors before they enter git history
- Stays Simple: Boring technology with deep LLM knowledge bases for AI-assisted development
Decision
I will use Husky for git hook management combined with lint-staged to run targeted checks on staged files.
Husky manages the .git/hooks/pre-commit script, delegating to a mise task that orchestrates lint-staged. The lint-staged configuration in package.json defines file patterns and their corresponding mise tasks, creating a clean delegation hierarchy:
Git commit → Husky → mise run //:pre-commit → lint-staged → mise lint tasks
Toolchain
Git Hook Management:
husky: Manages git hooks, runs.husky/pre-commitwhich callsmise run //:pre-commit
Staged File Processing:
lint-staged: Filters staged files by pattern and runs appropriate mise tasks
Linting Libraries:
markdownlint-cli: Lints Markdown files for syntax, style, and frontmatter consistencyremark-cliwith plugins: Lints and auto-fixes MDX filesremark-frontmatter: Parse MDX frontmatterremark-gfm: GitHub Flavored Markdown supportremark-lint: Linting foundationremark-mdx: MDX-specific syntax supportremark-preset-lint-recommended: Sensible defaults for Markdown best practicesyaml-lint: Validates YAML syntax and structure
Configuration
The lint-staged configuration in package.json maps file patterns to mise tasks:
{
"lint-staged": {
"ui/**/*.{ts,tsx,js,jsx,json}": ["mise run //ui:format"],
"infra/**/*.tf": ["mise run //infra:format", "mise run //infra:precommit-lint"],
"**/*.md": ["mise run //:lint:markdown"],
"**/*.mdx": ["mise run //:lint:mdx"],
"*.{yml,yaml}": ["mise run //:lint:yaml"]
}
}
This builds directly on top of mise tasks from ADR 004, avoiding duplication. The tasks in .mise.toml accept file arguments, allowing lint-staged to pass only staged files rather than running against the entire codebase.
Alternatives Considered
pre-commit.com (Python-based framework):
- Pros: Language-agnostic, extensive plugin ecosystem, declarative YAML configuration, manages hook installation automatically
- Cons: Introduces Python as a dependency for the entire repo (same monorepo-wide language dependency issue as Husky, just with Python instead of Node). Adds another configuration format (.pre-commit-config.yaml) instead of leveraging existing mise tasks. Less familiarity in the Node.js ecosystem where this project is primarily based.
- Why not chosen: Would create duplication between
.pre-commit-config.yamland mise tasks, violating the "single source of truth" principle. Python dependency is less natural for a repo with Node.js at the root.
Lefthook (Go-based alternative):
- Pros: Faster than Husky, language-agnostic, supports parallel execution, single binary with no runtime dependencies
- Cons: Less mature ecosystem, fewer npm downloads (~900K/week vs Husky's 18M/week), less LLM training data for AI-assisted development
- Why not chosen: Speed difference is negligible for this project's pre-commit time (2-5 seconds). The "boring technology" principle favors Husky's wider adoption and better LLM knowledge base.
Custom shell scripts in .git/hooks:
- Pros: Simple, no dependencies, direct control
- Cons: Not tracked in git by default, requires manual distribution to all contributors, no automatic installation, harder to maintain across team
- Why not chosen: Violates "low maintenance" requirement. Every contributor would need manual setup instructions.
CI-only checks (no local hooks):
- Pros: Simplest setup, no local tooling required, guaranteed consistency
- Cons: Slow feedback loop (minutes vs seconds), wastes CI resources, context switching cost to fix trivial issues post-push
- Why not chosen: Fails the "fast feedback loop" requirement. Particularly bad for AI-assisted development where Claude Code can make many small commits.
git-hooks via core.hooksPath:
- Pros: Git-native configuration, no third-party tools
- Cons: Still requires manual setup per developer, hooks tracked in a separate directory, no automatic installation on clone
- Why not chosen: Same distribution problem as custom shell scripts.
Consequences
Positive:
- Fast Feedback Loop: Linters run in seconds on staged files instead of minutes on the entire codebase. Typical pre-commit time: 2-5 seconds vs 30+ seconds for full repo checks.
- Fails Before CI: Catches formatting, syntax, and linting errors before they enter git history. CI becomes a verification layer, not the first line of defense.
- Single Source of Truth: Lint tasks are defined once in
.mise.tomland reused by both pre-commit hooks and CI. No duplication between local checks and GitHub Actions. - Monorepo Awareness: Supports workspace-specific tasks (
//ui:format,//infra:format) while maintaining repo-wide consistency for shared file types (Markdown, YAML). - Low Maintenance: Husky auto-installs hooks via the
preparescript onpnpm install. No manual setup or git hook configuration required. - Boring Technology: Husky is the industry standard, with excellent documentation and deep LLM training data. Works seamlessly with the LLM-optimized development workflow.
- Selective Execution: lint-staged only processes files that are staged, reducing noise and focusing checks on what's actually changing.
- Claude Code Guardrails: Prevents Claude Code from drifting too far and committing malformed code. Pre-commit hooks act as an immediate correction signal, keeping the AI agent aligned with project standards without requiring constant human oversight.
Negative:
- Commit Latency: Adds 2-5 seconds to every commit. For trivial changes (typo fixes, documentation tweaks), this can feel like friction. Mitigation:
git commit --no-verifyto bypass when necessary. - Setup Complexity: Requires three tools working together (Husky, lint-staged, mise) rather than simple shell scripts. The abstraction is justified by the flexibility and speed gains, but adds cognitive overhead for new contributors.
- Silent Failures: If Husky or mise aren't installed (e.g., someone clones without running
pnpm install), hooks silently skip. CI catches this eventually, but the local safety net is gone. - Merge Conflicts in Hooks: Husky hooks are tracked in git (
.husky/directory). During merges or rebases, these files can conflict, requiring manual resolution. Rare but annoying when it happens. - Monorepo-Wide Node.js Dependency: Husky and lint-staged live in the root
package.json, requiring Node.js setup even for contributors working exclusively on non-Node parts of the monorepo. A developer making Terraform-only changes or contributing to a hypothetical Python service still needs to install Node.js and runpnpm installat the root to get working git hooks. This is mitigated by mise managing Node at the root level (simplifying setup to justmise install), but it still means additional tooling overhead and apackage.jsonat the repo root for what is fundamentally a multi-language monorepo. If this project ever migrates away from Node entirely, hooks will need reimplementation in a language-agnostic tool.