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 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:

  1. Pre-commit hook → Runs ESLint and Prettier on staged files only (fast!)
  2. Commit message validation → Enforces conventional commit format
  3. 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:

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 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 init

What just happened:

  • npm install added Husky to package.json devDependencies
  • npx husky init created .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
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

What 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],
  },
}
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 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-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 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)
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

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.

Since .husky/pre-commit already exists from husky init, just edit it:

# .husky/pre-commit
npx lint-staged

What this hook does:

  1. Triggers before commit is created
  2. Runs lint-staged on staged files only (fast!)
  3. If linting fails → commit is aborted
  4. 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-msg

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

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

What this hook does:

  1. Triggers after you write commit message
  2. Validates message against commitlint rules
  3. If invalid → commit is aborted with error message
  4. 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)
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}\""
  }
}

New scripts explained:

  • prepare - Auto-installs Husky after npm install
  • lint - Check all files for linting errors
  • lint:fix - Auto-fix all linting errors
  • format - Format all files with Prettier
  • format: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:

  1. Pre-commit hook ran successfully (lint-staged processed files)
  2. Files were linted and formatted (backed up to stash in case of errors)
  3. 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.mdx

What happened:

  1. Pre-commit hook ran lint-staged on 12 changed files
  2. Files were formatted and modifications applied
  3. Commit-msg hook validated “feat: add git-hook-implementation post”
  4. 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-msg and .husky/pre-commit marked 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: 0 to 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 script

What happens automatically:

  • npm install runs
  • prepare script 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:

  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"

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-staged

3. 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: