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.
.git/hooks/ directorypre-commit, not pre-commit.shchmod +x .git/hooks/pre-commit.git/hooks/*.sample — rename to activate.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.
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.
# === 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
.husky/ instead of .git/hooks/ and runs npm run commands instead of raw bash.
#!/bin/bash or #!/bin/shexit 0 = success (Git continues)exit 1 = failure (Git aborts)echo to print feedback to the developergit diff --cached --name-only lists staged files# === 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"
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
"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.
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.
{
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
],
"*.css": [
"prettier --write"
],
"*.py": [
"black --quiet",
"flake8"
],
"*.sh": [
"shellcheck"
]
}
# === 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
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.
# === 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
type-enum — type must be from the allowed listtype-empty: never — type is requiredsubject-empty: never — description is requiredsubject-max-length: 72 — description max 72 charssubject-case: lower-case — description must be lowercasefeat!: rename userId to user_idBREAKING CHANGE: userId renamed to user_id{
"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/pre-commit npx lint-staged # .husky/commit-msg npx --no-install commitlint --edit "$1" # .husky/pre-push npm test
git clone repo && npm install → prepare runs → hooks installed → all quality gates active. Zero manual setup.
#!/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 (fast, < 2 sec)
pre-push (can be slower, < 60 sec)
--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.
npm init → install Husky → lint-staged → commitlint → test bad commits being blocked → test good commits passing
cd my-devops-app && npm init -y. This creates package.json which Husky needs. If package.json already exists, skip this step.npm install --save-dev husky && npx husky init. Verify .husky/ directory is created and "prepare": "husky" appears in package.json..lintstagedrc.json and write npx lint-staged into .husky/pre-commit.@commitlint/cli @commitlint/config-conventional. Create commitlint.config.js. Write commitlint command into .husky/commit-msg.git commit -m "bad message" → should fail. Try: git commit -m "fixed stuff" → should fail. Read the error output from commitlint.git commit -m "chore: add husky lint-staged commitlint" → should pass all hooks. Push to GitHub. Your repo now has enforced quality gates.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
# === 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/
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.
3 questions · 5 minutes · hooks location, lint-staged, and pre-commit
.github/hooks/.git/hooks/hooks/ in the project rootpackage.jsonpost-commitpre-pushpre-commitcommit-msg.husky/ committed to repo ✓git commit -m "bad" fails ✓git commit -m "chore: add husky" ✓notes/day9-hooks.md to repo ✓| 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 |
# 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
# 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
| Check | Local Hook (Husky) | CI Pipeline | Why Both? |
|---|---|---|---|
| Linting (ESLint) | ✅ pre-commit (staged files only) | ✅ Full codebase | Local: 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 check | Local catches it early |
| Unit tests (fast) | ✅ pre-push (<30 sec) | ✅ Full suite | Local: subset. CI: everything |
| Integration tests | ❌ Too slow for hooks | ✅ CI only | Needs DB + services = CI environment |
| Security scan (SAST) | ❌ Too slow | ✅ CI (Trivy, Snyk) | Deep scanning needs full codebase context |
| Docker build | ❌ Too slow | ✅ CI only | Multi-minute operation |
| 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! |
| Day | Topic | Lab Output | Status |
|---|---|---|---|
| Day 6 | Git Fundamentals | my-devops-app live on GitHub | ✅ |
| Day 7 | Branching Strategies | PR merged, branch protection on main | ✅ |
| Day 8 | Pull Requests & Code Review | PR template + CODEOWNERS | — |
| Day 9 ← TODAY | Git Hooks & Automation | Husky + lint-staged + commitlint in repo | ✅ |
| Day 10 | Git Advanced Lab | rebase -i, cherry-pick, bisect, reflog | Tomorrow |
my-devops-app repo should now have:.husky/pre-commit — lint-staged.husky/commit-msg — commitlint.lintstagedrc.jsoncommitlint.config.jspackage.json with "prepare": "husky"