Stop Letting CI Catch Your Linting Errors
Pre-commit hooks are your first line of defence against sloppy code shipping into a PR. Linting errors. Forgotten console.logs. Invalid JSON. TypeScript type failures. All of these can be caught *before* you push, before a CI run spins up, before the inevitable "fix lint" commit that clogs your history.
Most teams either skip pre-commit hooks entirely (because they're annoying to maintain) or use Husky — which works, but has a notable limitation: it's Node-based, which means every hook invocation bootstraps Node, requiring maybe 300-500ms of overhead before the actual linting even starts. On a machine with 20+ repos, that overhead compounds quickly.
Velocity X uses Lefthook instead. It's Go-based, runs in parallel, and has virtually zero bootstrap time. The same lint + test suite that might take 6-8 seconds in Husky finishes in 2-3 seconds with Lefthook.
Here's why Lefthook matters and how to implement it.
Why pre-commit hooks matter
Pre-commit hooks are scripts that run automatically *after* you stage files (git add) but *before* git actually creates the commit. They can inspect staged files, run linters, execute unit tests, validate JSON configs, or do literally anything you want. If the hook exits with a non-zero status, the commit is rejected. You fix the issue and try again.
This prevents entire categories of bugs from ever reaching your remote. No linting failures in CI. No type errors discovered during review. No broken JSON in your config files. These issues surface immediately, on your machine, while you're still in the development mindset.
The catch: if your pre-commit hooks are slow, developers will disable them or commit with --no-verify. That defeats the purpose entirely.
Husky's bootstrap overhead
Husky is the Node.js pre-commit hook standard. It works by creating .husky/pre-commit scripts that source environment variables and invoke Node-based tools. That sourcing and Node bootstrap typically takes 300-500ms per hook invocation — even if the hook itself finishes instantly. On machines with multiple repos, or during rapid iteration cycles, that overhead adds up.
More importantly, Husky hooks run *sequentially*. Lint first, then tests, then validation. If linting takes 2 seconds and tests take 3 seconds, the total pre-commit time is 5 seconds. Every commit cycle.
Lefthook: Go-based, parallel, instant
Lefthook is a Go binary that executes hooks defined in a simple YAML file. No Node bootstrap. No sequential execution unless you explicitly configure it that way. By default, Lefthook runs all tasks *in parallel*, which means a 2-second lint and 3-second test suite can often complete in roughly 3 seconds total (the duration of the longest task).
For Velocity X, the pre-commit hook runs:
1. **eslint** on staged JavaScript/TypeScript files
2. **prettier** (format check, not auto-format) on all staged files
3. **vitest** for unit tests in affected paths
4. **JSON validation** on any modified .json files
All of these run in parallel unless one fails. Total execution time: 2-3 seconds. With Husky, the same suite took 6-8 seconds.
Lefthook setup: lefthook.yml
Start by installing Lefthook:
```bash
npm install -D lefthook
npx lefthook install
```
Then create a lefthook.yml in your project root:
```yaml
commit-msg:
parallel: false
commands:
lint-commit-msg:
glob: ""
run: npx commitlint --edit $1
pre-commit:
parallel: true
commands:
eslint:
glob: "src/**/*.{ts,tsx,js,jsx}"
run: npx eslint {staged_files} --fix
prettier:
glob: "src/**/*"
run: npx prettier --check {staged_files}
vitest:
glob: "src/**/*.test.{ts,tsx}"
run: npx vitest run --include {staged_files}
validate-json:
glob: "*.json"
run: node -e "fs.readFileSync('$FILE', 'utf8') && JSON.parse(fs.readFileSync('$FILE', 'utf8'))" || exit 1
```
The key differences from Husky: parallel: true means all hooks run concurrently. glob patterns select which files trigger each hook. {staged_files} is Lefthook's placeholder for the staged file list. If any command exits non-zero, the commit is rejected.
Lint-staged integration
Lefthook works well with lint-staged, a utility that filters a glob down to only *staged* files (not all files matching a pattern). You can either invoke lint-staged inside a Lefthook command or configure Lefthook directly with glob patterns. For Velocity X, we invoke Lefthook directly because glob + staged_files gives us fine-grained control.
If you prefer lint-staged's file filtering, configure it in package.json and invoke it from Lefthook:
```json
{
"lint-staged": {
"src/**/*.{ts,tsx}": ["eslint --fix", "vitest run --include"]
}
}
```
Then in lefthook.yml:
```yaml
pre-commit:
commands:
lint-staged:
run: npx lint-staged
```
Vitest integration for affected paths
Vitest has a --include flag that runs only the tests matching a pattern. Combining this with Lefthook's {staged_files} lets you run *only the tests relevant to the files you changed*, rather than the entire test suite. This is a massive time saver.
If you changed src/utils/format.ts, the pre-commit hook runs only src/utils/format.test.ts (or whatever matches your test file naming convention). The full test suite runs in CI later; the pre-commit hook focuses on the delta.
Six FAQs
**Q: Will this slow down my commit loop?**
No. Lefthook is designed for speed. Parallel execution means most developers commit in 2-3 seconds. Sequentially, the same hooks might take 6-8 seconds. Over 100 commits a week, that's 8-10 minutes saved per developer.
**Q: Can I skip the pre-commit hook?**
Yes: git commit --no-verify. But we recommend against it — the hook exists for a reason. If you skip it and push, CI will catch the error anyway, leading to a revert and an extra commit. Total time cost is higher.
**Q: What if the hook fails on a legitimate edge case?**
Use --no-verify, fix the edge case in your hook config, then commit normally. Add a comment explaining why the edge case needed special handling.
**Q: Does Lefthook work on macOS and Linux?**
Yes. Lefthook is cross-platform. Windows is officially supported but less commonly tested in our stack.
**Q: How do I configure different hooks for different projects?**
Each project gets its own lefthook.yml. The Lefthook config is not inherited — every repo defines its own rules. This is a feature, not a bug. Different projects have different standards.
**Q: Can I run Lefthook manually outside of commit?**
Yes: npx lefthook run pre-commit. Useful for testing hook config or running hooks on demand without committing.
Bottom line
Pre-commit hooks are the cheapest insurance against sloppy code. When they're fast (Lefthook), developers actually use them. When they're slow (Husky with bootstrap overhead), they get disabled, and you're back to catching bugs during CI. Velocity X standardises on Lefthook for exactly this reason: parallel execution, zero overhead, and peace of mind that linting, formatting, and unit tests pass before code ever reaches the remote.
Set it up once. Forget about it. The bugs it prevents are worth far more than the five minutes of configuration.