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: A Practical Case - Complete workflow context
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:
- Pre-commit hook → ESLint and Prettier on staged files only (fast)
- Commit message validation → Enforces conventional commit format
- GitLab CI/CD integration → Same checks in pipeline as safety net
Why these?
- Lint errors caught in
<5seconds locally vs 2+ minutes in CI - Consistent commit messages across the team
- Broken builds never reach GitLab
Prerequisites
You need:
- Node.js project with
package.json - Git repository (GitLab, GitHub, or any host)
- ESLint and Prettier configured
- 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 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.jsondevDependencies - 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.
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-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],
},
}
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 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.
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
Runs before Git creates the commit.
If npx husky init created .husky/pre-commit with npm test, replace that content.
Edit the File Directly (Recommended)
# .husky/pre-commit
npx lint-staged
What happens:
- Triggers before commit
- Runs lint-staged on staged files only
- Linting fails → commit aborted
- 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:
- Triggers after you write commit message
- Validates against commitlint rules
- Invalid → commit aborted with error
- Valid → commit proceeds
The --no flag prevents npx from prompting to install commitlint if missing. It fails immediately instead.
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 afternpm installlint/lint:fix- Check or auto-fix linting errorsformat/format:check- Format files or verify formatting (CI usesformat: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:
- 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"
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:
- Git Hooks for Automation - Theory, patterns, and advanced hooks
- Git Branching Strategy: A Practical Case - Complete workflow context
- How to Fix: Accidentally Committed to Main Branch - Recover from common mistakes