What is Husky?
Husky is a powerful tool that enables you to run scripts directly from your Git hooks. It allows you to automate tasks like linting, testing, and formatting at specific points in your Git workflow — before commits, before pushes, after merges, and more. Husky works by creating hook scripts inside the .husky/ directory of your project, which Git executes at the appropriate moments.
Unlike older approaches that required manually editing .git/hooks/ files, Husky centralizes hook management in a version-controlled directory, making it easy to share hooks with your entire team and enforce consistent quality gates across all contributors.
Why Husky Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Manual quality checks are error-prone and often skipped under pressure. Husky automates these checks at the Git level, ensuring that:
- Code quality is enforced consistently — every commit can be linted before it enters the repository
- Tests run automatically — broken tests are caught before code reaches shared branches
- Commit messages follow conventions — commitlint can validate messages before they are finalized
- Secrets are never committed — tools like gitleaks or detect-secrets run as pre-commit hooks
- Build artifacts stay clean — you can prevent committing generated files or large binaries
By shifting quality checks to the earliest possible moment — before code enters the repository — Husky dramatically reduces the feedback loop and prevents problematic code from spreading through branches, saving hours of remediation work later.
Installation and Initial Setup
Husky supports modern versions (v9+) with a streamlined setup process. Here is the complete installation workflow:
Step 1: Install Husky as a dev dependency
npm install --save-dev husky
# or
yarn add -D husky
# or
pnpm add -D husky
Step 2: Initialize Husky
The init command sets up the .husky/ directory with the necessary configuration and ensures Git hooks point to Husky's scripts:
npx husky init
This command creates:
- A
.husky/directory at the root of your project - A
.husky/_/directory containing internal helper scripts - A
.husky/pre-commitfile with a basic example hook
Step 3: Verify the setup
After initialization, check that your .git/config file contains the correct core.hooksPath setting pointing to .husky/:
git config core.hooksPath
# Output: .husky
You can also inspect the generated pre-commit hook:
cat .husky/pre-commit
Initially, it contains a simple test command:
npm test
Configuring Git Hooks with Husky
Husky hooks are shell scripts placed in the .husky/ directory. Each file is named after the Git hook it represents. Here is how to configure the most common hooks for a complete quality pipeline.
Pre-commit Hook — Linting and Formatting
The pre-commit hook runs before a commit is finalized. It is the most common hook, used to run linters, formatters, and basic checks. Here is a comprehensive example:
# .husky/pre-commit
# Run ESLint on staged files
npx lint-staged
# Run Prettier check
npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css}"
# Run unit tests related to changed files
npx jest --bail --findRelatedTests --passWithNoTests
For projects using lint-staged (which we will cover shortly), the pre-commit hook becomes incredibly concise:
# .husky/pre-commit
npx lint-staged
Commit-msg Hook — Validating Commit Messages
The commit-msg hook receives the path to the commit message file as its first argument. It is used to enforce commit message conventions:
# .husky/commit-msg
# Validate commit message using commitlint
npx --no-install commitlint --edit "$1"
# Alternatively, enforce a custom format
# Example: require conventional commits format
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
if ! echo "$COMMIT_MSG" | grep -qE '^(feat|fix|docs|chore|test|refactor|perf|ci|build|revert)(\(.+\))?: .+'; then
echo "❌ Commit message does not follow conventional commits format."
echo "Expected: type(scope): description"
echo "Example: feat(auth): add login functionality"
exit 1
fi
Pre-push Hook — Running Full Test Suites
The pre-push hook executes before code is pushed to a remote repository. It is ideal for running heavier operations like full test suites or security scans:
# .husky/pre-push
echo "Running full test suite before push..."
# Run all tests
npx jest --coverage --ci
# Check for secrets
npx git-secrets --scan
# Build check to ensure no compile errors
npx tsc --noEmit
echo "✅ Pre-push checks passed!"
Post-checkout and Post-merge Hooks — Dependency Management
These hooks run after branch switches or merges and are perfect for keeping dependencies synchronized:
# .husky/post-checkout
# This script runs after git checkout
PREVIOUS_HEAD=$1
NEW_HEAD=$2
BRANCH_CHANGE=$3
# Only run on branch changes (not file checkouts)
if [ "$BRANCH_CHANGE" = "1" ]; then
echo "Branch changed. Checking dependencies..."
# Check if package.json changed
if git diff --name-only "$PREVIOUS_HEAD" "$NEW_HEAD" | grep -q "package.json"; then
echo "package.json changed. Installing dependencies..."
npm install
fi
fi
For post-merge, a similar approach ensures dependencies are always up to date after pulling or merging:
# .husky/post-merge
echo "Post-merge: checking for dependency changes..."
# Check if package-lock.json or yarn.lock changed
if git diff --name-only HEAD@{1} HEAD | grep -qE "package-lock.json|yarn.lock|pnpm-lock.yaml"; then
echo "Lock file changed. Running install..."
npm install
fi
Integrating Husky with lint-staged
lint-staged is Husky's perfect companion. While Husky triggers the hooks, lint-staged ensures that only staged files (files you have modified and are about to commit) are processed. This makes linting and formatting incredibly fast, even in large codebases.
Installing lint-staged
npm install --save-dev lint-staged
Configuring lint-staged
Add a lint-staged configuration to your package.json or create a dedicated lint-staged.config.js file. Here is a complete configuration example in package.json:
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write",
"jest --bail --findRelatedTests --passWithNoTests"
],
"*.{json,md,css,scss}": [
"prettier --write"
],
"*.{png,jpeg,jpg,gif,svg}": [
"imagemin-lint-staged"
]
}
}
Or as a separate JavaScript file for more complex logic:
// lint-staged.config.js
export default {
'*.{ts,tsx}': () => 'tsc --noEmit',
'*.{ts,tsx,js,jsx}': [
'eslint --fix',
'prettier --write',
],
'*.{json,md}': ['prettier --write'],
'*': (filenames) => {
// Check for file size limits
const oversizedFiles = filenames.filter(
(file) => require('fs').statSync(file).size > 100 * 1024 // 100KB
);
if (oversizedFiles.length > 0) {
console.error('Files exceed 100KB limit:', oversizedFiles);
process.exit(1);
}
return [];
},
};
Combining Husky pre-commit with lint-staged
With lint-staged configured, your Husky pre-commit hook becomes a single line:
# .husky/pre-commit
npx lint-staged
Now when you run git commit, Husky triggers the pre-commit hook, which calls lint-staged. lint-staged identifies the staged files, matches them against the glob patterns in its configuration, and runs only the relevant commands on those specific files. This means:
- Linting takes milliseconds instead of minutes
- Only relevant tests run, not the entire suite
- Formatting is applied only to changed files
Advanced Husky Configuration Patterns
Conditional Hooks Based on Branch
Sometimes you want stricter checks on certain branches (like main or develop) while allowing lighter checks on feature branches. Here is how to implement conditional logic in your hooks:
# .husky/pre-commit
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ] || [ "$BRANCH" = "develop" ]; then
echo "🔒 Protected branch detected. Running full checks..."
# Full test suite
npx jest --coverage
# Full type checking
npx tsc --noEmit
# Security audit
npm audit --audit-level=high
else
echo "📝 Feature branch. Running basic checks..."
# Only lint-staged on changed files
npx lint-staged
fi
Skipping Hooks When Necessary
There are legitimate situations where you need to bypass hooks — for example, during a rapid hotfix or when bisecting. Husky respects Git's standard skip mechanisms:
# Skip all hooks for this commit
git commit -m "urgent hotfix" --no-verify
# Skip hooks for specific operations
git push --no-verify
You can also implement a custom skip mechanism using environment variables:
# .husky/pre-commit
# Allow skipping with HUSKY_SKIP_HOOKS environment variable
if [ "$HUSKY_SKIP_HOOKS" = "true" ]; then
echo "⏭️ Skipping hooks via HUSKY_SKIP_HOOKS"
exit 0
fi
# Check for skip commit message marker
COMMIT_MSG_FILE=".git/COMMIT_EDITMSG"
if [ -f "$COMMIT_MSG_FILE" ]; then
FIRST_LINE=$(head -n1 "$COMMIT_MSG_FILE")
if echo "$FIRST_LINE" | grep -q '\[skip ci\]'; then
echo "⏭️ Skipping hooks due to [skip ci] marker"
exit 0
fi
fi
npx lint-staged
Timeouts and Performance Guards
Prevent hooks from running indefinitely by adding timeout mechanisms:
# .husky/pre-commit
# Set a maximum duration for lint-staged (60 seconds)
TIMEOUT=60
timeout $TIMEOUT npx lint-staged
EXIT_CODE=$?
if [ $EXIT_CODE -eq 124 ]; then
echo "⏱️ lint-staged timed out after ${TIMEOUT}s"
echo "Run 'npx lint-staged' manually to investigate"
exit 1
elif [ $EXIT_CODE -ne 0 ]; then
echo "❌ lint-staged failed with exit code $EXIT_CODE"
exit $EXIT_CODE
fi
echo "✅ Pre-commit checks passed"
Monorepo Configuration
In monorepo setups using tools like Turborepo, Nx, or Lerna, Husky hooks need to be configured at the root level. Here is a pattern for a monorepo pre-commit hook that runs checks only on affected packages:
# .husky/pre-commit (monorepo root)
# Use turborepo to run tasks on affected packages only
npx turbo run lint test typecheck --filter=[HEAD^1]
# Alternatively, with Nx
npx nx affected --target=lint,test,typecheck --base=HEAD~1 --head=HEAD
For the commit-msg hook in a monorepo, you may want to enforce scoped commits:
# .husky/commit-msg
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Enforce package scope in conventional commits for monorepo
# Pattern: type(package-name): description
if ! echo "$COMMIT_MSG" | grep -qE '^(feat|fix|docs|chore|test|refactor|perf|ci)\([a-z-]+\): .+'; then
echo "❌ Monorepo commits require a package scope."
echo "Format: type(package): description"
echo "Example: feat(api): add new endpoint"
echo ""
echo "Available packages:"
ls -1 packages/ apps/ 2>/dev/null | grep -v node_modules
exit 1
fi
Husky with Different Package Managers
Using Husky with Yarn
Husky works with Yarn Berry (Yarn 4) and classic Yarn. With Yarn Berry's Plug'n'Play, ensure Husky is properly initialized:
# Install
yarn add -D husky
# Initialize
yarn husky init
# Add hooks (example: pre-commit)
echo 'yarn lint-staged' > .husky/pre-commit
chmod +x .husky/pre-commit
Using Husky with pnpm
pnpm works seamlessly with Husky. The initialization command differs slightly:
# Install
pnpm add -D husky
# Initialize — note the use of pnpm exec
pnpm exec husky init
# Add a hook
echo 'pnpm lint-staged' > .husky/pre-commit
For pnpm workspaces, ensure the hooks run at the workspace root:
# .husky/pre-commit
pnpm -r run lint
pnpm -r run test
Testing and Debugging Husky Hooks
Running Hooks Manually
You can test hooks without actually committing by executing them directly:
# Test pre-commit hook
bash .husky/pre-commit
# Test commit-msg hook with a sample message
echo "feat: add login feature" > /tmp/test-commit-msg
bash .husky/commit-msg /tmp/test-commit-msg
Debugging Hook Failures
When a hook fails, add debugging information to understand what went wrong:
# .husky/pre-commit with debugging
# Enable verbose debugging
set -x
echo "=== Husky Pre-commit Debug ==="
echo "Current branch: $(git rev-parse --abbrev-ref HEAD)"
echo "Staged files:"
git diff --cached --name-only --diff-filter=ACM
echo "=============================="
# Run lint-staged with verbose output
npx lint-staged --verbose
# Capture and display exit code
EXIT_CODE=$?
echo "lint-staged exit code: $EXIT_CODE"
# Disable debugging output
set +x
exit $EXIT_CODE
Checking Hook Registration
Verify that Git recognizes your Husky hooks:
# List all configured hooks
ls -la .husky/
# Check Git's hooks path
git config --get core.hooksPath
# List hook files that Git will execute
ls -la $(git rev-parse --git-dir)/hooks/
Best Practices for Husky Configuration
- Keep hooks fast — Pre-commit hooks should complete in under 10 seconds. Use lint-staged to process only changed files. Reserve heavy operations (full test suites, security audits) for pre-push hooks where the delay is more acceptable.
-
Version control your
.husky/directory — The entire.husky/directory should be committed to your repository. This ensures every developer on the team uses the same hooks without additional setup steps. -
Use
npxor package manager exec commands — Always prefix tool invocations withnpx,yarn, orpnpmrather than assuming global installations. This guarantees hooks work across different development environments. - Provide clear error messages — When a hook fails, the developer should immediately understand why and how to fix it. Include examples of correct formats in commit-msg hooks and descriptive failure reasons in pre-commit hooks.
-
Allow intentional bypass with care — Support
--no-verifyand document when it is acceptable to use. Implement custom skip markers like[skip ci]for automated commits from CI/CD pipelines. - Separate concerns between hooks — Use pre-commit for fast checks (linting, formatting), commit-msg for message validation, pre-push for comprehensive tests, and post-checkout/post-merge for dependency management.
- Test hooks in CI as well — Hooks are a convenience layer, not a security boundary. Always run the same checks in your CI pipeline, since hooks can be bypassed. Hooks catch issues early; CI provides the final gate.
- Document hook expectations in CONTRIBUTING.md — Explain what each hook does, why it exists, and how to troubleshoot or bypass it when necessary. This reduces friction for new contributors.
Common Issues and Troubleshooting
Hooks Not Running After Git Clone
When a new developer clones the repository, hooks may not activate automatically. This happens because Git needs the core.hooksPath configuration set. The solution is to run husky init or include a prepare script:
// package.json
{
"scripts": {
"prepare": "husky init || true"
}
}
The prepare script runs automatically after npm install, ensuring hooks are configured for every developer.
Hooks Work Locally but Not in CI
CI environments may have a different Git configuration. Ensure your CI pipeline explicitly sets up Husky:
# CI pipeline step
npm ci
npx husky init
git config core.hooksPath .husky
Better yet, run your quality checks directly as CI steps rather than relying on Git hooks in CI.
Windows Compatibility
Husky generates shell scripts that require a Unix-like shell. On Windows, ensure Git Bash, WSL, or another compatible shell is available. Husky automatically detects the shell, but you can explicitly configure it:
# Add a shebang to specify the shell
#!/bin/sh
# .husky/pre-commit
npx lint-staged
For Windows-native commands, use the appropriate shell:
#!/usr/bin/env node
// .husky/pre-commit (Node.js script instead of shell)
import { execSync } from 'child_process';
try {
execSync('npx lint-staged', { stdio: 'inherit' });
} catch (error) {
process.exit(1);
}
Complete Example: Production-Ready Husky Setup
Here is a full, production-ready Husky configuration for a TypeScript project with ESLint, Prettier, Jest, and commitlint. This represents a complete quality pipeline that you can adapt for your own projects.
package.json scripts and dependencies
{
"scripts": {
"prepare": "husky init || true",
"lint": "eslint . --ext .ts,.tsx,.js,.jsx",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "jest --coverage",
"test:staged": "jest --bail --findRelatedTests --passWithNoTests",
"typecheck": "tsc --noEmit",
"commitlint": "commitlint --config .commitlintrc.js"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write",
"jest --bail --findRelatedTests --passWithNoTests"
],
"*.{json,md,css,scss}": [
"prettier --write"
]
}
}
.husky/pre-commit
#!/bin/sh
echo "🔍 Running pre-commit checks..."
# Run lint-staged for fast, file-specific checks
npx lint-staged --verbose
if [ $? -ne 0 ]; then
echo "❌ Pre-commit checks failed. Please fix the issues and try again."
exit 1
fi
echo "✅ Pre-commit checks passed!"
.husky/commit-msg
#!/bin/sh
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(head -n1 "$COMMIT_MSG_FILE")
echo "📝 Validating commit message..."
# Run commitlint
npx --no-install commitlint --edit "$COMMIT_MSG_FILE"
if [ $? -ne 0 ]; then
echo ""
echo "❌ Commit message validation failed."
echo "Expected format: type(scope): description"
echo "Examples:"
echo " feat(auth): add login with JWT"
echo " fix(api): resolve race condition in requests"
echo " docs(readme): update installation instructions"
echo " chore(deps): upgrade lodash to v4.17.21"
exit 1
fi
echo "✅ Commit message is valid!"
.husky/pre-push
#!/bin/sh
echo "🚀 Running pre-push checks..."
# Full type checking
echo "Running type checker..."
npx tsc --noEmit
if [ $? -ne 0 ]; then
echo "❌ Type checking failed."
exit 1
fi
# Full test suite
echo "Running full test suite..."
npx jest --coverage --ci --maxWorkers=4
if [ $? -ne 0 ]; then
echo "❌ Tests failed."
exit 1
fi
# Security audit
echo "Running security audit..."
npm audit --audit-level=high
if [ $? -ne 0 ]; then
echo "⚠️ High severity vulnerabilities found. Please review before pushing."
exit 1
fi
echo "✅ Pre-push checks passed! Pushing..."
.husky/post-checkout
#!/bin/sh
PREVIOUS_HEAD=$1
NEW_HEAD=$2
BRANCH_CHANGE=$3
if [ "$BRANCH_CHANGE" = "1" ]; then
echo "🔄 Branch changed. Checking for dependency updates..."
if git diff --name-only "$PREVIOUS_HEAD" "$NEW_HEAD" | grep -qE "package.json|package-lock.json|yarn.lock|pnpm-lock.yaml"; then
echo "📦 Dependencies changed. Running npm install..."
npm install
echo "✅ Dependencies updated."
else
echo "✅ Dependencies unchanged."
fi
fi
.commitlintrc.js (companion configuration)
// .commitlintrc.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
[
'feat',
'fix',
'docs',
'chore',
'test',
'refactor',
'perf',
'ci',
'build',
'revert',
],
],
'scope-enum': [1, 'always', ['auth', 'api', 'ui', 'db', 'config', 'deps']],
'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']],
'body-max-line-length': [1, 'always', 100],
},
};
Migration from Husky v4 to v9+
If you are upgrading from Husky v4, the configuration model has changed significantly. Here is a quick migration guide:
# Remove old configuration from package.json
# Delete the "husky" key from package.json
# Remove old hooks directory
rm -rf .husky
# Install latest Husky
npm install --save-dev husky@latest
# Reinitialize
npx husky init
# Recreate your hooks as shell scripts in .husky/
# Old: package.json "husky.hooks.pre-commit": "npm test"
# New: .husky/pre-commit contains "npm test"
The key difference is that Husky v4 stored configuration in package.json under a "husky" key, while modern Husky uses standalone shell scripts in the .husky/ directory. This change improves portability and makes hooks easier to inspect and debug.
Conclusion
Husky transforms Git hooks from an obscure, often-ignored feature into a central pillar of your development workflow. By automating quality checks at the Git level, it catches issues before they propagate, saves review time, and ensures consistency across your entire team. The configuration patterns covered in this guide — from basic pre-commit linting to sophisticated monorepo setups with conditional logic — provide a solid foundation for any project. Start with a simple pre-commit hook and lint-staged integration, then gradually layer on commit message validation, pre-push test suites, and post-checkout dependency management as your team's needs evolve. The .husky/ directory becomes a living document of your quality standards, version-controlled and shared with every contributor. Combined with a robust CI pipeline, Husky creates a defense-in-depth strategy that keeps your codebase healthy, your releases reliable, and your development velocity high.