Developer Tools

Configuring Git Hooks with Husky: Ensuring Clean Commits Automagically

By DexNox Dev Team Published May 16, 2026

Distributed development teams require reliable local validation checks to prevent syntax errors, formatting regressions, and broken test suites from entering shared repositories. Running validation checks only on remote Continuous Integration (CI) runners introduces latency and consumes runner hours. Building local validation gates using Husky and lint-staged allows developers to resolve compilation errors and formatting mismatches locally.

Hook Execution and Scoping Architecture

Git hooks are lifecycle event triggers executed by the Git version control system. These hooks run local scripts during actions such as committing changes (pre-commit), entering commit messages (commit-msg), and pushing to remote repositories (pre-push).

By default, Git hook scripts reside in .git/hooks/. Because the .git/ folder is not tracked by version control, sharing hook configurations across a development team requires a coordination wrapper. Husky overrides Git’s default hooks directory path using the native core.hooksPath configuration:

Local Git Transaction ──▶ Check core.hooksPath (.husky/) ──▶ Execute Target Hook Script

       ┌───────────────── Fails Validation ────────────────────────┼─── Passes Checks ──┐
       ▼                                                           ▼                    ▼
   Abort Transaction                                         Open Commit Editor    Proceed to Push

This mapping targets the .husky/ directory, which is tracked by version control, ensuring every developer runs the same validation scripts after executing package installations.

Running validation checks across an entire project during a commit creates a significant performance bottleneck. As codebase scale increases, the execution latency of linters and type checkers grows linearly.

Below are latency profiles of hook execution modes evaluated across different repository scales:

Repository Scale (Files)Global Scan RunScoped lint-staged ScanExecution Velocity Factor
Small (150 files)14.8 s1.8 s8.2x improvement
Medium (1,200 files)54.3 s2.5 s21.7x improvement
Large (5,000 files)240.6 s3.9 s61.6x improvement

By utilizing lint-staged, Husky limits validation checks to files staged in the active Git index, keeping commit overhead under 4 seconds even in large-scale codebases.

Deep Dive: The Git Hook Lifecycle Sequence

To build a reliable local pipeline, developers must understand the exact chronological sequence in which Git executes hooks during a commit transaction:

  1. pre-commit: Executes first, before Git asks the user for a commit message. It is used to verify code quality, run linters, check for type errors, or scan for credentials.
  2. prepare-commit-msg: Executes after the pre-commit checks pass. It receives the path to the commit message file. This hook allows script tools to dynamically pre-fill the commit message editor (e.g., prepending the active ticket ID parsed from the Git branch name).
  3. commit-msg: Executes after the user saves the commit message. It checks the message syntax against style rules. If the message fails validation, the commit is aborted.
  4. post-commit: Runs after the commit transaction is finalized. It is used for notification scripts since it cannot abort the commit.
  5. pre-push: Runs before Git pushes references to a remote server. This is the optimal location for executing unit test suites and integration tests.
git commit ──▶ pre-commit ──▶ prepare-commit-msg ──▶ User Editor ──▶ commit-msg ──▶ post-commit

Step-by-Step Husky v9+ Initialization

1. Dependency Installation

Execute the following command at the repository root to install the hooks wrapper, the staged file filter, and conventional commit linting utilities:

npm install --save-dev husky lint-staged @commitlint/cli @commitlint/config-conventional

Initialize the default Husky configuration inside the workspace:

npx husky init

This command generates the .husky/ directory, configures a default pre-commit hook template, and registers the prepare script inside package.json.

2. Workspace Package Definition (package.json)

Configure package.json to trigger Husky initialization during package installation:

{
  "name": "dexnox-workspace-template",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc -b",
    "lint": "eslint 'src/**/*.{ts,tsx,js,jsx}'",
    "format": "prettier --write 'src/**/*.{ts,tsx,js,jsx,json,md}'",
    "test:run": "vitest run",
    "prepare": "husky"
  },
  "devDependencies": {
    "@commitlint/cli": "^19.3.0",
    "@commitlint/config-conventional": "^19.2.2",
    "eslint": "^9.3.0",
    "husky": "^9.0.11",
    "lint-staged": "^15.2.5",
    "prettier": "^3.2.5",
    "typescript": "^5.4.5",
    "vitest": "^1.6.0"
  }
}

3. File Directory Blueprint

The directory structure for a repository configured with Husky and lint-staged is organized as follows:

project-root/
├── .husky/
│   ├── _/                  # Internal helper scripts
│   ├── commit-msg          # Hook file executing commitlint
│   ├── pre-commit          # Hook file executing lint-staged
│   └── pre-push            # Hook file executing vitest runs
├── .commitlintrc.json
├── lint-staged.config.js
└── package.json

Implementing Pre-Commit Hooks

The pre-commit hook intercepts the commit transaction before opening the commit message editor. If any check fails (exiting with a non-zero code), Git aborts the commit.

Pre-Commit Script (.husky/pre-commit)

#!/bin/sh
npx lint-staged

On Unix systems (macOS, Linux), ensure the file is executable:

chmod +x .husky/pre-commit

Staged Validation Configuration (lint-staged.config.js)

TypeScript type-checking (tsc --noEmit) requires a global code context and cannot compile individual files in isolation. We configure lint-staged using a function hook to trigger a global type check only when TypeScript files are staged, while running formatting and linting tasks on the specific staged files:

export default {
  '**/*.{ts,tsx}': [
    'eslint --fix',
    'prettier --write',
    () => 'tsc --noEmit'
  ],
  '**/*.{js,jsx}': [
    'eslint --fix',
    'prettier --write'
  ],
  '**/*.{json,md,yml,yaml}': [
    'prettier --write'
  ]
};

The function () => 'tsc --noEmit' ensures that tsc compiles the entire project configuration defined in tsconfig.json without having individual file names appended to the command.

Advanced Custom Hook Script Example

Below is a custom pre-commit bash script that performs low-level validation checks (such as blocking accidental commits containing private SSH keys, AWS access tokens, or files larger than 10MB) before delegating tasks to lint-staged:

#!/usr/bin/env bash
# .husky/pre-commit-custom

set -euo pipefail

# Block large files (> 10MB)
MAX_FILE_SIZE=10485760
STAGED_FILES=$(git diff --cached --name-only --diff-filter=d)

for file in ${STAGED_FILES}; do
  if [ -f "${file}" ]; then
    size=$(wc -c < "${file}")
    if [ "${size}" -gt "${MAX_FILE_SIZE}" ]; then
      echo "ERROR: File '${file}' exceeds size limit of 10MB."
      exit 1
    fi
  fi
done

# Block private keys or secrets patterns
for file in ${STAGED_FILES}; do
  if [ -f "${file}" ]; then
    if grep -qE "(BEGIN PRIVATE KEY|AWS_ACCESS_KEY_ID|SECRET_KEY)" "${file}" 2>/dev/null; then
      echo "ERROR: File '${file}' contains potential secrets patterns."
      exit 1
    fi
  fi
done

# Delegate to standard lint-staged
npx lint-staged

Conventional Commits Validation

To ensure structured commit histories that integrate with changelog generators and semantic versioning systems, configure commitlint to validate commit messages.

Commit Message Hook (.husky/commit-msg)

#!/bin/sh
npx --no -- commitlint --edit "$1"

Configure executable permissions:

chmod +x .husky/commit-msg

Commitlint Configuration (.commitlintrc.json)

Configure rules matching the Conventional Commits specification:

{
  "extends": ["@commitlint/config-conventional"],
  "rules": {
    "type-enum": [
      2,
      "always",
      [
        "build",
        "chore",
        "ci",
        "docs",
        "feat",
        "fix",
        "perf",
        "refactor",
        "revert",
        "style",
        "test"
      ]
    ],
    "type-case": [2, "always", "lower-case"],
    "subject-empty": [2, "never"],
    "subject-case": [
      2,
      "never",
      ["sentence-case", "start-case", "pascal-case", "upper-case"]
    ],
    "header-max-length": [2, "always", 72]
  }
}

Push Safeguards

Running full test suites inside pre-commit hooks slows down the commit cycle. We run test suites on the pre-push hook to ensure code pushed to remote branches passes all validation gates.

Pre-Push Hook (.husky/pre-push)

#!/bin/sh
npm run test:run

Configure executable permissions:

chmod +x .husky/pre-push

What Breaks in Production

Windows Execution Metadata Discrepancies

When developers configure hook files on Windows workstations, the executable file permissions are not written to the Git index. When a macOS or Linux developer checkouts the branch, Git checks out the hook files as non-executable, causing Git to ignore the hooks.

Ensure the files are flagged as executable in Git’s index using the Git update-index command:

git update-index --chmod=+x .husky/pre-commit
git update-index --chmod=+x .husky/commit-msg
git update-index --chmod=+x .husky/pre-push

Terminal Hangs on Interactive Shell Loops

Invoking test suites (like Jest or Vitest) or linters that run in watch mode within hooks causes the hook script to hang indefinitely. The process waits for input, blocking the Git transaction until the developer force-kills the terminal.

Ensure all commands run in non-interactive CI mode. Add explicit flags like run or ci to force process exit upon task completion.

Workspace Scope Misalignments in Monorepos

In monorepo setups, executing hook tasks from sub-directories can cause relative path failures. If the .husky/ folder is placed in a sub-project directory, Git’s global lifecycle triggers will fail to resolve the path unless the hook is registered at the workspace root.

Maintain the .husky/ directory at the root of the Git repository. Use workspace filtering commands (such as pnpm --filter) to execute checks inside specific sub-projects.

CI Pipeline Scripts Overhead

Running Husky installations and hooks setup on automated CI environments is unnecessary. It adds dependency resolution overhead and can block container build pipelines that execute inside isolated environments.

Prevent hooks installation in non-development environments by using the --no-scripts option or setting the HUSKY variable to 0:

HUSKY=0 npm ci

Frequently Asked Questions

How can I bypass all hooks for an emergency commit?

You can skip hook checks by passing the --no-verify flag to your git commit command. This flag bypasses both the pre-commit and commit-msg hooks.

Why does tsc fail when run directly on staged files?

Running tsc with specific file arguments tells the compiler to ignore tsconfig.json configurations. This prevents the compiler from loading declarations and modules, triggering false-positive type compilation errors.

How do I configure Husky to work with pnpm workspaces?

Define the prepare hook script inside the root package.json. You can then invoke specific validation tasks across workspace directories using the pnpm filter option.

Frequently Asked Questions

Can developers easily bypass Husky git hooks locally?

Yes, developers can bypass hooks by executing Git commits with the no-verify flag. This flag instructs Git to skip the execution of pre-commit and commit-msg hooks during the commit process.

How do we prevent Husky hooks from running in automated CI environments?

Configure the package installation command with the no-scripts option on CI systems. This skips running the prepare lifecycle script that installs the local hook bindings.

What is the recommended approach for managing Husky in a monorepos?

Install Husky inside the root directory configuration of the monorepo workspace. The root prepare script links git hooks globally, which can then delegate specific checks to sub-projects using project path filters.

How does Husky v9 differ from older versions like v4 or v8 in terms of directory structure?

Husky v9 executes hooks directly from plain shell scripts stored within the dot-husky directory. Older versions relied on complex package config paths or JSON definitions inside the root configuration file.