Trovella Wiki

Pre-commit Hooks

The Husky + lint-staged pipeline -- how staged files are grouped by package, the custom ESLint wrapper script, and what happens on each commit.

Every git commit triggers a pre-commit hook that runs ESLint and Prettier on staged files. This catches formatting and lint errors before they reach CI, keeping the feedback loop under a few seconds for typical commits.

Tool Chain

Three tools work together:

ToolRoleConfig location
HuskyGit hook manager -- installs the pre-commit hook into .git/hooks/.husky/pre-commit, package.json ("prepare": "husky")
lint-stagedRuns commands only on staged files.lintstagedrc.mjs
Custom ESLint wrapperRuns ESLint from the correct package directoryscripts/lint-staged-eslint.mjs

Setup

Husky installs automatically when you run pnpm install, via the prepare script in the root package.json:

{
  "scripts": {
    "prepare": "husky"
  }
}

This creates .git/hooks/pre-commit, which runs:

pnpm exec lint-staged

No manual setup is needed after cloning the repo and running pnpm install.

What Happens on Each Commit

When you run git commit, lint-staged reads .lintstagedrc.mjs and processes staged files through two pipelines based on file extension.

Pipeline 1: TypeScript files (*.{ts,tsx})

  1. Group by package. The groupByPackage() function in .lintstagedrc.mjs maps each staged file to its nearest package directory (apps/* or packages/*). Files outside these directories are skipped.

  2. Run ESLint per package. For each package group, lint-staged runs:

    node scripts/lint-staged-eslint.mjs "<pkgDir>" "<file1>" "<file2>" ...

    The wrapper script runs pnpm exec eslint --fix with cwd set to the package directory. This ensures ESLint finds the correct eslint.config.js and tsconfig.json for that package.

  3. Run Prettier. After all ESLint commands, Prettier runs with --write on all staged TypeScript files. This formats any code that ESLint's --fix modified.

Pipeline 2: Other files (*.{js,jsx,mjs,cjs,json,md,mdx,yml,yaml,css})

Prettier runs with --write directly. No ESLint pass is needed for these file types.

Execution Order

Both pipelines are defined in the same lint-staged config. Within Pipeline 1, the ESLint commands and the Prettier command are returned as an array. Lint-staged runs them sequentially:

ESLint (package A) -> ESLint (package B) -> ... -> Prettier (all TS files)

If any command fails (non-zero exit), the commit is aborted and the remaining commands do not run.

The ESLint Wrapper Script

ESLint needs to run from each package's directory because parserOptions.projectService resolves the tsconfig.json relative to the working directory. Running ESLint from the repo root would cause it to use the wrong type information.

The wrapper script (scripts/lint-staged-eslint.mjs) is minimal:

import { execSync } from "node:child_process";

const [pkgDir, ...files] = process.argv.slice(2);

if (!pkgDir || files.length === 0) {
  process.exit(0);
}

const fileArgs = files.map((f) => `"${f}"`).join(" ");

execSync(`pnpm exec eslint --fix ${fileArgs}`, {
  cwd: pkgDir,
  stdio: "inherit",
});

Key behaviors:

  • cwd: pkgDir -- ESLint runs from the package directory, finding the correct eslint.config.js
  • --fix -- auto-fixes are applied (import sorting, type imports, etc.) and staged
  • stdio: "inherit" -- ESLint output streams to the terminal so you see any unfixable errors
  • Empty file list -- exits cleanly with code 0 if no files were passed

The groupByPackage Function

The .lintstagedrc.mjs config groups files by their package to avoid running ESLint once per file:

function groupByPackage(files) {
  const byPackage = new Map();
  for (const file of files) {
    const rel = path.relative(root, file).replace(/\\/g, "/");
    const parts = rel.split("/");
    let pkgDir;
    if ((parts[0] === "apps" || parts[0] === "packages") && parts.length > 2) {
      pkgDir = path.join(root, parts[0], parts[1]);
    }
    if (pkgDir) {
      if (!byPackage.has(pkgDir)) byPackage.set(pkgDir, []);
      byPackage.get(pkgDir).push(file);
    }
  }
  return byPackage;
}

Files are grouped by their apps/<name> or packages/<name> prefix. If a file does not live under apps/ or packages/ (e.g., a root-level config file), it is excluded from the ESLint pass. Root-level files are still formatted by Prettier.

Common Scenarios

Commit is blocked by a lint error

The terminal shows the ESLint error. Fix the issue, git add the fix, and commit again. The --fix flag handles auto-fixable issues (import sorting, type imports, trailing commas), but some errors require manual intervention (unused variables, type errors, naming convention violations).

Commit includes files from multiple packages

Each package group gets its own ESLint invocation. A commit touching packages/db/src/schema.ts and apps/web/src/page.tsx runs ESLint twice: once from packages/db/ and once from apps/web/.

Commit includes only non-TypeScript files

Only Pipeline 2 runs (Prettier). The ESLint pass is skipped entirely because groupByPackage returns an empty map.

Bypassing hooks in an emergency

git commit --no-verify -m "emergency: description"

This skips the pre-commit hook entirely. CI will still enforce all quality gates, so this only defers the check -- it does not skip it permanently. Use sparingly.

Relationship to CI

The pre-commit hook is a fast, partial check. CI runs the full quality gate suite including:

  • format:check (Prettier, check-only mode on all files)
  • lint (ESLint on all affected packages, not just staged files)
  • typecheck (TypeScript compiler, which pre-commit hooks do not run)
  • Static analysis tools (dep-cruise, Knip, jscpd)

The pre-commit hook catches the most common issues (formatting, import order, obvious lint errors) before you push. CI catches everything else.

On this page