← Back to DevBytes

Husky: Complete Configuration Guide

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:

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles