Learning Objectives
By the end of this module, you will be able to:
- Explain what Git hooks are and distinguish between client-side and server-side hooks
- Identify the key hooks in the commit lifecycle (
pre-commit,prepare-commit-msg,commit-msg,post-commit) and other workflows (pre-push,post-merge,post-checkout) - Write custom hook scripts that validate code, enforce standards, and automate tasks
- Share hooks across a team using
core.hooksPath, a.githooks/directory, or dedicated tools - Configure tools like Husky, pre-commit framework, lefthook, and lint-staged for automated quality gates
- Set up commitlint to enforce conventional commit message formats
1. What Are Git Hooks?
Git hooks are executable scripts that Git runs automatically at specific points in its workflow. They live in the .git/hooks/ directory of every repository.
ls .git/hooks/applypatch-msg.sample pre-commit.sample
commit-msg.sample pre-merge-commit.sample
fsmonitor-watchman.sample pre-push.sample
post-update.sample pre-rebase.sample
pre-applypatch.sample prepare-commit-msg.sample
pre-auto-gc.sample push-to-checkout.sample
update.sample
Every new repository comes with sample hooks (.sample extension). These are inactive — Git only runs hooks without the .sample suffix.
How Hooks Work
┌──────────────────────────────────────────────────────────────┐
│ Hook Execution Flow │
│ │
│ 1. You run a Git command (e.g., git commit) │
│ 2. Git checks for the corresponding hook script │
│ 3. If the hook exists and is executable: │
│ a. Git runs it │
│ b. Exit code 0 → proceed with the operation │
│ c. Exit code non-zero → ABORT the operation │
│ 4. If no hook exists → proceed normally │
│ │
└──────────────────────────────────────────────────────────────┘
Key rules:
- Hooks must be executable (
chmod +x) - Hooks must have no file extension (e.g.,
pre-commit, notpre-commit.sh) - Hooks can be written in any language — bash, Python, Ruby, Node.js, etc.
- The shebang line (
#!/bin/bash,#!/usr/bin/env python3) determines the interpreter - Exit code 0 = success (operation proceeds)
- Any non-zero exit code = failure (operation aborts)
2. Client-Side vs. Server-Side Hooks
Client-Side Hooks
These run on your local machine during your local Git operations:
Commit Workflow:
pre-commit → prepare-commit-msg → commit-msg → post-commit
Other Operations:
pre-rebase (before git rebase)
post-checkout (after git checkout / git switch)
post-merge (after git merge / git pull)
pre-push (before git push)
post-rewrite (after git commit --amend or git rebase)
Client-side hooks are not cloned or pushed. They exist only in your local .git/hooks/ directory. This is by design — executing arbitrary code from a remote repository would be a security risk.
Server-Side Hooks
These run on the server (e.g., your Git hosting platform) when receiving pushes:
| Hook | When It Runs | Use Case |
|---|---|---|
pre-receive | Before accepting any pushed refs | Reject pushes that don't meet criteria |
update | Per-branch, before updating each ref | Per-branch access control |
post-receive | After all refs are updated | Trigger CI/CD, send notifications |
Server-side hooks are typically configured by system administrators. Platforms like GitHub and GitLab provide their own mechanisms (GitHub Actions, GitLab CI, branch protection rules) instead of raw server hooks.
3. The Commit Lifecycle Hooks
pre-commit — The Quality Gate
Runs before Git even opens your editor for the commit message. This is the most commonly used hook.
#!/bin/bash
# .git/hooks/pre-commit
# Run linter
echo "Running linter..."
npm run lint 2>/dev/null
if [ $? -ne 0 ]; then
echo "❌ Linting failed. Fix errors before committing."
exit 1
fi
# Check for debug statements
if git diff --cached --name-only | xargs grep -l "console\.log\|debugger\|binding\.pry" 2>/dev/null; then
echo "❌ Debug statements found in staged files."
echo "Remove console.log/debugger statements before committing."
exit 1
fi
# Check for large files
MAX_FILE_SIZE=5242880 # 5MB
for file in $(git diff --cached --name-only); do
if [ -f "$file" ]; then
file_size=$(wc -c < "$file")
if [ "$file_size" -gt "$MAX_FILE_SIZE" ]; then
echo "❌ File $file is too large ($(($file_size / 1024))KB > 5MB)"
exit 1
fi
fi
done
echo "✅ Pre-commit checks passed."
exit 0Common uses:
- Run linters (ESLint, Pylint, RuboCop)
- Run formatters and verify formatting (Prettier, Black, gofmt)
- Check for forbidden patterns (debug statements, API keys, passwords)
- Run fast unit tests
- Validate file sizes
prepare-commit-msg — Pre-fill the Commit Message
Runs after pre-commit but before the editor opens. It receives the path to the commit message file and can modify it.
#!/bin/bash
# .git/hooks/prepare-commit-msg
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2 # "message", "template", "merge", "squash", or ""
SHA1=$3 # Only set when amending
# Don't modify merge commits or amend commits
if [ "$COMMIT_SOURCE" = "merge" ] || [ -n "$SHA1" ]; then
exit 0
fi
# Auto-prepend branch name as ticket reference
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
# Extract ticket number from branch name (e.g., feature/PROJ-123-add-login → PROJ-123)
TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+')
if [ -n "$TICKET" ]; then
# Only prepend if ticket isn't already in the message
if ! grep -q "$TICKET" "$COMMIT_MSG_FILE"; then
sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE"
rm -f "${COMMIT_MSG_FILE}.bak"
fi
fiThis hook automatically adds [PROJ-123] to commit messages when you're on a branch named feature/PROJ-123-whatever.
commit-msg — Validate the Commit Message
Runs after the user writes their message. Receives the message file path as $1. Return non-zero to abort.
#!/bin/bash
# .git/hooks/commit-msg
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Enforce minimum message length
if [ ${#COMMIT_MSG} -lt 10 ]; then
echo "❌ Commit message too short (minimum 10 characters)."
exit 1
fi
# Enforce conventional commit format
# Pattern: type(optional-scope): description
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,}"
if ! echo "$COMMIT_MSG" | head -1 | grep -qE "$PATTERN"; then
echo "❌ Commit message does not follow Conventional Commits format."
echo ""
echo "Expected format: type(scope): description"
echo ""
echo "Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
echo ""
echo "Examples:"
echo " feat: add user authentication"
echo " fix(auth): resolve login redirect loop"
echo " docs: update API reference"
exit 1
fi
# Enforce subject line length (max 72 characters)
SUBJECT_LENGTH=$(echo "$COMMIT_MSG" | head -1 | wc -c)
if [ "$SUBJECT_LENGTH" -gt 73 ]; then
echo "❌ Subject line too long ($SUBJECT_LENGTH chars). Maximum is 72."
exit 1
fi
exit 0post-commit — Notification After Commit
Runs after a successful commit. Cannot abort anything (the commit already happened). Useful for notifications.
#!/bin/bash
# .git/hooks/post-commit
COMMIT_MSG=$(git log -1 --pretty=%s)
BRANCH=$(git symbolic-ref --short HEAD)
echo "📦 Committed to $BRANCH: $COMMIT_MSG"
# Optional: send notification
# curl -s -X POST "https://hooks.slack.com/..." \
# -d "{\"text\":\"New commit on $BRANCH: $COMMIT_MSG\"}" > /dev/null4. Other Important Hooks
pre-push — Last Line of Defense
Runs before git push sends data to the remote. Receives the remote name and URL as arguments. Pushed refs are passed via stdin.
#!/bin/bash
# .git/hooks/pre-push
REMOTE="$1"
URL="$2"
# Prevent pushing to main/master directly
CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
echo "❌ Direct push to $CURRENT_BRANCH is not allowed."
echo "Create a feature branch and open a pull request."
exit 1
fi
# Run tests before pushing
echo "Running tests before push..."
npm test 2>/dev/null
if [ $? -ne 0 ]; then
echo "❌ Tests failed. Fix them before pushing."
exit 1
fi
exit 0post-merge — After Pull or Merge
Runs after a successful merge (including git pull). Useful for updating dependencies.
#!/bin/bash
# .git/hooks/post-merge
# Check if package.json changed
CHANGED_FILES=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)
if echo "$CHANGED_FILES" | grep -q "package.json"; then
echo "📦 package.json changed. Running npm install..."
npm install
fi
if echo "$CHANGED_FILES" | grep -q "requirements.txt"; then
echo "🐍 requirements.txt changed. Running pip install..."
pip install -r requirements.txt
fi
if echo "$CHANGED_FILES" | grep -q "Gemfile"; then
echo "💎 Gemfile changed. Running bundle install..."
bundle install
fipost-checkout — After Branch Switch
Runs after git checkout or git switch. Receives the previous HEAD, new HEAD, and a flag indicating branch checkout (1) vs file checkout (0).
#!/bin/bash
# .git/hooks/post-checkout
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_CHECKOUT=$3 # 1 = branch switch, 0 = file checkout
# Only run on branch switches
if [ "$BRANCH_CHECKOUT" -ne 1 ]; then
exit 0
fi
# Check if package.json differs between branches
if ! git diff --quiet "$PREV_HEAD" "$NEW_HEAD" -- package.json 2>/dev/null; then
echo "📦 package.json differs on this branch. Running npm install..."
npm install
fipre-rebase — Before Rebase
Runs before git rebase. Useful for preventing rebases of shared branches.
#!/bin/bash
# .git/hooks/pre-rebase
UPSTREAM=$1
BRANCH=${2:-$(git symbolic-ref --short HEAD)}
# Prevent rebasing protected branches
PROTECTED="main master develop"
for protected_branch in $PROTECTED; do
if [ "$BRANCH" = "$protected_branch" ]; then
echo "❌ Rebasing $BRANCH is not allowed."
exit 1
fi
done
exit 05. Bypassing Hooks
Sometimes you need to skip hooks — for WIP commits, during rebases, or in emergencies:
# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "WIP: experimental changes"
# Short form
git commit -n -m "WIP: quick save"
# Also works with push
git push --no-verifyThe --no-verify flag (or -n) skips all verification hooks for that operation. This is why WIP (work-in-progress) aliases often include --no-verify:
# Common WIP alias
alias gwip='git add -A && git commit --no-verify -m "WIP"'Use --no-verify sparingly. If you're using it frequently, your hooks might be too strict or too slow.
6. Sharing Hooks Across a Team
The fundamental problem: .git/hooks/ is not tracked by Git. Each developer has their own local hooks directory. How do you ensure everyone runs the same hooks?
Method 1: core.hooksPath (Git 2.9+)
Store hooks in a tracked directory and point Git to it:
# Create a hooks directory in your repo
mkdir .githooks
# Move/create your hooks there
cp .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
# Tell Git to use this directory
git config core.hooksPath .githooks
# Commit the hooks
git add .githooks/
git commit -m "chore: add shared git hooks"New developers just need to run:
git config core.hooksPath .githooksOr set it globally for all repos:
git config --global core.hooksPath .githooksPros: Simple, no extra tools, works with any language.
Cons: Developers must manually set core.hooksPath. There's no enforcement.
Method 2: Symlink from a Tracked Directory
# Create hooks in a tracked directory
mkdir -p scripts/hooks
# ... create your hook scripts there ...
# Each developer runs this setup script:
#!/bin/bash
# scripts/setup-hooks.sh
HOOK_DIR=".git/hooks"
SCRIPT_DIR="scripts/hooks"
for hook in "$SCRIPT_DIR"/*; do
hook_name=$(basename "$hook")
ln -sf "../../$SCRIPT_DIR/$hook_name" "$HOOK_DIR/$hook_name"
echo "Linked $hook_name"
doneMethod 3: Dedicated Tools
For most projects, dedicated tools provide a better developer experience than manual configuration.
7. Husky — The JavaScript Ecosystem Standard
Husky is the most popular Git hooks manager for Node.js projects. It automatically installs hooks via npm install.
Setup (Husky v9+)
# Install
npm install --save-dev husky
# Initialize (creates .husky/ directory)
npx husky initThis creates a .husky/ directory and adds a prepare script to your package.json:
{
"scripts": {
"prepare": "husky"
}
}The prepare script runs automatically after npm install, ensuring every developer gets the hooks.
Creating Hooks
# Create a pre-commit hook
echo "npm test" > .husky/pre-commit
# Create a commit-msg hook
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msgHooks are just executable files in the .husky/ directory — clean and simple.
Project Structure with Husky
my-project/
├── .husky/
│ ├── pre-commit ← runs before every commit
│ └── commit-msg ← validates commit messages
├── package.json ← "prepare": "husky" auto-installs hooks
├── .commitlintrc.json ← commitlint configuration
└── ...
8. lint-staged — Run Linters on Staged Files Only
Running linters on your entire codebase during every commit is slow. lint-staged runs linters only on files that are staged (in the Git index), making pre-commit hooks fast.
Setup
npm install --save-dev lint-stagedConfiguration
Add to package.json:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss}": [
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
]
}
}Or in a standalone .lintstagedrc.json:
{
"*.js": ["eslint --fix", "prettier --write"],
"*.py": ["black", "flake8"],
"*.go": ["gofmt -w", "go vet"]
}Integration with Husky
# .husky/pre-commit
npx lint-stagedNow when you commit:
pre-commithook fireslint-stagedidentifies staged files- Each file is matched against the glob patterns
- Matching linters/formatters run on each file
- If any linter fails → commit aborts
- If formatters modify files → changes are re-staged automatically
9. The pre-commit Framework (Language-Agnostic)
The pre-commit framework works with any programming language and manages hook installations via a YAML config file.
Setup
# Install the tool
pip install pre-commit
# Or via Homebrew
brew install pre-commitConfiguration
Create .pre-commit-config.yaml in your repo root:
repos:
# General checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-added-large-files
args: ['--maxkb=500']
- id: no-commit-to-branch
args: ['--branch', 'main', '--branch', 'master']
# JavaScript/TypeScript
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.56.0
hooks:
- id: eslint
files: \.[jt]sx?$
additional_dependencies:
- eslint@8.56.0
# Python
- repo: https://github.com/psf/black
rev: 24.1.1
hooks:
- id: black
# Commit message validation
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.1.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]Installation
# Install the hooks into .git/hooks/
pre-commit install
# Also install commit-msg hook
pre-commit install --hook-type commit-msg
# Run against all files (first time or CI)
pre-commit run --all-filesPros: Language-agnostic, huge ecosystem of ready-made hooks, auto-updates, caching. Cons: Python dependency, can be slow on first run (downloads tools).
10. Lefthook — Fast and Universal
Lefthook is a newer, fast hooks manager written in Go. It requires no runtime dependencies.
Setup
# Install
brew install lefthook
# Or: npm install lefthook --save-dev
# Or: curl -fsSL https://get.lefthook.com | sh
# Initialize
lefthook installConfiguration
Create lefthook.yml:
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,jsx,tsx}"
run: npx eslint {staged_files}
format:
glob: "*.{js,ts,jsx,tsx,json,css,md}"
run: npx prettier --check {staged_files}
test:
run: npm test
commit-msg:
commands:
validate:
run: 'head -1 {1} | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+"'
pre-push:
commands:
test:
run: npm test
no-main:
run: |
branch=$(git symbolic-ref --short HEAD)
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
echo "Direct push to $branch is not allowed"
exit 1
fiPros: Very fast (Go binary), no runtime dependencies, parallel execution, {staged_files} placeholder.
Cons: Smaller ecosystem than Husky or pre-commit.
11. commitlint — Enforcing Conventional Commits
commitlint validates commit messages against a configurable set of rules. It's most commonly used with the Conventional Commits specification.
Setup
npm install --save-dev @commitlint/cli @commitlint/config-conventionalConfiguration
Create .commitlintrc.json:
{
"extends": ["@commitlint/config-conventional"]
}Or commitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert'
]],
'subject-max-length': [2, 'always', 72],
'body-max-line-length': [2, 'always', 100],
}
};Integration with Husky
# .husky/commit-msg
npx --no -- commitlint --edit "$1"How It Works
# ✅ Valid messages:
git commit -m "feat: add user authentication"
git commit -m "fix(auth): resolve login redirect"
git commit -m "docs: update API reference"
# ❌ Invalid messages (commitlint will reject):
git commit -m "Add user authentication" # Missing type prefix
git commit -m "feat add auth" # Missing colon
git commit -m "FEAT: add auth" # Wrong case
git commit -m "feat: A" # Too shortUsing commitlint with a Git Hook (Without Husky)
#!/bin/bash
# .git/hooks/commit-msg
# Pipe the commit message file to commitlint
cat "$1" | npx --no -- commitlintMake it executable:
chmod +x .git/hooks/commit-msgCommand Reference
| Command / File | Description |
|---|---|
.git/hooks/<hook-name> | Location of hook scripts |
chmod +x .git/hooks/pre-commit | Make a hook executable |
git commit --no-verify | Skip pre-commit and commit-msg hooks |
git push --no-verify | Skip pre-push hook |
git config core.hooksPath .githooks | Point Git to a custom hooks directory |
npx husky init | Initialize Husky in a project |
npx lint-staged | Run linters on staged files only |
pre-commit install | Install pre-commit framework hooks |
pre-commit run --all-files | Run all pre-commit hooks on entire codebase |
lefthook install | Install lefthook hooks |
npx commitlint --edit "$1" | Validate a commit message with commitlint |
Hands-On Lab
Setup
mkdir hooks-lab && cd hooks-lab
git init
# Create a simple project
cat > app.js << 'EOF'
function greet(name) {
return "Hello, " + name;
}
function add(a, b) {
return a + b;
}
module.exports = { greet, add };
EOF
cat > utils.js << 'EOF'
function formatDate(date) {
return date.toISOString().split('T')[0];
}
module.exports = { formatDate };
EOF
git add .
git commit -m "feat: initial project setup"Part 1: Write a Basic pre-commit Hook
# Create the hook
cat > .git/hooks/pre-commit << 'HOOK'
#!/bin/bash
echo "🔍 Running pre-commit checks..."
# Check for console.log statements in staged JS files
STAGED_JS=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
if [ -n "$STAGED_JS" ]; then
if git diff --cached -- $STAGED_JS | grep -q "console\.log"; then
echo "❌ Found console.log in staged files:"
git diff --cached -- $STAGED_JS | grep -n "console\.log"
echo ""
echo "Remove debug statements before committing."
exit 1
fi
fi
# Check for trailing whitespace
if git diff --cached --check 2>/dev/null | grep -q "trailing whitespace"; then
echo "❌ Trailing whitespace detected:"
git diff --cached --check
exit 1
fi
echo "✅ Pre-commit checks passed!"
exit 0
HOOK
chmod +x .git/hooks/pre-commitExercise: Test the hook.
# This should PASS (no console.log)
echo "function test() { return true; }" >> app.js
git add app.js
git commit -m "feat: add test function"
# ✅ Should succeed
# This should FAIL (has console.log)
echo "console.log('debug');" >> app.js
git add app.js
git commit -m "feat: add logging"
# ❌ Should be blocked by hook
# Clean up the debug statement
git checkout -- app.jsCheckpoint: The first commit should succeed. The second should fail with a message about console.log. After git checkout -- app.js, the file is restored.
Part 2: Write a commit-msg Hook
cat > .git/hooks/commit-msg << 'HOOK'
#!/bin/bash
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE")
# Allow merge commits
if echo "$FIRST_LINE" | grep -qE "^Merge "; then
exit 0
fi
# Enforce conventional commit format
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{3,}"
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
echo ""
echo "❌ Invalid commit message format!"
echo ""
echo "Your message: $FIRST_LINE"
echo ""
echo "Expected: type(scope): description"
echo ""
echo "Valid types: feat, fix, docs, style, refactor,"
echo " perf, test, build, ci, chore, revert"
echo ""
echo "Examples:"
echo " feat: add user authentication"
echo " fix(api): handle null response"
echo " docs: update installation guide"
echo ""
exit 1
fi
# Check subject line length
LENGTH=${#FIRST_LINE}
if [ "$LENGTH" -gt 72 ]; then
echo "❌ Subject line too long ($LENGTH chars, max 72)."
exit 1
fi
exit 0
HOOK
chmod +x .git/hooks/commit-msgExercise: Test the commit-msg hook.
# This should FAIL (no conventional prefix)
echo "// update" >> app.js
git add app.js
git commit -m "update the code"
# ❌ Should be blocked
# This should PASS
git commit -m "refactor: update the code structure"
# ✅ Should succeed
# This should FAIL (too short description)
echo "// fix" >> utils.js
git add utils.js
git commit -m "fix: ab"
# ❌ Should fail (description less than 3 chars)
# This should PASS
git commit -m "fix: adjust date formatting"
# ✅ Should succeedCheckpoint: Only properly formatted conventional commit messages should pass through.
Part 3: Write a prepare-commit-msg Hook
cat > .git/hooks/prepare-commit-msg << 'HOOK'
#!/bin/bash
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# Only modify manual commits (not merges, amends, etc.)
if [ -n "$COMMIT_SOURCE" ]; then
exit 0
fi
# Get current branch name
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
# Extract ticket number (e.g., PROJ-123 from feature/PROJ-123-add-login)
TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+')
if [ -n "$TICKET" ]; then
# Prepend ticket number if not already present
if ! grep -q "$TICKET" "$COMMIT_MSG_FILE"; then
CURRENT_MSG=$(cat "$COMMIT_MSG_FILE")
echo "[$TICKET] $CURRENT_MSG" > "$COMMIT_MSG_FILE"
fi
fi
HOOK
chmod +x .git/hooks/prepare-commit-msgExercise: Test with a ticket-named branch.
# Create a branch with a ticket number
git checkout -b feature/PROJ-42-add-validation
echo "// validation" >> app.js
git add app.js
# Commit with -m (prepare-commit-msg still runs but the message already has content)
# For this hook to truly shine, commit without -m and see it pre-fill the editor
git commit -m "feat: add input validation"
# Check the commit message
git log -1 --pretty=%s
# Should show: [PROJ-42] feat: add input validation
git checkout mainCheckpoint: The commit message should be prefixed with [PROJ-42].
Part 4: Write a pre-push Hook
cat > .git/hooks/pre-push << 'HOOK'
#!/bin/bash
BRANCH=$(git symbolic-ref --short HEAD)
# Block direct pushes to main
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo ""
echo "❌ Direct push to $BRANCH is blocked!"
echo ""
echo "Please create a feature branch and open a pull request."
echo ""
exit 1
fi
echo "✅ Push to $BRANCH allowed."
exit 0
HOOK
chmod +x .git/hooks/pre-pushExercise: Verify the hook works (without an actual remote, we can test the logic).
# Test the branch check logic manually
BRANCH=$(git symbolic-ref --short HEAD)
echo "Current branch: $BRANCH"
# If on main, the hook would block. If on a feature branch, it would allow.Part 5: Using core.hooksPath for Team Sharing
git checkout main
# Create a shared hooks directory
mkdir -p .githooks
# Copy hooks to the shared directory
cp .git/hooks/pre-commit .githooks/
cp .git/hooks/commit-msg .githooks/
# Point Git to the shared directory
git config core.hooksPath .githooks
# Commit the shared hooks
git add .githooks/
git commit -m "chore: add shared git hooks for team"
# Verify hooks still work from the new location
echo "// test shared hooks" >> app.js
git add app.js
git commit -m "test without prefix"
# ❌ Should still be caught by commit-msg hook
git commit -m "test: verify shared hooks work"
# ✅ Should succeedCheckpoint: Hooks should work from the .githooks/ directory. The hooks are now tracked in version control.
Challenge
Build a complete hook suite that:
- pre-commit: Checks for
TODOorFIXMEcomments in staged files and warns (but doesn't block) - commit-msg: Enforces conventional commits AND checks that the scope (if present) is from a whitelist (e.g.,
auth,api,ui,db) - prepare-commit-msg: Adds the current date as a comment in the commit message template
- Create a setup script (
scripts/setup.sh) that configurescore.hooksPathso new developers can get started with one command
Common Pitfalls
| Pitfall | What Happens | Prevention |
|---|---|---|
Hook file has .sh extension | Git ignores it — hooks must have no extension | Name hooks exactly: pre-commit, not pre-commit.sh |
| Hook not executable | Git ignores it silently | Always chmod +x your hooks |
| Slow hooks on every commit | Developers start using --no-verify for everything | Use lint-staged to check only staged files; keep hooks under 5 seconds |
| Hooks not shared with team | Each developer has different (or no) quality gates | Use core.hooksPath, Husky, or pre-commit framework |
| Overly strict hooks in early development | Slows down experimentation and iteration | Start with --no-verify-friendly WIP workflows; enforce strictly only on CI |
pre-commit checks unstaged files | Hook catches issues in files not being committed | Always use git diff --cached (not git diff) in pre-commit hooks |
| Hooks break during rebase/merge | Interactive rebase triggers commit hooks repeatedly | Detect rebase/merge state and skip: check for .git/rebase-merge/ or .git/MERGE_HEAD |
Pro Tips
-
Keep hooks fast. A pre-commit hook that takes 30 seconds will frustrate developers. Use
lint-stagedto run linters only on changed files, not the entire codebase. -
Layer your defenses. Client-side hooks catch issues early (fast feedback), but they can be bypassed with
--no-verify. Always have CI/CD as a second line of defense that enforces the same rules. -
Use
git diff --cachedin pre-commit hooks to inspect only staged changes. Usinggit diff(without--cached) checks unstaged changes, which won't be in the commit. -
Detect rebase/merge in hooks. During interactive rebase,
pre-commitruns for every replayed commit. Check for.git/rebase-merge/or.git/rebase-apply/and exit early if present:if [ -d ".git/rebase-merge" ] || [ -d ".git/rebase-apply" ]; then exit 0 fi -
commitlint + Husky is the industry standard for JavaScript projects. For polyglot projects, the pre-commit framework or Lefthook are better choices since they work with any language.
-
Document your hooks in the project README. Even with auto-installation (Husky's
preparescript), developers should understand what checks run and why. -
Start with CI, add hooks later. If your team is new to hooks, enforce standards in CI first. Once everyone agrees on the rules, add client-side hooks to catch issues earlier.
Quiz / Self-Assessment
- Where do Git hooks live, and why are they not tracked by default?
Answer
Hooks live in the .git/hooks/ directory of each repository. They're not tracked because the .git/ directory is never committed — it's Git's internal database. This is a security design: executing arbitrary code from a cloned repository would be dangerous. Teams share hooks using core.hooksPath, tools like Husky, or by storing them in a tracked directory with a setup script.
- What is the difference between client-side and server-side hooks?
Answer
Client-side hooks run on the developer's local machine during local operations (commit, push, merge, rebase, checkout). Server-side hooks run on the Git hosting server when receiving pushes (pre-receive, update, post-receive). Client-side hooks can be bypassed with --no-verify; server-side hooks cannot be bypassed by the pusher.
- In what order do the commit-related hooks fire?
Answer
pre-commit— runs first, before the editor opens; can abortprepare-commit-msg— runs after pre-commit; can modify the commit message templatecommit-msg— runs after the user writes the message; can abort if validation failspost-commit— runs after the commit succeeds; informational only (cannot abort)
- A developer complains that their pre-commit hook isn't running. What are the likely causes?
Answer
Common causes:
- The hook file has a
.shor other extension (hooks must have no extension) - The hook is not executable (
chmod +x .git/hooks/pre-commit) - The hook has Windows line endings (CRLF) that break the shebang line
- The shebang line is missing or incorrect
core.hooksPathis set to a different directory- They're using
--no-verify(intentionally or via an alias)
- What does
git diff --cachedshow, and why is it important in pre-commit hooks?
Answer
git diff --cached shows only the staged changes (what will go into the next commit). In a pre-commit hook, you must use --cached to inspect only the files being committed, not all modified files in the working directory. Without --cached, the hook might flag issues in files that aren't part of the commit, or miss issues in staged files that have since been modified in the working directory.
- How does
lint-stagedmake pre-commit hooks faster?
Answer
Instead of running linters on the entire codebase, lint-staged runs them only on files that are currently staged (git add-ed). It uses glob patterns to match file types to their corresponding linters. For a project with thousands of files, this can reduce hook execution from minutes to seconds. It also automatically re-stages any auto-fixed files.
- How do you share hooks with your team so they're automatically installed?
Answer
Several approaches:
- Husky (JS projects): The
"prepare": "husky"script inpackage.jsonruns afternpm install, auto-installing hooks core.hooksPath: Store hooks in a tracked.githooks/directory and configure Git to use it- pre-commit framework: Developers run
pre-commit installafter cloning - Lefthook: Add
lefthook.ymlto the repo and have developers runlefthook install - Symlinks: Store hooks in a tracked directory and create a setup script that symlinks them
- What flag do you use to bypass hooks, and when is it appropriate?
Answer
git commit --no-verify (or -n) skips pre-commit and commit-msg hooks. git push --no-verify skips pre-push hooks. Appropriate uses: WIP commits during rapid development, emergency hotfixes, or when hooks are broken. It should be used sparingly — if developers constantly bypass hooks, the hooks are either too slow or too strict. CI should always enforce the same rules as a safety net.
- You want to prevent anyone from pushing directly to
main. Where would you implement this check?
Answer
Implement it in a pre-push hook that checks the current branch name and exits with a non-zero code if it's main or master. However, since client-side hooks can be bypassed with --no-verify, you should also configure branch protection rules on your hosting platform (GitHub, GitLab, Bitbucket) as a server-side enforcement that cannot be bypassed.
- What is commitlint and how does it integrate with Git hooks?
Answer
commitlint is a tool that validates commit messages against configurable rules, typically the Conventional Commits specification (feat:, fix:, docs:, etc.). It integrates via the commit-msg hook — the hook receives the commit message file path as $1, and commitlint reads and validates it. With Husky: create .husky/commit-msg containing npx --no -- commitlint --edit "$1". If the message doesn't match the rules, commitlint returns a non-zero exit code, aborting the commit.