Git Hooks for Development Automation
How to use Git hooks to automate quality checks, commit message validation, pre-push tests and branch naming — with Husky and custom scripts.

Git hooks are scripts that run automatically at specific points in the Git workflow — before a commit, before a push, after a merge. They are the enforcement layer between "we agreed to lint our code" and "our code is actually linted." Without hooks, code quality checks depend on developer discipline. With hooks, they are automatic.
The most valuable hooks are pre-commit (validate code before committing), commit-msg (enforce commit message format), and pre-push (run tests before pushing).
Setting Up Hooks with Husky
Git hooks live in .git/hooks/, which is not tracked by version control. This means you cannot share hooks through the repository. Husky solves this by adding hooks to a .husky/ directory that is committed to the repo.
# Install Husky
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory)
npx husky init
# This creates .husky/pre-commit with a default script
# and adds "prepare": "husky" to package.json// package.json
{
"scripts": {
"prepare": "husky",
"lint": "eslint . --ext .ts,.tsx",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"husky": "^9.0.0",
"lint-staged": "^15.0.0"
}
}Pre-Commit Hook: Lint Staged Files
The pre-commit hook runs before every commit. The key optimization is lint-staged — instead of linting the entire codebase, only lint the files that are being committed.
# .husky/pre-commit
npx lint-staged// lint-staged.config.ts
const config = {
// TypeScript/JavaScript files: lint and format
'*.{ts,tsx}': [
'eslint --fix --max-warnings 0',
'prettier --write',
],
// CSS/SCSS files: format only
'*.{css,scss}': [
'prettier --write',
],
// JSON/YAML files: format
'*.{json,yaml,yml}': [
'prettier --write',
],
// Markdown files: format
'*.md': [
'prettier --write',
],
};
export default config;# ❌ Linting the entire codebase on every commit
# .husky/pre-commit
npm run lint # Takes 30 seconds on a large codebase
npm run format:check # Another 15 seconds
npm run typecheck # Another 20 seconds
# Developers disable the hook because it's too slow
# ✅ Only check the files being committed
# .husky/pre-commit
npx lint-staged # Takes 2-3 seconds — only changed files
# Fast enough that developers never disable itCommit Message Hook: Enforce Conventional Commits
The commit-msg hook validates the commit message format. Conventional commits (feat:, fix:, chore:, etc.) enable automated changelog generation and semantic versioning.
# .husky/commit-msg
npx --no -- commitlint --edit $1// commitlint.config.ts
const config = {
extends: ['@commitlint/config-conventional'],
rules: {
// Type must be one of these
'type-enum': [
2, // Error level
'always',
[
'feat', // New feature
'fix', // Bug fix
'docs', // Documentation only
'style', // Formatting (no code change)
'refactor', // Code change that neither fixes nor adds
'perf', // Performance improvement
'test', // Adding or updating tests
'chore', // Build process or auxiliary tools
'ci', // CI configuration
'revert', // Revert a previous commit
],
],
// Subject must be lowercase
'subject-case': [2, 'always', 'lower-case'],
// Subject must not be empty
'subject-empty': [2, 'never'],
// Subject must not end with period
'subject-full-stop': [2, 'never', '.'],
// Max line length for the header
'header-max-length': [2, 'always', 100],
},
};
export default config;# ❌ Bad commit messages that commit-msg hook rejects
git commit -m "fixed stuff" # No type prefix
git commit -m "feat: Updated the login." # Period at end, uppercase
git commit -m "wip" # No type, not descriptive
# ✅ Good commit messages that pass validation
git commit -m "fix: resolve race condition in payment webhook handler"
git commit -m "feat: add email notification preferences to user settings"
git commit -m "refactor: extract validation logic into shared utility"
git commit -m "docs: add API authentication examples to README"Pre-Push Hook: Run Tests Before Pushing
The pre-push hook runs before code is pushed to the remote. This is the place for heavier checks that would be too slow for pre-commit — type checking and test suites.
# .husky/pre-push
echo "Running type check..."
npx tsc --noEmit
echo "Running tests..."
npx vitest run --reporter=dot
echo "All checks passed. Pushing..."// For monorepos: only run tests for packages that changed
// .husky/pre-push (advanced)
import { execSync } from 'child_process';
function getChangedPackages(): string[] {
// Compare local HEAD with remote
const diff = execSync(
'git diff --name-only @{upstream}...HEAD',
{ encoding: 'utf-8' }
);
const changedFiles = diff.trim().split('\n');
const packages = new Set<string>();
for (const file of changedFiles) {
// Extract package name from path like "packages/auth/src/..."
const match = file.match(/^packages\/([^/]+)\//);
if (match) {
packages.add(match[1]);
}
}
return [...packages];
}
const changed = getChangedPackages();
if (changed.length > 0) {
console.log(`Testing changed packages: ${changed.join(', ')}`);
for (const pkg of changed) {
execSync(`npm run test --workspace=packages/${pkg}`, { stdio: 'inherit' });
}
}Custom Hooks: Branch Naming
Beyond the standard checks, you can enforce team conventions like branch naming patterns.
#!/bin/bash
# .husky/pre-push — branch naming validation
BRANCH=$(git rev-parse --abbrev-ref HEAD)
# Allowed patterns: feat/*, fix/*, chore/*, hotfix/*, release/*
PATTERN="^(feat|fix|chore|hotfix|release|docs|test)\/[a-z0-9._-]+$"
if [[ "$BRANCH" == "main" || "$BRANCH" == "develop" ]]; then
exit 0 # Main branches are always allowed
fi
if [[ ! "$BRANCH" =~ $PATTERN ]]; then
echo "❌ Branch name '$BRANCH' does not match the required pattern."
echo " Use: feat/description, fix/description, chore/description"
echo " Example: feat/add-user-notifications"
exit 1
fi// ❌ Without branch naming enforcement
// Branches in the repo: wip, johns-branch, test123, fix, asdf
// Nobody knows what these branches are for
// ✅ With branch naming enforcement
// Branches: feat/user-notifications, fix/payment-race-condition, chore/update-deps
// Every branch name tells you what it contains and why it existsBypassing Hooks (When Necessary)
Sometimes you need to skip hooks — during a hotfix at 2 AM, or when committing generated files. Git provides escape hatches, but they should be rare.
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "hotfix: emergency patch for production outage"
# Skip pre-push hooks
git push --no-verify
# ⚠️ IMPORTANT: Hooks are a safety net, not a prison.
# If you're using --no-verify regularly, your hooks are too slow or too strict.
# Fix the hooks instead of bypassing them.Key Takeaways
- Use
lint-stagedfor pre-commit — lint only changed files to keep the hook fast enough that nobody disables it - Enforce conventional commits — structured commit messages enable automated changelogs and semantic versioning
- Run tests in pre-push, not pre-commit — heavier checks belong before push, not before every commit
- Husky makes hooks shareable —
.husky/is committed to the repo, so every developer gets the same hooks - Enforce branch naming — consistent naming patterns make branch purpose immediately clear
- Keep hooks fast — if a hook takes more than 5 seconds, developers will bypass it; optimize or move it to CI


