Implementing Git Hooks with Husky: A Real-World Example
Step-by-step guide to implementing Git hooks with Husky in a real Astro blog project. Learn how to set up pre-commit linting, commit message validation, and integrate with GitLab CI/CD.
This is a practical implementation guide. For theory and concepts, see:
- Git Hooks for Automation - Learn what hooks are and common patterns
- Git Branching Strategy for Small Teams - Complete workflow context
This guide shows the actual implementation of Git hooks in the blog.stack101 project using Husky.
Why This Guide?
Most Git hooks tutorials show you the theory. This guide shows you the actual implementation in a real Astro blog project hosted on GitLab. You’ll see:
- Real configuration files (not just examples)
- Terminal output from actual hook execution
- Integration with existing CI/CD pipeline
- Troubleshooting based on real issues we encountered
By the end, you’ll have working Git hooks that catch errors locally before CI/CD even runs.
What We’re Building
We’re implementing three automated checks for the blog.stack101 project:
- Pre-commit hook → Runs ESLint and Prettier on staged files only (fast!)
- Commit message validation → Enforces conventional commit format
- GitLab CI/CD integration → Runs same checks in pipeline as safety net
Why these hooks?
- Catch linting errors in < 5 seconds locally vs 2+ minutes in CI
- Force consistent commit messages across the team
- Prevent broken builds from ever reaching GitLab
Prerequisites
Before starting, you need:
- Node.js project with
package.json - Git repository (GitLab, GitHub, or any Git host)
- ESLint configured (we already have this)
- Prettier configured (we already have this)
- Read this guide: https://typicode.github.io/husky/get-started.html
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 validationStep 1: Install Husky
Husky makes Git hooks shareable across the team. Unlike .git/hooks/ (which Git doesn’t track), Husky stores hooks in .husky/ which IS tracked.
# Install Husky
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory)
npx husky initWhat just happened:
npm installadded Husky topackage.jsondevDependenciesnpx husky initcreated.husky/directory with setup files- Hooks will now live in
.husky/and be version controlled
Check Prepare Script
Ensure package.json has this so new team members get hooks automatically:
{
"scripts": {
"prepare": "husky"
}
}What prepare does:
- Runs automatically after
npm install - Installs Husky hooks for anyone cloning the repo
- No manual setup needed for new developers
The prepare script is a special npm lifecycle hook that runs:
- After
npm install(includingnpm ciin 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-conventionalWhat these packages do:
@commitlint/cli- Command-line tool to check commit messages@commitlint/config-conventional- Preset rules for conventional commits
Create Commitlint Configuration
Important: 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],
},
}If you get this error:
ReferenceError: module is not defined in ES module scopeThe issue: Your package.json has "type": "module" (ES modules), but commitlint config uses CommonJS syntax.
Solution: Rename commitlint.config.js → commitlint.config.cjs
The .cjs extension tells Node.js to treat it as CommonJS, even in an ES module project.
What this configuration does:
- Extends conventional commit rules (standard format)
- Limits commit types to specific keywords
- Enforces lowercase subject line (no “Fix Bug”, use “fix bug”)
- Subject line max 72 characters (shows fully in Git logs)
- Body line max 100 characters (readable diffs)
Step 3: Install Lint-Staged
Lint-staged runs linters only on staged files. This is way faster than linting the entire codebase on every commit.
# Install lint-staged
npm install --save-dev lint-stagedCreate 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 this works:
- Matches staged files by extension
- Runs commands sequentially for each match
- Auto-fixes issues (when possible)
- Re-stages fixed files automatically
Why separate patterns?
- TypeScript/Astro files: ESLint + Prettier
- Markdown/CSS files: Only Prettier (no ESLint)
Without lint-staged:
npm run lintchecks ~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
This hook runs before Git creates the commit.
Note: If you ran npx husky init in Step 1, it already created .husky/pre-commit with npm test inside. You need to replace that content.
Edit the File Directly (Recommended)
Since .husky/pre-commit already exists from husky init, just edit it:
# .husky/pre-commit
npx lint-stagedWhat this hook does:
- Triggers before commit is created
- Runs lint-staged on staged files only (fast!)
- If linting fails → commit is aborted
- If linting passes → commit proceeds
Step 5: Create Commit-Msg Hook
This hook validates commit messages after you write them.
Note: husky add command is deprecated in Husky v9. Create the file manually instead.
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-msgOr create .husky/commit-msg with your editor:
npx --no -- commitlint --edit "$1"What this hook does:
- Triggers after you write commit message
- Validates message against commitlint rules
- If invalid → commit is aborted with error message
- If valid → commit proceeds
Why --no flag?
- Prevents npx from prompting to install commitlint if not found
- Fails immediately if commitlint isn’t installed (safer behavior)
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}\""
}
}New scripts explained:
prepare- Auto-installs Husky after npm installlint- Check all files for linting errorslint:fix- Auto-fix all linting errorsformat- Format all files with Prettierformat:check- Check if files are formatted (CI/CD uses this)
Step 7: Test the Hooks
Let’s test that our hooks actually work with 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)What happened:
- ✅ Pre-commit hook ran successfully (lint-staged processed files)
- ✅ Files were linted and formatted (backed up to stash in case of errors)
- ❌ Commit-msg hook failed - “add git-hook-implementation post” is missing a type
Why it failed: Commitlint requires format <type>: <subject>, but we only provided the subject.
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.mdxWhat happened:
- ✅ Pre-commit hook ran lint-staged on 12 changed files
- ✅ Files were formatted and modifications applied
- ✅ Commit-msg hook validated “feat: add git-hook-implementation post”
- ✅ Commit created successfully with hash
554a71c
Notice the details:
- Stash backup:
312c658- lint-staged backs up state before modifying files - 12 files changed - Including new hooks, config files, and blog post
- 2600 insertions - The entire implementation in one commit
- Executable permissions:
.husky/commit-msgand.husky/pre-commitmarked as executable
Test 3: Linting Errors (Should Fail)
Create a file with intentional linting 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)✅ Hook worked! Commit was blocked due to linting errors.
Step 8: Integrate with GitLab CI/CD
Hooks catch errors locally, but CI/CD is our safety net for:
- Developers who bypass hooks (
git commit --no-verify) - Merge requests from external contributors
- Ensuring main/develop branches stay clean
Update .gitlab-ci.yml
Add a lint stage with TWO jobs - one for code quality, one for commit messages:
# 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 ...What these CI jobs do:
1. lint:code job:
- Runs on all merge requests and protected branches
- Validates code quality with ESLint
- Checks formatting with Prettier
- Blocks merge if linting or formatting fails
2. lint:commit job:
- Runs only on merge requests (validates all commits in the MR)
- Uses
GIT_DEPTH: 0to fetch full git history - Validates commit messages from base branch to current commit
- Blocks merge if any commit message violates conventional format
Why we need BOTH jobs:
- Code quality (
lint:code) catches what pre-commit hooks catch locally - Commit messages (
lint:commit) catches what commit-msg hooks catch locally - If someone bypasses hooks with
--no-verify, CI/CD still validates both - External contributors without hooks installed get validated by CI/CD
Why lint in CI when we have hooks?
- Hooks can be bypassed with
--no-verify(emergency hotfixes) - External contributors might not have hooks installed
- Amended commits (
git commit --amend) might skip hooks - Final verification before merge to protected branches
- Ensures team standards are enforced repository-wide
Step 9: Team Onboarding
When new developers join the team, onboarding is 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 scriptWhat happens automatically:
npm installrunspreparescript executes:husky install.husky/hooks are activated- Developer is ready to commit with validation
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:
- Fix the reported errors
- Stage your fixes:
git add . - Try committing again
To bypass hooks (emergency only):
git commit --no-verify -m "emergency: hotfix for production"Note: CI/CD still runs all checks, so bypassing hooks doesn’t skip validation.
Results & Benefits
Before Hooks
Developer experience:
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...Total time wasted: 6-10 minutes per cycle
After Hooks
Developer experience:
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
You’ve got working hooks! Here’s what to add next:
1. Pre-Push Hook (Optional)
Run more expensive checks before pushing:
"npm test"This runs your full test suite before pushing to 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-staged3. Monitor Hook Performance
Track how long hooks take:
# 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 take >5 seconds, optimize your linting patterns.
Conclusion
You now have production-ready Git hooks that:
✅ Catch linting errors locally in < 5 seconds ✅ Enforce consistent commit messages ✅ Integrate with GitLab CI/CD pipeline ✅ Auto-install for new team members ✅ Are fully customizable and extendable
Key takeaways:
- Hooks save time by catching errors before CI/CD
- Lint-staged makes hooks fast (only check staged files)
- Husky makes hooks shareable across the team
- CI/CD is your safety net (always run same checks)
The blog.stack101 project now has automated quality gates that prevent bad code from reaching the repository. Your team will thank you when they’re not debugging CI failures at midnight.
Related reading:
- Git Hooks for Automation - Theory, patterns, and advanced hooks
- Git Branching Strategy for Small Teams - Complete workflow context
- How to Fix: Accidentally Committed to Main Branch - Recover from common mistakes
Share this post
Found this helpful? Share it with your network!
