← Back to writingEngineering / 2026.10

Pre-Commit Hooks: Catching Problems Before They Ever Reach CI

I was on a call once, screen shared, walking a newer teammate through why his commit had been sitting for two minutes without finishing. He hadn't done anything wrong. He'd just typed a normal git commit, ticket key in the message like the convention required, and then watched his terminal do nothing visible while, underneath, a full test suite and a full production build ran on his machine before Git would let the commit land. He asked, reasonably, "is it stuck?" It wasn't stuck. It was just thorough, and thorough and fast are not the same thing, which is the whole tension this article is about.

what a pre-commit hook is actually for

By the time code reaches CI, it's already shared. A pipeline failing on a pushed branch means a red check next to your name, maybe a Slack message, maybe a teammate who pulled your branch and now has a broken build too. A pre-commit hook runs before any of that, on your own machine, against your own working tree, before the commit object even exists. Its whole value is catching the same class of problem earlier and cheaper, while the fix is still one keystroke away instead of a later commit and an apology.

That's a genuinely good idea. It's also one that's easy to ruin by piling on checks until the hook takes longer than the thing it's protecting against, at which point people start reaching for --no-verify and the guardrail stops guarding anything. I've worked on two real pre-commit setups that landed on opposite ends of that tradeoff, and neither one is simply right or wrong. They're answers to different questions.

company A: heavy, and honest about it

At one company, a frontend for a review platform built as an Nx monorepo, the .husky/pre-commit hook ran, in strict order: lint-staged formatting staged files with Prettier, a full nx run-many --target=lint across the entire monorepo rather than just the touched projects, a separate type aware ESLint pass scoped to staged .ts/.tsx files (a heavier, typed config kept apart from the fast one on purpose), the same typed pass again for staged e2e specs, stylelint on any staged .scss, the full Vitest suite rather than just tests near the diff, a full production build, and a check for code duplication via jscpd. A separate commit-msg hook ran commitlint on top of all that, enforcing a custom rule that every commit scope contain a real ticket key from the team's tracker, or an explicit na-0 escape hatch for the rare commit that genuinely had no ticket behind it.

Format, lint, typed lint, stylelint, the full test suite, a full build, and a duplication gate, every single time, before Git would even let you finish typing a commit message. That's heavy by any measure, and I won't pretend it always felt good waiting on it. But it bought something real: nothing broken reached even a local commit, let alone a shared branch, and CI on that project almost never caught something a developer hadn't already seen fail on their own machine first. The team accepted the tax on commit speed because the alternative, a red pipeline discovered ten minutes after pushing, cost more of everyone's time than it saved.

company B: fewer checks, more careful ones

At a different company, a travel site built on Nuxt that served multiple tenants, the team deliberately skipped Husky as a dependency. Instead, a prepare script in package.json ran git config core.hooksPath .githooks, pointing Git straight at a hooks folder committed to the repo. Its .githooks/pre-commit enforced the correct Node version through nvm, ran a full ESLint pass, ran type aware ESLint scoped only to staged .ts/.vue files, ran the full Vitest suite, ran a full build, and then did something the first setup didn't: two separate, narrower duplication checks instead of one broad one.

The first was a regression guard watching two specific, fixed test files that had, in the project's own history, drifted into being near duplicates of each other without anyone intending that. The second was a jscpd check limited to staged files only, with a 2.5% duplication threshold, deliberately matched to that project's own SonarQube quality gate so the local check and the one running on the server agreed with each other instead of arguing.

the check that looked smart and wasn't

Here's the part worth sitting with. That second team used to run jscpd across the whole repo in its "baseline diff" mode, comparing the current tree against a stored baseline to catch newly introduced duplication anywhere in the codebase. It sounded like the more thorough option, scanning everything instead of just what you'd staged. In practice it was flaky in a way that took a while to notice, because jscpd's baseline comparison turned out to be sensitive to the size of the whole corpus being scanned, not purely to content. Add a handful of unrelated files anywhere in the repo, or delete some, and the tool's internal clone detection bookkeeping could shift just enough that it started flagging completely untouched, unrelated files as "new" duplication on a commit that never went near them.

That happened more than once, and each time it happened, the fix someone reached for was to skip the check rather than trust it, which is exactly the failure mode a pre-commit hook is supposed to prevent. The team's actual response was better than that: they dropped the broad check that was sensitive to corpus size entirely and replaced it with the two narrower, deterministic ones described above, a known historical hotspot watched directly, plus a duplication threshold checked only against what was actually staged. Neither one can drift out from under you because neither one depends on anything outside the files you're touching. A guardrail that occasionally accuses innocent files of a crime is worse than no guardrail, because it teaches people to distrust every red result it ever gives them, including the true ones.

the gap nobody enforced

One more contrast stuck with me. Company A enforced its convention requiring a Jira ticket key in commit messages with actual tooling, commitlint rejected a commit outright if the scope didn't match. Company B followed the same ticket key convention, by team agreement, in documentation, with nothing checking it. In two years I don't remember that gap causing a real problem there, people mostly did it because it was clearly useful, not because a hook made them. But it's a real gap all the same, a norm surviving purely on habit rather than on anything that would catch a tired Friday afternoon commit. Whether that's an acceptable risk or a quiet time bomb probably depends on how much the team's memory can be trusted to outlast the people who set the norm in the first place.

what I took from having both

A pre-commit hook only stays useful for as long as people don't route around it, and people route around anything that's slow or wrong often enough. Company A's setup was heavy but earned its keep by being right every time; Company B's setup was lighter partly because it had already been burned by a check that was clever but occasionally wrong, and it responded by making the guardrail smaller and more honest instead of just louder. The lesson I keep coming back to isn't "run more checks" or "run fewer checks." It's that every local check has to earn its place by catching real problems reliably, because the moment developers start typing --no-verify out of habit, you don't have a guardrail anymore. You have a suggestion.

Tech used in this article

  • Node.js
  • Nx