Git hooks run on every developer’s machine before commits are pushed. Without standardization, hooks exist in some team members’ repos and not others, leading to inconsistent code quality, broken CI pipelines, and merge conflicts from formatting differences. Remote teams feel this more acutely — there’s no over-the-shoulder “hey, you’re missing the linter” moment.
This guide covers three hook managers for different stack profiles: Husky (JavaScript teams), pre-commit (polyglot teams), and Lefthook (performance-sensitive workflows).
The Problem with .git/hooks
The .git/hooks/ directory is not tracked by version control. Each developer must manually copy or symlink hooks. That never happens consistently. The solution is to version hook configuration in the repo itself, then auto-install on git clone or initial setup.
Husky (JavaScript/Node Teams)
Husky is the standard for JavaScript projects. It integrates with npm’s prepare lifecycle so hooks install automatically after npm install.
npm install --save-dev husky
npx husky init
This creates .husky/ directory (tracked) and adds "prepare": "husky" to package.json.
Add a pre-commit hook:
# .husky/pre-commit
#!/bin/sh
npx lint-staged
Configure lint-staged in package.json to only run tools on staged files (much faster than running on all files):
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss}": [
"prettier --write"
],
"*.{json,md,yaml,yml}": [
"prettier --write"
]
}
}
Add a commit-msg hook for conventional commits:
# Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
# .husky/commit-msg
#!/bin/sh
npx --no -- commitlint --edit $1
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'chore', 'style', 'refactor', 'ci', 'test', 'perf', 'revert'],
],
'subject-max-length': [2, 'always', 100],
'scope-case': [2, 'always', 'lower-case'],
},
};
Add a pre-push hook to run tests before pushing to main:
# .husky/pre-push
#!/bin/sh
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "staging" ]; then
echo "Running full test suite before push to $BRANCH..."
npm test -- --passWithNoTests
fi
pre-commit (Polyglot Teams)
pre-commit is Python-based but works across any language. It downloads and manages hook tools in isolated environments, so hooks run identically regardless of what’s installed locally.
Install:
pip install pre-commit
# or
brew install pre-commit
Define hooks in .pre-commit-config.yaml:
# .pre-commit-config.yaml
repos:
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
- id: check-large-files
args: ['--maxkb=1024']
- id: mixed-line-ending
args: ['--fix=lf']
# Python
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
# Go
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-unit-tests
# Terraform
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.90.0
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
# Secrets detection
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
# Dockerfile
- repo: https://github.com/hadolint/hadolint
rev: v2.12.0
hooks:
- id: hadolint-docker
Install for all developers:
pre-commit install
pre-commit install --hook-type commit-msg
Run manually against all files:
pre-commit run --all-files
Auto-update hooks monthly:
pre-commit autoupdate
Add to CI to enforce hooks even if a developer bypasses locally:
# .github/workflows/pre-commit.yml
name: Pre-commit checks
on: [push, pull_request]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: pre-commit/action@v3.0.1
Lefthook (Fast, Multi-language)
Lefthook is a Go binary with no runtime dependencies. It’s significantly faster than Husky or pre-commit because it runs hooks in parallel.
Install:
# macOS
brew install lefthook
# npm (for JS monorepos)
npm install --save-dev lefthook
# Direct download
curl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.deb.sh' | sudo bash
apt install lefthook
Configure in lefthook.yml:
# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,jsx,tsx}"
run: npx eslint --fix {staged_files}
stage_fixed: true
format:
glob: "*.{js,ts,jsx,tsx,json,css,md}"
run: npx prettier --write {staged_files}
stage_fixed: true
go-fmt:
glob: "*.go"
run: gofmt -w {staged_files}
stage_fixed: true
go-vet:
glob: "*.go"
run: go vet ./...
secrets:
run: detect-secrets scan --baseline .secrets.baseline {staged_files}
pre-push:
commands:
tests:
run: go test ./...
build:
run: go build ./...
commit-msg:
commands:
commitlint:
run: npx commitlint --edit {1}
Install hooks:
lefthook install
Lefthook runs lint, format, go-fmt, go-vet, and secrets checks in parallel. On a modern laptop this completes in under 2 seconds for most changesets.
Skip hooks when needed (rare, break-glass):
LEFTHOOK=0 git commit -m "wip: debug session"
Distributing Hooks in a Monorepo
For monorepos with multiple packages, scope hooks to the affected package:
# lefthook.yml (monorepo root)
pre-commit:
commands:
frontend-lint:
root: "packages/frontend/"
glob: "*.{ts,tsx}"
run: npx eslint --fix {staged_files}
stage_fixed: true
backend-vet:
root: "services/api/"
glob: "*.go"
run: go vet ./...
infra-validate:
root: "infra/terraform/"
glob: "*.tf"
run: terraform validate
Enforce via Makefile
Create a make setup target so new team members get hooks with one command:
# Makefile
.PHONY: setup hooks
setup: hooks
@echo "Development environment ready"
hooks:
@which pre-commit > /dev/null || pip install pre-commit
pre-commit install
pre-commit install --hook-type commit-msg
@echo "Git hooks installed"
# Allow skipping hooks in CI (hooks run separately)
ci-check:
pre-commit run --all-files
Document this in your onboarding runbook:
# New developer setup
git clone git@github.com:your-org/your-repo.git
cd your-repo
make setup
Related Reading
- Best Tools for Remote Team Code Ownership
- Remote Team Code Review Checklist Template
- How to Create a Remote Dev Environment Template
- Best Knowledge Base Tool for Remote Team That Works Offline