DevOps · 35 Days · Week 2 Day 09 — Git Hooks & Automation
1 / 22
Week 2 · Day 9

Git Hooks & Automation

Pre-commit, commit-msg, pre-push — Husky, lint-staged, commitlint. Catch bad code and bad commit messages before they ever reach your repo. Zero-friction quality gates that run locally on every developer's machine.

⏱ Duration 60 min
📖 Theory 25 min
🔧 Lab 30 min
❓ Quiz 5 min
Shift Left Quality
Catching a lint error in a CI pipeline after a push takes 3–5 minutes. Catching it in a pre-commit hook takes 0.3 seconds. Hooks shift quality checks left — to the developer's machine.
Session Overview

What we cover today

01
Git Hooks — What & When
pre-commit, commit-msg, pre-push, post-merge. Shell scripts that run automatically at key Git events.
02
Native Hooks (no tooling)
Write hooks directly in .git/hooks/ — understand the mechanism before abstracting it.
03
Husky — Team-Shareable Hooks
Hooks that live in the repo, not .git. Every contributor gets the same quality gates automatically.
04
lint-staged — Fast Pre-commit Linting
Run ESLint + Prettier only on staged files. Pre-commit hook completes in < 1 second.
05
commitlint — Enforce Conventional Commits
commit-msg hook that rejects commits not following feat:, fix:, chore: format.
06
Complete Quality Gate Pipeline
Lint → format → commit message validation — the full local CI experience.
07
🔧 Lab — Full Husky Setup
npm init → Husky → lint-staged → commitlint → test bad + good commits.
Part 1 of 5

Git Hooks — automation at every event

pre-commit
Before snapshot saved
lint, format, unit tests
prepare-commit-msg
Auto-populate message
e.g. add branch name
commit-msg
Validate message format
commitlint runs here
post-commit
After commit saved
notifications, logging
pre-push
Before git push
heavier tests, security
post-merge
After git pull/merge
npm install, migrations
How hooks work
  • Shell scripts stored in .git/hooks/ directory
  • Named exactly (no extension): pre-commit, not pre-commit.sh
  • Must be executable: chmod +x .git/hooks/pre-commit
  • Exit code 0 = hook passed, Git continues
  • Exit code non-zero = hook failed, Git aborts the operation
  • Samples provided in .git/hooks/*.sample — rename to activate
⚠ .git/hooks/ is not committed
Hooks in .git/hooks/ exist only on your machine. Your teammates don't get them. This is why we need Husky — it puts hooks into a tracked directory (.husky/) so everyone gets the same quality gates.
💡 Bypass when needed
git commit --no-verify or git push --no-verify skips hooks. Use sparingly — for WIP commits or when a hook is broken. Team culture should discourage bypassing.
Part 2 of 5

Native hooks — understand the mechanism

🐧🪟 WSL2 / Mac / Linux
# === Explore existing hook samples ===
ls .git/hooks/
# pre-commit.sample, pre-push.sample, commit-msg.sample ...

# === Write a simple pre-commit hook ===
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
echo "🔍 Running pre-commit checks..."

# Check for debug statements
if git diff --cached --name-only | xargs grep -l "console.log\|debugger" 2>/dev/null; then
  echo "❌ BLOCKED: console.log or debugger found in staged files!"
  echo "   Remove debug statements before committing."
  exit 1
fi

echo "✅ Pre-commit checks passed!"
exit 0
EOF
chmod +x .git/hooks/pre-commit

# === Write a commit-msg hook ===
cat > .git/hooks/commit-msg << 'EOF'
#!/bin/bash
MSG=$(cat "$1")
PATTERN="^(feat|fix|chore|docs|refactor|test|style|ci)(\(.+\))?: .{1,72}$"

if ! echo "$MSG" | grep -qE "$PATTERN"; then
  echo "❌ BLOCKED: Invalid commit message format!"
  echo "   Use: feat: description (max 72 chars)"
  echo "   Types: feat|fix|chore|docs|refactor|test|style|ci"
  echo "   Your message: $MSG"
  exit 1
fi
echo "✅ Commit message format valid!"
exit 0
EOF
chmod +x .git/hooks/commit-msg

# === Test it ===
echo "console.log('debug')" > test.js
git add test.js
git commit -m "bad message"
# → ❌ BLOCKED: console.log found
Why Learn Native Hooks First
Understanding that hooks are just shell scripts with exit codes makes Husky demystified. Husky doesn't do magic — it just puts these scripts in .husky/ instead of .git/hooks/ and runs npm run commands instead of raw bash.
Hook script rules
  • First line: #!/bin/bash or #!/bin/sh
  • exit 0 = success (Git continues)
  • exit 1 = failure (Git aborts)
  • Use echo to print feedback to the developer
  • git diff --cached --name-only lists staged files
Part 3 of 5

Husky — team-shareable hooks

🐧🪟 All Platforms
# === Setup: requires package.json ===
cd my-devops-app
npm init -y                         # Init if not done

# === Install Husky ===
npm install --save-dev husky

# === Initialize Husky (creates .husky/ dir) ===
npx husky init
# Creates: .husky/pre-commit (sample)
# Adds:   "prepare": "husky" to package.json scripts
# .husky/ is committed to git — team gets hooks on npm install

# === View what was created ===
ls .husky/
cat .husky/pre-commit
cat package.json | grep prepare

# === How it works on clone ===
# When a teammate runs: git clone → npm install
# npm runs the "prepare" script → husky installs
# hooks are set up automatically — no manual steps

# === Add a pre-commit hook ===
echo "npx lint-staged" > .husky/pre-commit

# === Add a pre-push hook (run tests) ===
echo "npm test" > .husky/pre-push

# === Add commit-msg hook (commitlint) ===
echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg

# === Commit .husky to share with team ===
git add .husky package.json
git commit -m "chore: add husky git hooks"
Husky directory structure
my-devops-app/
├── .husky/
│   ├── pre-commit    ← runs on git commit
│   ├── commit-msg    ← validates message
│   └── pre-push      ← runs on git push
├── package.json
│   └── "prepare": "husky"
└── .git/
    └── hooks/
        └── pre-commit → ../.husky/pre-commit
The Prepare Script Magic
"prepare": "husky" in package.json runs automatically after every npm install. This means: clone → npm install → hooks installed. Zero-friction onboarding — new developers get all quality gates without any extra setup steps.
Part 4 of 5

lint-staged — fast targeted linting

The Problem lint-staged Solves

Without lint-staged: pre-commit hook runs ESLint on 3,000 files → takes 45 seconds → developers disable the hook.

With lint-staged: runs ESLint only on the 2 files you staged → takes 0.3 seconds → developers keep the hook.

Fast hooks are used hooks. Slow hooks are bypassed hooks.

.lintstagedrc.json
{
  "*.{js,jsx,ts,tsx}": [
    "eslint --fix",
    "prettier --write"
  ],
  "*.{json,md,yml,yaml}": [
    "prettier --write"
  ],
  "*.css": [
    "prettier --write"
  ],
  "*.py": [
    "black --quiet",
    "flake8"
  ],
  "*.sh": [
    "shellcheck"
  ]
}
🐧🪟 Install + Wire
# === Install ===
npm install --save-dev lint-staged prettier eslint

# === ESLint config (minimal) ===
cat > .eslintrc.json << 'EOF'
{
  "env": { "node": true, "es2021": true },
  "extends": "eslint:recommended",
  "rules": {
    "no-console": "warn",
    "no-unused-vars": "error",
    "no-debugger": "error"
  }
}
EOF

# === Prettier config ===
cat > .prettierrc << 'EOF'
{
  "semi": true,
  "singleQuote": true,
  "printWidth": 80,
  "trailingComma": "es5"
}
EOF

# === Wire to Husky pre-commit ===
echo "npx lint-staged" > .husky/pre-commit

# === Test it ===
# Create a badly formatted JS file
echo 'const x = 1   ;var y=2' > test.js
git add test.js
git commit -m "feat: test lint-staged"
# → prettier auto-fixes formatting
# → eslint reports var usage warning
💡 Auto-fix then re-stage
When lint-staged runs prettier --write, it modifies the staged files. You need to git add again to include the fixed version in the commit. lint-staged handles this automatically.
Part 5 of 5

commitlint — enforcing Conventional Commits

🐧🪟 Install + Configure
# === Install commitlint ===
npm install --save-dev @commitlint/cli @commitlint/config-conventional

# === Create config (extend conventional preset) ===
cat > commitlint.config.js << 'EOF'
module.exports = {
  extends: ['@commitlint/config-conventional'],

  rules: {
    'type-enum': [2, 'always', [
      'feat', 'fix', 'chore', 'docs',
      'refactor', 'test', 'style', 'ci', 'perf'
    ]],
    'subject-max-length': [2, 'always', 72],
    'subject-case':       [2, 'always', 'lower-case'],
    'subject-empty':      [2, 'never'],
    'type-empty':         [2, 'never'],
    'scope-case':         [2, 'always', 'lower-case'],
  }
};
EOF

# === Wire to Husky commit-msg hook ===
echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg

# === Test FAILING commit ===
git commit -m "bad commit message"
# ✖   subject may not be empty [subject-empty]
# ✖   type may not be empty [type-empty]
# ✖   found 2 problems, 0 warnings

git commit -m "fixed the bug"
# ✖   type may not be empty [type-empty]

# === Test PASSING commit ===
git commit -m "fix: resolve null pointer in login handler"
# ✔   found 0 problems, 0 warnings
Commitlint Rules (Level 2 = Error)
  • type-enum — type must be from the allowed list
  • type-empty: never — type is required
  • subject-empty: never — description is required
  • subject-max-length: 72 — description max 72 chars
  • subject-case: lower-case — description must be lowercase
  • Rule level 0 = disabled, 1 = warning, 2 = error (blocks commit)
Why This Pays Off
After commitlint is enforced: git log becomes readable, changelogs auto-generate, semantic-release auto-bumps versions, CI can filter deployments by commit type. One small hook, massive downstream value.
💡 BREAKING CHANGE syntax
feat!: rename userId to user_id
Or footer: BREAKING CHANGE: userId renamed to user_id
commitlint validates both formats.
Putting It Together

Complete quality gate pipeline

package.json — complete config
{
  "name": "my-devops-app",
  "version": "1.0.0",
  "scripts": {
    "prepare": "husky",
    "lint":    "eslint src/",
    "format":  "prettier --write .",
    "test":    "jest"
  },
  "devDependencies": {
    "husky":             "^9.0.0",
    "lint-staged":       "^15.0.0",
    "prettier":          "^3.0.0",
    "eslint":            "^8.0.0",
    "@commitlint/cli":   "^18.0.0",
    "@commitlint/config-conventional": "^18.0.0"
  },
  "lint-staged": {
    "*.{js,ts}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml}": ["prettier --write"]
  }
}
.husky/ — 3 hooks
# .husky/pre-commit
npx lint-staged

# .husky/commit-msg
npx --no-install commitlint --edit "$1"

# .husky/pre-push
npm test
What runs on git commit -m "feat: x"
  1. pre-commit → lint-staged → ESLint + Prettier on staged files
  2. If lint fails → commit aborted with clear error
  3. commit-msg → commitlint validates "feat: x"
  4. If format invalid → commit aborted with rule violations
  5. Both pass → commit saved ✅
💡 New team member flow
git clone repo && npm installprepare runs → hooks installed → all quality gates active. Zero manual setup.
Advanced Hook

pre-push — last line of defence

🐧🪟 .husky/pre-push
#!/bin/bash
# .husky/pre-push — runs before git push
# Good place for: tests, build check, security scan

set -e   # Exit immediately if any command fails

echo "🧪 Running tests before push..."
npm test

echo "🏗  Verifying build..."
npm run build

echo "🛡  Checking for secrets in staged files..."
if git diff origin/main...HEAD | grep -E "(password|secret|api_key|token)\s*=" 2>/dev/null; then
  echo "❌ BLOCKED: Potential secret found in commit!"
  exit 1
fi

echo "✅ All pre-push checks passed!"
pre-commit vs pre-push — what goes where

pre-commit (fast, < 2 sec)

  • Lint staged files (lint-staged)
  • Format staged files (prettier)
  • Validate commit message (commitlint)

pre-push (can be slower, < 60 sec)

  • Unit tests
  • Build check (does it compile?)
  • Secret scanning
  • Type checking (tsc --noEmit)
⚠ Don't make hooks too slow
If pre-commit takes > 5 seconds, developers will start using --no-verify. Keep pre-commit under 2 seconds. If you need heavy checks, put them in pre-push or CI. Rule: hooks should be fast enough to forget they're running.
Hands-On Lab

🔧 Full Husky Setup

npm init → install Husky → lint-staged → commitlint → test bad commits being blocked → test good commits passing

⏱ 30 minutes
Node.js installed ✓
my-devops-app repo ✓
🔧 Lab — Steps

Set up automated quality gates

1
Initialize Node project
cd my-devops-app && npm init -y. This creates package.json which Husky needs. If package.json already exists, skip this step.
2
Install Husky and initialize
npm install --save-dev husky && npx husky init. Verify .husky/ directory is created and "prepare": "husky" appears in package.json.
3
Install lint-staged + linters, configure pre-commit
Install prettier, eslint, lint-staged. Create .lintstagedrc.json and write npx lint-staged into .husky/pre-commit.
4
Install commitlint, configure commit-msg hook
Install @commitlint/cli @commitlint/config-conventional. Create commitlint.config.js. Write commitlint command into .husky/commit-msg.
5
Test bad commits are blocked
Try: git commit -m "bad message" → should fail. Try: git commit -m "fixed stuff" → should fail. Read the error output from commitlint.
6
Test good commits pass, commit and push the setup
git commit -m "chore: add husky lint-staged commitlint" → should pass all hooks. Push to GitHub. Your repo now has enforced quality gates.
🔧 Lab — Complete Commands

Full lab script

🐧🪟 bash / WSL2
cd my-devops-app

# === 1. Init (skip if package.json exists) ===
npm init -y

# === 2. Install all tools ===
npm install --save-dev \
  husky \
  lint-staged \
  prettier \
  eslint \
  @commitlint/cli \
  @commitlint/config-conventional

# === 3. Initialize Husky ===
npx husky init
cat package.json | grep prepare  # Verify "prepare":"husky"
ls .husky/                        # Should see pre-commit file

# === 4. Pre-commit hook → lint-staged ===
echo "npx lint-staged" > .husky/pre-commit

# === 5. lint-staged config ===
cat > .lintstagedrc.json << 'EOF'
{
  "*.{js,ts}": ["eslint --fix", "prettier --write"],
  "*.{json,md,yml}": ["prettier --write"]
}
EOF

# === 6. ESLint config ===
cat > .eslintrc.json << 'EOF'
{
  "env": { "node": true, "es2021": true },
  "extends": "eslint:recommended",
  "rules": { "no-console": "warn", "no-debugger": "error" }
}
EOF

# === 7. commitlint config ===
cat > commitlint.config.js << 'EOF'
module.exports = {
  extends: ['@commitlint/config-conventional']
};
EOF

# === 8. commit-msg hook ===
echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg
🐧🪟 Test + Commit
# === 9. Test BAD commits (should fail) ===
git add .
git commit -m "bad message"
# ✖  type may not be empty [type-empty]
# ✖  found 1 problem, 0 warnings
# husky - commit-msg script failed (code 1)

git commit -m "fixed stuff"
# ✖  type may not be empty [type-empty]

git commit -m "FEAT: uppercase type"
# ✖  type must be lower-case [type-case]

# === 10. Test GOOD commits (should pass) ===
git commit -m "chore: add husky lint-staged commitlint"
# ✔  found 0 problems, 0 warnings
# [main abc1234] chore: add husky lint-staged commitlint
# 5 files changed...

# === 11. Push and verify ===
git push origin main
# All good — quality gates in place!

# === 12. Test --no-verify bypass (educational) ===
git commit -m "bad" --no-verify
# Bypasses hooks — use only in emergencies!

# === 13. Verify new member flow ===
# Simulate: clone fresh + npm install
# After npm install, hooks are auto-installed
ls .git/hooks/pre-commit  # Should exist and point to .husky/
💡 Commit setup files with notes
docs(day9): add husky setup notes to repo — create notes/day9-hooks.md explaining what each hook does and why. Your future self will thank you.
Knowledge Check

Quiz Time

3 questions · 5 minutes · hooks location, lint-staged, and pre-commit

Test your hooks knowledge →
QUESTION 1 OF 3
Where are native Git hooks stored in a repository?
A
.github/hooks/
B
.git/hooks/
C
hooks/ in the project root
D
package.json
QUESTION 2 OF 3
What problem does lint-staged solve compared to running ESLint on the full codebase in a pre-commit hook?
A
It runs linters only on staged files — making hooks fast enough to be practical (sub-second vs 45 seconds)
B
It only lints test files, ignoring production code
C
It replaces ESLint and Prettier entirely
D
It fixes all bugs automatically without developer input
QUESTION 3 OF 3
Which Git hook runs before a commit is saved — making it the right place for linting and fast unit tests?
A
post-commit
B
pre-push
C
pre-commit
D
commit-msg
Day 9 — Complete

What you learned today

🪝
Git Hooks
Shell scripts in .git/hooks/ that run at key events. Exit 0 = pass. Exit 1 = abort.
🐶
Husky
Moves hooks to .husky/ (committed). Everyone gets same quality gates via npm install → prepare.
lint-staged
Runs ESLint + Prettier only on staged files. 0.3 sec vs 45 sec. Fast hooks = used hooks.
📝
commitlint
commit-msg hook validates Conventional Commits. "bad message" blocked. "fix: good" passes.
Day 9 Action Items
  1. Husky installed and .husky/ committed to repo ✓
  2. Bad commit blocked: git commit -m "bad" fails ✓
  3. Good commit passes: git commit -m "chore: add husky"
  4. Commit notes/day9-hooks.md to repo ✓
Tomorrow — Day 10
Git Advanced Lab

Week 2 capstone — rebase -i (interactive rebase), cherry-pick, bisect, reflog. Real-world power commands that make you dangerous with Git.

rebase -i cherry-pick git bisect reflog
📌 Reference — All Git Hooks

Every Git hook — complete reference

Hook Triggers When Common Use Can Abort?
pre-commit Before commit saved lint-staged, fast unit tests ✅ Yes
prepare-commit-msg Before message editor opens Auto-populate ticket number from branch name ✅ Yes
commit-msg After message written commitlint — validate Conventional Commits ✅ Yes
post-commit After commit saved Notifications, audit logging ❌ No
pre-push Before git push Full test suite, build check, secret scan ✅ Yes
post-merge After git pull / merge npm install, DB migrations, cache invalidation ❌ No
pre-rebase Before git rebase starts Warn if rebasing published branch ✅ Yes
post-checkout After git checkout / switch Auto-run npm install if package-lock changed ❌ No
📌 Alternative — Lefthook

Lefthook — Husky alternative for any language

lefthook.yml — config
# Lefthook: works for ANY language (not just Node.js)
# Single YAML file instead of multiple .husky/ scripts

pre-commit:
  parallel: true        # Run checks in parallel (faster!)
  commands:
    lint:
      glob: "*.{js,ts}"
      run: npx eslint --fix {staged_files}
    format:
      glob: "*.{js,ts,json,md}"
      run: npx prettier --write {staged_files}
    python-lint:
      glob: "*.py"
      run: flake8 {staged_files}
    shell-check:
      glob: "*.sh"
      run: shellcheck {staged_files}

commit-msg:
  commands:
    commitlint:
      run: npx commitlint --edit {1}

pre-push:
  commands:
    tests:
      run: npm test
    type-check:
      run: npx tsc --noEmit
🐧🪟 Install Lefthook
# Node.js (npm)
npm install --save-dev @evilmartians/lefthook

# Go projects
go install github.com/evilmartians/lefthook@latest

# Python (pip)
pip install lefthook

# Brew (Mac)
brew install lefthook

# Install hooks from config
lefthook install

# Test manually
lefthook run pre-commit
Husky vs Lefthook
Husky: npm-only, simple, widely used. Best for Node.js projects.

Lefthook: language-agnostic, parallel execution, single YAML file, faster. Best for polyglot repos (Node + Python + Shell + Go) or when you want hooks to run in parallel.

Both solve the same problem. Pick one per project.
📌 Strategy

Hooks vs CI — what goes where

Check Local Hook (Husky) CI Pipeline Why Both?
Linting (ESLint)✅ pre-commit (staged files only)✅ Full codebaseLocal: fast feedback. CI: catches bypassed hooks
Formatting (Prettier)✅ pre-commit (auto-fix)✅ Check (no auto-fix)Local fixes it. CI enforces it.
Commit message✅ commit-msg (commitlint)Optional PR title checkLocal catches it early
Unit tests (fast)✅ pre-push (<30 sec)✅ Full suiteLocal: subset. CI: everything
Integration tests❌ Too slow for hooks✅ CI onlyNeeds DB + services = CI environment
Security scan (SAST)❌ Too slow✅ CI (Trivy, Snyk)Deep scanning needs full codebase context
Docker build❌ Too slow✅ CI onlyMulti-minute operation
The Rule
Hooks = fast, local, developer experience. CI = thorough, remote, team safety net. Hooks catch the 80% of issues in milliseconds. CI catches the remaining 20% (and anything bypassed with --no-verify). Both are needed. Neither replaces the other.
📌 Troubleshooting

Common Husky issues & fixes

Problem Cause Fix
Hooks not running at all Husky not installed or prepare didn't run Run npm install then ls .git/hooks/
"permission denied" on hook Hook file not executable chmod +x .husky/pre-commit
commitlint "Cannot find module" Package not installed or PATH issue Run npm install — use npx --no-install in hook
Hook runs but takes 30+ seconds Linting entire codebase, not using lint-staged Replace eslint . with npx lint-staged
Hooks not running on Windows Git Bash / WSL2 path issues Use WSL2 for all Git operations, or ensure Git Bash is in PATH
Teammates' hooks not running They cloned but didn't run npm install Add README note: always run npm install after clone
Need to bypass hooks temporarily Broken hook or WIP commit needed git commit --no-verify — use sparingly!
Week 2 — Almost Done

Week 2 in review — Git mastery achieved

Day Topic Lab Output Status
Day 6Git Fundamentalsmy-devops-app live on GitHub
Day 7Branching StrategiesPR merged, branch protection on main
Day 8Pull Requests & Code ReviewPR template + CODEOWNERS
Day 9 ← TODAYGit Hooks & AutomationHusky + lint-staged + commitlint in repo
Day 10Git Advanced Labrebase -i, cherry-pick, bisect, reflogTomorrow
Week 2 → Week 3 Bridge
After Day 10: confident, professional Git user. Week 3 (Day 11) starts CI/CD with GitHub Actions + Jenkins. The pipeline you build in Week 3 will:

✅ Trigger on git push (Day 6 foundation)
✅ Use feature branches + PRs (Day 7)
✅ Require PR review + CI pass (Day 8)
✅ Enforce commit standards (Day 9)
Repo health check
Your my-devops-app repo should now have:

  • .husky/pre-commit — lint-staged
  • .husky/commit-msg — commitlint
  • .lintstagedrc.json
  • commitlint.config.js
  • package.json with "prepare": "husky"
  • ✅ Branch protection on main