← All posts
·15 min read

Claude Code with Husky: Git Hooks, lint-staged, and Commit Validation

Claude CodeHuskyGit HooksWorkflow
Claude Code with Husky: Git Hooks, lint-staged, and Commit Validation

Why Husky and Claude Code need explicit rules to work together

Husky enforces quality gates at the Git level. Before a commit lands, pre-commit runs ESLint and Prettier through lint-staged. Before the commit message is accepted, commit-msg validates that it follows Conventional Commits format. Before a push reaches the remote, pre-push runs the full test suite and type-checks. These gates exist so that broken code, malformed messages, and failing tests never enter the shared history.

Claude Code is an autonomous agent that runs in your terminal and issues git commit commands on your behalf. The problem is the gap between those two facts. Claude does not automatically know that your project has Husky installed, which hooks are active, what format the commit-msg hook expects, or what happens when lint-staged returns a non-zero exit code. Without explicit instructions, Claude's default commit workflow can produce one of three outcomes: the hook fires and Claude is confused about why the commit failed, Claude infers the hook failure is a problem with its own output and retries with --no-verify to "fix" the issue, or the commit succeeds because Claude happened to generate a message and file diff that passes, with no guarantee the next commit will too.

None of these outcomes is what you want. The CLAUDE.md configuration in this guide closes the gap. It tells Claude exactly how Husky 9 hooks are structured, what lint-staged does, what format commit-msg expects, and the single hard rule that prevents every class of hook-bypass failure: never use --no-verify.

If you have not set up Claude Code yet, the Claude Code setup guide covers installation. The broader git workflow patterns are in Claude Code git workflow. For CI quality gates that complement these local hooks, Claude Code with GitHub Actions covers the server-side equivalent.

The Husky CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a project with Husky 9, it must declare: the Husky version and hook file locations, how lint-staged is invoked from the pre-commit hook, what the commit-msg hook validates, what the pre-push hook runs, and the hard rules that prevent hook bypasses.

# Husky git hooks rules

## Stack
- Node.js 20+, TypeScript 5.x strict
- Husky 9.x, lint-staged 15.x
- ESLint 9.x (flat config), Prettier 3.x
- Vitest 2.x for unit tests

## Husky version and hook locations
- Husky version: 9.x (not 4.x or 7.x)
- Hook files live in .husky/ at the project root
- Husky 9 hook files are plain shell scripts, NOT JSON, NOT Node
- Each hook file must be executable (chmod +x .husky/pre-commit etc.)
- Hooks are installed via "prepare": "husky" in package.json scripts

## Active hooks
- .husky/pre-commit: runs lint-staged
- .husky/commit-msg: validates conventional commit format
- .husky/pre-push: runs type-check and test suite

## Pre-commit hook file (.husky/pre-commit)
npx lint-staged

## Commit-msg hook file (.husky/commit-msg)
npx --no -- commitlint --edit "$1"

## Pre-push hook file (.husky/pre-push)
pnpm run typecheck && pnpm run test --run

## lint-staged config (lint-staged.config.js)
- Runs on staged files only, not the full project
- *.ts, *.tsx: eslint --fix, then prettier --write
- *.js, *.jsx: eslint --fix, then prettier --write
- *.json, *.md: prettier --write

## Commit message format (Conventional Commits)
- Pattern: <type>(<optional scope>): <description>
- Types: feat, fix, chore, docs, refactor, test, perf, style, ci, build
- Examples:
  - feat(auth): add Google OAuth provider
  - fix(api): handle null response from payment gateway
  - chore: update ESLint to v9 flat config
  - docs(readme): add Husky setup instructions
- Max first-line length: 72 characters
- Body and footer are optional

## Hard rules
- NEVER use git commit --no-verify or git push --no-verify
- NEVER bypass hooks for any reason, including "it's just a small fix"
- When a hook fails, fix the underlying issue before retrying the commit
- NEVER commit with --allow-empty-message
- Staged files must be explicitly listed with git add before committing
- NEVER use git add -A or git add . unless every changed file is intentional
- When lint-staged auto-fixes a file, stage the fixed file and commit again
- Commit messages must match the conventional commit pattern exactly
- Run pnpm run typecheck manually before any large refactor commit

Four rules in this template prevent the failures Claude produces most often without it.

The no --no-verify rule is the most important entry. When Claude's commit fails because a hook rejected it, the path of least resistance looks like retrying with --no-verify. The hook stops running, the commit succeeds, and the problem that caused the hook failure is now in your history. The rule closes that path completely. When a hook fails, the correct response is to diagnose and fix the failure.

The explicit staging rule prevents a common Claude pattern where it runs git add -A before committing, which stages files it did not touch during the task. lint-staged runs checks against everything staged. If Claude staged an unrelated file with a lint error that already existed, the commit fails for a reason unrelated to the current task. Explicit git add {specific-file} prevents the problem.

The lint-staged re-stage rule matters because lint-staged auto-fixes files. ESLint and Prettier rewrite the file on disk, but the staged version is still the original. Claude must run git add {file} again after the auto-fix before retrying the commit, otherwise the commit contains the unfixed version and the hook fails again.

The Husky 9 syntax rule exists because Husky changed its hook format significantly between major versions. Husky 4 used a husky.hooks key in package.json. Husky 7 introduced a .huskyrc file. Husky 9 uses plain shell scripts in .husky/ with no configuration layer. Claude will sometimes generate Husky 7 hook syntax or reference .huskyrc if it does not know which version is in use.

Installing and configuring Husky 9 with lint-staged

Before writing the CLAUDE.md rules, the toolchain needs to be installed correctly. This section shows the canonical setup Claude should follow when you ask it to add Husky to a project. With the pattern in CLAUDE.md, Claude generates this exact setup every time.

# Install dependencies
pnpm add -D husky lint-staged @commitlint/cli @commitlint/config-conventional

# Initialize Husky (creates .husky/ and adds prepare script to package.json)
pnpm dlx husky init

# The init command creates .husky/pre-commit with "npm test" by default
# Replace that with lint-staged
echo "npx lint-staged" > .husky/pre-commit

# Create commit-msg hook
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg

# Create pre-push hook
echo "pnpm run typecheck && pnpm run test --run" > .husky/pre-push

# Make hooks executable
chmod +x .husky/pre-commit .husky/commit-msg .husky/pre-push

The package.json changes Husky init produces:

{
  "scripts": {
    "prepare": "husky"
  }
}

The lint-staged.config.js at the project root:

export default {
  "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
  "*.{js,jsx}": ["eslint --fix", "prettier --write"],
  "*.{json,md,yaml,yml}": ["prettier --write"],
};

The commitlint.config.js at the project root:

export default {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "header-max-length": [2, "always", 72],
    "body-max-line-length": [2, "always", 100],
    "footer-max-line-length": [2, "always", 100],
  },
};

Add both config files to CLAUDE.md so Claude can generate valid configurations when extending them later. When Claude knows the exact shape of the commitlint config, it can correctly add a project-specific scope allowlist (scope-enum) without breaking the existing rules.

For monorepos where Husky needs to live at the root while packages have their own tooling, the Claude Code monorepo post covers the project structure conventions that keep this working across workspaces.

Commit-msg validation and Conventional Commits

The commit-msg hook runs after Claude writes the commit message but before Git finalizes the commit. If the message does not match the Conventional Commits pattern, commitlint exits with a non-zero code and the commit is rejected. This is the most common hook failure Claude hits without explicit instructions, because Claude's default commit messages are descriptive English sentences rather than structured conventional commit format.

Without CLAUDE.md:

Updated the authentication middleware to handle expired tokens correctly

With CLAUDE.md:

fix(auth): handle expired token in middleware

Expired tokens previously caused an unhandled promise rejection.
Now returns 401 with WWW-Authenticate header instead.

The difference is not style preference. The first message causes commitlint to reject the commit with:

⧗   input: Updated the authentication middleware to handle expired tokens correctly
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings

Claude reads this output and, with the CLAUDE.md pattern in place, knows the fix: rewrite the message as a conventional commit and retry. Without the pattern, Claude may interpret the type-empty error as a linting configuration issue and attempt to modify the commitlint config.

Add a scope section to CLAUDE.md for projects with defined scopes:

## Conventional commit scopes (optional but consistent)
- auth: authentication and session changes
- api: server-side route handlers and middleware
- ui: component changes
- db: database schema, migrations, queries
- config: build config, tooling, environment
- deps: dependency updates
- test: test files and test utilities

When Claude knows the scope list, it picks from it rather than inventing new scopes. Consistent scopes make git log --oneline readable and let tools like conventional-changelog generate accurate release notes.

For projects using semantic-release or similar tooling that parses commit history to generate changelogs and bump versions, the CLAUDE.md rules are especially important. A single malformed commit in a batch of changes can break the release automation. The pattern in this guide produces machine-readable history from day one.

Pre-push checks: type-check and test gates

The pre-push hook runs after git push is issued but before the commits reach the remote. It is the last local quality gate. Claude needs to know what it runs so that when a push fails, it understands the error and knows what to fix rather than retrying the push.

A typical pre-push hook for a TypeScript project:

#!/bin/sh
pnpm run typecheck && pnpm run test --run

The --run flag on Vitest (or --watchAll=false on Jest) ensures the test runner exits after a single pass rather than staying in watch mode, which would hang the hook indefinitely.

Add the pre-push section to CLAUDE.md:

## Pre-push hook behaviour
- Runs: pnpm run typecheck, then pnpm run test --run
- typecheck is: tsc --noEmit
- If typecheck fails: fix the type errors before pushing, do not comment them out
- If tests fail: fix the failing tests before pushing
- Do not use @ts-ignore or @ts-expect-error to silence type errors for the purpose of passing the hook
- Do not skip failing tests with .skip or .only to pass the hook
- If a test is genuinely obsolete, delete it and explain why in the commit message
- Pre-push runs against the actual commit history, not the working directory
  (staged or unstaged changes not yet committed do not affect it)

## typecheck command
tsc --noEmit

## test command
pnpm run test --run

The @ts-ignore and .skip rules matter because they are the path of least resistance when Claude encounters a type error or test failure that it cannot easily fix. Suppressing the error or skipping the test lets the push proceed, but pushes the real problem into the codebase. With the rules explicit in CLAUDE.md, Claude diagnoses and fixes the underlying issue instead.

When you have a large test suite that makes the pre-push hook slow, a common pattern is running only tests related to changed files:

#!/bin/sh
pnpm run typecheck && pnpm run test --run --changed HEAD~1

Add whichever variant your project uses to CLAUDE.md so Claude runs the right command when you ask it to manually trigger a pre-push check.

The broader testing configuration patterns for Vitest and Jest are in Claude Code with Vitest and Claude Code with Jest.

When a hook fails: the Claude Code decision tree

Hook failures during Claude's commit workflow are not errors in Claude's output. They are the hooks doing exactly what they are supposed to do. The difference between a good outcome and a frustrating one depends on whether Claude treats hook output as diagnostic information or as an obstacle.

With the CLAUDE.md rules in place, Claude follows this decision tree:

Pre-commit hook failure (lint-staged)

The lint-staged output tells Claude which files failed and why. If ESLint reports fixable errors, lint-staged has already auto-fixed them and the files are modified on disk. Claude stages the auto-fixed files and retries the commit.

If ESLint reports unfixable errors (unused variables that lint cannot remove, logic errors, import order issues that conflict), Claude fixes them manually, stages the fixes, and retries.

## When lint-staged fails

1. Read the lint-staged output to identify which files failed
2. Check if the errors are fixable (lint-staged auto-fixes most ESLint and all Prettier issues)
3. If auto-fixed: run git add {file} for each modified file, then retry the commit
4. If not auto-fixed: fix the reported error in the source file, run git add {file}, retry
5. Never run git commit --no-verify to skip past the failure
6. Never modify .eslintignore or lint-staged config to exclude the failing file

Commit-msg hook failure (commitlint)

The commitlint output tells Claude exactly which rule failed and what it expected. Claude rewrites the commit message to match and retries.

## When commitlint fails

1. Read the commitlint error output (it prints the failing rules and their requirements)
2. Rewrite the commit message to satisfy the conventional commit format
3. Use: git commit --edit or amend the message if the commit was created without the hook running
4. Retry git commit with the corrected message
5. If the scope rule fails (scope-enum), use a scope from the approved list in this CLAUDE.md

Pre-push hook failure (typecheck or tests)

This is the most expensive failure because it means Claude must fix a type error or failing test before the commit history can reach the remote. The correct response is always to fix the problem.

## When pre-push fails

1. Read the output to determine whether typecheck or tests failed (or both)
2. For typecheck failures:
   a. Read the TypeScript error message and the file + line it points to
   b. Fix the type error at source
   c. Verify with: pnpm run typecheck
   d. Stage and commit the fix: git add {file}, git commit -m "fix(types): ..."
   e. Push again
3. For test failures:
   a. Read the failing test name and assertion output
   b. Check whether the test failure is caused by a real regression or an outdated test
   c. If regression: fix the source code, re-run tests to confirm, commit the fix, push
   d. If outdated test: update the test to match the new behaviour, commit, push
   e. Never delete a test because it is inconvenient
4. Never push with --no-verify to bypass a failing pre-push

The failure mode Claude produces most often when hooks are not in CLAUDE.md is attempting to pass --no-verify after two failed retries. The second most common is modifying the hook file itself to disable the check. Both are closed by the explicit rules above.

Permission hooks for Husky-adjacent commands

Claude Code's own permission system in .claude/settings.local.json works alongside Husky hooks. The two systems are complementary: Husky hooks gate what gets committed, Claude's permission hooks gate what commands Claude can run without explicit confirmation. The combination is documented in Claude Code permissions.

For a Husky project, a sensible permission configuration:

{
  "permissions": {
    "allow": [
      "Bash(pnpm run lint*)",
      "Bash(pnpm run typecheck*)",
      "Bash(pnpm run test*)",
      "Bash(pnpm run format*)",
      "Bash(git add*)",
      "Bash(git commit*)",
      "Bash(git push*)",
      "Bash(git status*)",
      "Bash(git log*)",
      "Bash(git diff*)"
    ],
    "deny": [
      "Bash(git commit --no-verify*)",
      "Bash(git push --no-verify*)",
      "Bash(git commit --allow-empty-message*)",
      "Bash(npx husky*)",
      "Bash(*--no-verify*)"
    ]
  }
}

The deny list uses glob patterns that match --no-verify wherever it appears in a git command. This is a hard block at the tool level: Claude cannot issue a --no-verify commit even if it tries to. The allow list is explicit rather than permissive, which means Claude will surface a confirmation prompt before running any git command not on the list, giving you a chance to catch unexpected operations.

Adding npx husky* to the deny list prevents Claude from modifying hook files without explicit permission. Hook files are configuration, not source code. They should change through deliberate decisions, not as a side effect of Claude trying to unblock itself from a failing commit.

Five failure modes Claude generates without Husky context

These are patterns observed in Claude Code sessions on projects without Husky context in CLAUDE.md. Each one has a root cause and a prevention rule already in the template above.

Failure mode 1: git commit -m "..." with a prose message

Root cause: Claude's default commit message style is an English description. Without knowing commitlint is active, it writes whatever is most descriptive.

Prevention: The commit message format section in CLAUDE.md. Claude uses the conventional commit pattern when it is explicitly specified.

Failure mode 2: git add -A before staging

Root cause: Claude uses git add -A as a safe default to ensure everything it changed is staged. This captures unintended files and triggers lint-staged checks on files Claude did not touch.

Prevention: The explicit staging rule. Claude lists the specific files it changed and stages them by name.

Failure mode 3: ignoring lint-staged auto-fix output

Root cause: lint-staged exits with a non-zero code even after auto-fixing because the working tree changed. Claude reads the non-zero exit as a failure rather than as "files were modified, re-stage them."

Prevention: The lint-staged failure procedure in CLAUDE.md. Claude checks for modified files after a lint-staged failure before concluding the files have unfixable errors.

Failure mode 4: retrying the commit without re-staging auto-fixed files

Root cause: Claude retries git commit immediately after lint-staged runs ESLint and Prettier. The commit goes through with the pre-fix version of the files because git still has the original staged content.

Prevention: The re-stage rule. Claude runs git add {file} for every file lint-staged modified before retrying the commit.

Failure mode 5: modifying the commitlint config to allow prose messages

Root cause: When commitlint repeatedly rejects Claude's commit messages, Claude sometimes concludes the config is too strict and proposes relaxing the rules rather than adjusting its message format.

Prevention: The hard rule prohibiting changes to hook files. Claude fixes its output to match the project's conventions, not the other way around.

Building a commit workflow Claude respects from session one

The Husky CLAUDE.md in this guide produces a Claude Code workflow where every commit goes through pre-commit linting and formatting on staged files, every commit message is validated against Conventional Commits format, every push is gated behind a type-check and test run, and hook failures are treated as diagnostic signals rather than obstacles to bypass.

The underlying principle is consistent across all framework integrations: Claude Code performs at the level of context you give it. A project without Husky context in CLAUDE.md produces Claude that generates prose commit messages, stages everything with git add -A, retries failed commits with --no-verify, and occasionally attempts to modify the hook files themselves to unblock. A project with the configuration above produces Claude that writes conventional commits, stages explicitly, reads hook output as diagnostics, and fixes the underlying issue every time.

For the mechanics of how CLAUDE.md shapes Claude's behaviour from session start, see CLAUDE.md explained. For the server-side quality gates that complement these local hooks, Claude Code with GitHub Actions covers CI workflows. For the ESLint and Prettier configurations that feed into lint-staged, the patterns in Claude Code best practices cover the tooling layer. Claudify includes a Husky-specific CLAUDE.md template pre-configured for Husky 9 syntax, commitlint with scope enforcement, lint-staged with ESLint 9 flat config, and the full failure-mode decision tree above.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir