# ADR 014: Husky Precommit

- HTML version: https://robbiepalmer.me/projects/personal-site/adrs/014-husky-precommit
- Project: Personal Site (https://robbiepalmer.me/projects/personal-site.md)
- Status: Accepted
- Date: 2025-10-19

# 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](/projects/personal-site/adrs/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-commit` which calls `mise 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 consistency
* `remark-cli` with plugins: Lints and auto-fixes MDX files
* `remark-frontmatter`: Parse MDX frontmatter
* `remark-gfm`: GitHub Flavored Markdown support
* `remark-lint`: Linting foundation
* `remark-mdx`: MDX-specific syntax support
* `remark-preset-lint-recommended`: Sensible defaults for Markdown best practices
* `yaml-lint`: Validates YAML syntax and structure

## Configuration

The `lint-staged` configuration in `package.json` maps file patterns to mise tasks:

```json
{
  "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](https://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.yaml` and mise tasks, violating the "single source of truth" principle. Python dependency is less natural for a repo with Node.js at the root.

**[Lefthook](https://github.com/evilmartians/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:**

* **[Short Feedback Loops](/projects?tab=philosophy#short-feedback-loops)**: 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.toml` and 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 `prepare` script on `pnpm 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](/projects/personal-site/adrs/012-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-verify` to 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 run `pnpm install` at the root to get working git hooks. This is mitigated by mise managing Node at the root level (simplifying setup to just `mise install`), but it still means additional tooling overhead and a `package.json` at 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.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
