Implementing Git Hooks with Husky: A Real-World Example

Implementing Git Hooks with Husky: A Real-World Example

· 11 min read
gitlab-for-your-team
Part of Git Workflow Series

This is a practical implementation guide. For theory and concepts, see:

This guide shows the actual implementation of Git hooks in the blog.stack101 project using Husky.

Why This Guide?

Most hook tutorials stop at theory. This one shows actual implementation in a real Astro blog on GitLab — real config, real output, real troubleshooting.

You’ll end up with working hooks that catch errors before CI/CD runs.

What We’re Building

Three checks for the blog.stack101 project:

  1. Pre-commit hook → ESLint and Prettier on staged files only (fast)
  2. Commit message validation → Enforces conventional commit format
  3. GitLab CI/CD integration → Same checks in pipeline as safety net

Why these?

  • Lint errors caught in <5 seconds locally vs 2+ minutes in CI
  • Consistent commit messages across the team
  • Broken builds never reach GitLab

Prerequisites

You need:

Current state of blog.stack101:

# What we have
- ESLint with @typescript-eslint + astro plugins
- Prettier with astro + tailwind plugins
- GitLab CI/CD pipeline (.gitlab-ci.yml)

# What we DON'T have yet
- Husky
- Git hooks
- Commit message validation

Step 1: Install Husky

Husky stores hooks in .husky/, which is version controlled. Unlike .git/hooks/, the whole team gets them.

# Install Husky
npm install --save-dev husky

# Initialize Husky (creates .husky/ directory)
npx husky init

What this did:

  • Added Husky to package.json devDependencies
  • Created .husky/ directory with setup files
  • Hooks are now version controlled

Check Prepare Script

Your package.json needs this:

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

prepare runs after npm install. Clone, install, hooks ready. No extra steps.

💡 Why This Works

The prepare script is a special npm lifecycle hook that runs:

  • After npm install (including npm ci in CI/CD)
  • Before npm publish

This means every developer who runs npm install automatically gets Git hooks configured. No extra steps, no documentation to read. It just works.

Step 2: Install Commitlint

Commitlint validates commit messages against conventional commit format.

# Install commitlint packages
npm install --save-dev @commitlint/cli @commitlint/config-conventional
  • @commitlint/cli - CLI tool to check commit messages
  • @commitlint/config-conventional - Preset rules for conventional commits

Create Commitlint Configuration

Use .cjs extension if your package.json has "type": "module".

Create commitlint.config.cjs in project root:

// commitlint.config.cjs
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat', // New feature
        'fix', // Bug fix
        'docs', // Documentation changes
        'style', // Formatting (no code change)
        'refactor', // Refactoring production code
        'perf', // Performance improvements
        'test', // Adding tests
        'build', // Build system changes
        'ci', // CI/CD changes
        'chore', // Maintenance tasks
        'revert', // Reverting changes
      ],
    ],
    'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']],
    'subject-max-length': [2, 'always', 72],
    'body-max-line-length': [2, 'always', 100],
  },
}
ES Module vs CommonJS

If you get this error:

ReferenceError: module is not defined in ES module scope

The issue: Your package.json has "type": "module" (ES modules), but commitlint config uses CommonJS syntax.

Solution: Rename commitlint.config.jscommitlint.config.cjs

The .cjs extension tells Node.js to treat it as CommonJS, even in an ES module project.

What this configures:

  • Standard conventional commit rules
  • Limited commit type set
  • Lowercase subjects only
  • Subject max 72 chars (fits git logs)
  • Body max 100 chars per line

Step 3: Install Lint-Staged

Lint-staged runs linters only on staged files. Way faster than linting the whole codebase.

# Install lint-staged
npm install --save-dev lint-staged

Create Lint-Staged Configuration

Create .lintstagedrc.json in project root:

{
  "*.{js,jsx,ts,tsx,astro}": ["eslint --fix", "prettier --write"],
  "*.{md,mdx,json,css,scss}": ["prettier --write"]
}

How it works:

  • Matches staged files by extension
  • Runs commands sequentially per match
  • Auto-fixes and re-stages fixed files

TypeScript/Astro files get ESLint + Prettier. Markdown/CSS get Prettier only.

Performance Win

Without lint-staged:

  • npm run lint checks ~200 files → 15-20 seconds

With lint-staged:

  • Pre-commit checks only 2-3 changed files → 2-3 seconds

That’s 6-10x faster. Developers won’t bypass hooks when they’re this fast.

Step 4: Create Pre-Commit Hook

Runs before Git creates the commit.

If npx husky init created .husky/pre-commit with npm test, replace that content.

# .husky/pre-commit
npx lint-staged

What happens:

  1. Triggers before commit
  2. Runs lint-staged on staged files only
  3. Linting fails → commit aborted
  4. Linting passes → commit proceeds

Step 5: Create Commit-Msg Hook

Validates commit messages after you write them.

husky add is deprecated in v9. Create the file manually.

Create the File Manually

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

# Make it executable
chmod +x .husky/commit-msg

Or create .husky/commit-msg with your editor:

npx --no -- commitlint --edit "$1"

What happens:

  1. Triggers after you write commit message
  2. Validates against commitlint rules
  3. Invalid → commit aborted with error
  4. Valid → commit proceeds

The --no flag prevents npx from prompting to install commitlint if missing. It fails immediately instead.

Common Issue: No Staged Files

If you see: lint-staged could not find any staged files.

The issue: You tried to commit without staging files first.

Solution: Always stage files before committing:

# Stage files first
git add .

# Then commit (hooks will run on staged files)
git commit -m "feat: your commit message"

Why this happens: The pre-commit hook runs lint-staged, which ONLY lints staged files. If nothing is staged, lint-staged skips (no error), but the commit-msg hook still validates your message.

Step 6: Update Package.json Scripts

Add helper scripts for manual linting/formatting:

{
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "deploy": "npm run build && wrangler pages deploy dist",
    "prepare": "husky install",
    "lint": "eslint --ext .js,.ts,.astro src/",
    "lint:fix": "eslint --ext .js,.ts,.astro src/ --fix",
    "format": "prettier --write \"src/**/*.{js,ts,astro,md,mdx,json,css,scss}\"",
    "format:check": "prettier --check \"src/**/*.{js,ts,astro,md,mdx,json,css,scss}\""
  }
}
  • prepare - Auto-installs Husky after npm install
  • lint / lint:fix - Check or auto-fix linting errors
  • format / format:check - Format files or verify formatting (CI uses format:check)

Step 7: Test the Hooks

Real output from the blog.stack101 project.

Test 1: Bad Commit Message (Should Fail)

# Stage your changes
git add .

# Try to commit without conventional format
git commit -m "add git-hook-implementation post"

Actual output:

 Backed up original state in git stash (eb9c24a)
 Running tasks for staged files...
 Applying modifications from tasks...
 Cleaning up temporary files...
   input: add git-hook-implementation post
   subject may not be empty [subject-empty]
   type may not be empty [type-empty]

   found 2 problems, 0 warnings
   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint

husky - commit-msg script failed (code 1)

Pre-commit passed, but commit-msg rejected it. No type prefix.

Test 2: Valid Commit Message (Should Pass)

# Try again with correct format
git commit -m "feat: add git-hook-implementation post"

Actual output:

 Backed up original state in git stash (312c658)
 Running tasks for staged files...
 Applying modifications from tasks...
 Cleaning up temporary files...
[feat/git-hooks-implementation 554a71c] feat: add git-hook-implementation post
 12 files changed, 2600 insertions(+), 129 deletions(-)
 create mode 100755 .husky/commit-msg
 create mode 100755 .husky/pre-commit
 create mode 100644 .lintstagedrc.json
 create mode 100644 commitlint.config.cjs
 create mode 100644 public/projects/git-hooks-implementation/.gitkeep
 create mode 100644 public/projects/git-hooks-implementation/code-structure.png
 create mode 100644 public/projects/git-hooks-implementation/featured.webp
 create mode 100644 src/content/blog/git-hooks-implementation.mdx

Both hooks passed. Commit created.

Test 3: Linting Errors (Should Fail)

Create a file with intentional errors:

// src/broken.js
const unused = 'variable' // ESLint will catch this
console.log('missing semicolon')
# Stage and try to commit
git add src/broken.js
git commit -m "test: add broken file"

Expected output:

npx lint-staged
 Preparing lint-staged...
 Running tasks for staged files...
 *.{js,jsx,ts,tsx,astro} 1 file
 eslint --fix [FAILED]
 prettier --write

 eslint --fix:

/Users/su/Projects/blog.stack101/src/broken.js
  1:7   error  'unused' is assigned a value but never used  @typescript-eslint/no-unused-vars
  2:1   error  Missing semicolon                            semi

 2 problems (2 errors, 0 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.

husky - pre-commit hook exited with code 1 (error)

Commit blocked.

Step 8: Integrate with GitLab CI/CD

Hooks catch errors locally. CI/CD is the safety net for bypassed hooks and external contributors.

Update .gitlab-ci.yml

Add a lint stage with two jobs:

# GitLab CI/CD pipeline for Astro blog deployment to Cloudflare Pages

image: node:24

stages:
  - lint # ← Add this stage
  - install
  - build
  - test
  - deploy

# ==========================================
# STAGE 0: LINT (runs first)
# ==========================================

# Job 1: Validate Code Quality
lint:code:
  stage: lint
  script:
    - echo "Running code quality checks..."
    - npm ci --prefer-offline --no-audit
    - npm run lint
    - npm run format:check
    - echo "✅ Code quality checks passed!"
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
      - .npm/
  only:
    - merge_requests
    - develop
    - main

# Job 2: Validate Commit Messages
lint:commit:
  stage: lint
  variables:
    GIT_DEPTH: 0 # Fetch full git history for commit range
  before_script:
    - npm ci --prefer-offline --no-audit
  script:
    - echo "Validating commit messages..."
    - npx commitlint --from ${CI_MERGE_REQUEST_DIFF_BASE_SHA} --to ${CI_COMMIT_SHA} --verbose
    - echo "✅ All commit messages follow conventional format!"
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
      - .npm/
  only:
    - merge_requests # Only validate commits in merge requests


# ... rest of your pipeline ...

lint:code runs on MRs and protected branches. Validates ESLint and Prettier. Blocks merge on failure.

lint:commit runs on MRs only. Validates all commit messages in the MR range.

Someone bypasses hooks with --no-verify? CI still catches it.

Step 9: Team Onboarding

It’s automatic.

For New Team Members

# 1. Clone repository
git clone https://gitlab.stack101.dev/your-org/blog.stack101.git

# 2. Install dependencies
npm install

# That's it! Hooks are installed automatically via prepare script

npm install triggers prepare, which runs husky install. Done.

What to Tell Your Team

Share this in your team docs:

## Git Hooks Are Enabled

This project uses Husky for automated code quality checks.

**What happens when you commit:**

- Pre-commit: Lints and formats your staged files (2-3 seconds)
- Commit-msg: Validates your commit message format

**Commit message format:**

```bash
<type>: <subject>

[optional body]
```

Valid types: feat, fix, docs, style, refactor, test, chore, ci, build

Examples:

  • feat: add user dashboard
  • fix: resolve login timeout issue
  • docs: update README with setup instructions
  • Fixed bug (missing type)
  • FEAT: Add Feature (subject must be lowercase)

If hooks fail:

  1. Fix the reported errors
  2. Stage your fixes: git add .
  3. Try committing again

To bypass hooks (emergency only):

git commit --no-verify -m "emergency: hotfix for production"

CI/CD still runs all checks, so bypassing hooks doesn’t skip validation entirely.

Results & Benefits

Before Hooks

git commit -m "fixed stuff"
git push origin feature/my-feature
# Wait 3-5 minutes for CI...
# CI fails: "ESLint errors found"
# Fix errors locally
# Push again
# Wait another 3-5 minutes...

Time wasted: 6-10 minutes per cycle.

After Hooks

git commit -m "fixed stuff"
# ❌ Hook fails in 2 seconds: "Invalid commit format"

git commit -m "fix: resolve user authentication bug"
# ✅ Hook passes in 3 seconds
# ✅ CI passes on first try (because hooks caught issues)

Time saved: 6-10 minutes per commit that would have failed CI.

Next Steps

Hooks are working. Here’s what to add next.

1. Pre-Push Hook (Optional)

Run heavier checks before pushing:

"npm test"

Runs your full test suite before code reaches the remote.

2. Custom Validation Rules

Add project-specific checks to .husky/pre-commit:

# Check for console.log in production code
if git diff --cached | grep -E "console\.(log|warn|error)" >/dev/null 2>&1; then
  echo "❌ Found console statements. Remove them before committing."
  exit 1
fi

npx lint-staged

3. Monitor Hook Performance

Track hook duration:

# Add to pre-commit
start_time=$(date +%s)
npx lint-staged
end_time=$(date +%s)
echo "⏱️ Lint-staged took $((end_time - start_time)) seconds"

If hooks regularly exceed 5 seconds, optimize your linting patterns.

Conclusion

You now have production-ready Git hooks:

  • Catch linting errors locally in < 5 seconds
  • Enforce consistent commit messages
  • Integrate with GitLab CI/CD pipeline
  • Auto-install for new team members
  • Fully customizable and extendable

Key takeaways:

  • Hooks save time by catching errors before CI/CD
  • Lint-staged keeps hooks fast (staged files only)
  • Husky makes hooks shareable
  • CI/CD remains the safety net

Related reading: