Git Hooks and Automation for Teams
Git Hooks and Automation for Teams
Git hooks are scripts that run automatically at specific points in the Git workflow. They let you enforce code quality standards, validate commit messages, run tests before pushing, and automate repetitive tasks without relying on developer discipline alone. Instead of hoping everyone remembers to run the linter before committing, a pre-commit hook runs it automatically and rejects the commit if it fails.
Hooks transform Git from a passive version control system into an active quality gate. They catch problems at the earliest possible moment, before bad code enters the repository history, before broken builds reach CI, and before poorly formatted commit messages make the log unreadable. This guide covers every hook type you need for a professional workflow, shows you how to implement them from scratch and with popular tools like Husky, and explains how to share hooks across a team reliably.
What You Will Learn
By the end of this article, you will understand the complete Git hooks system including client-side and server-side hooks, when each hook fires, and what arguments it receives. You will implement practical hooks for linting, testing, commit message validation, and branch protection. You will learn how to share hooks across a team using Husky and lint-staged, how to handle edge cases like merge commits and rebases, and how to integrate hooks with your CI/CD pipeline for defense in depth.
You will also learn when hooks are appropriate versus when they become a burden, how to let developers bypass hooks in legitimate situations, and how to keep hook execution fast enough that developers do not disable them out of frustration.
Prerequisites
You should be comfortable with basic Git operations including committing, branching, and pushing. Familiarity with Git workflow practices helps you understand where hooks fit into a team's development process. Understanding shell scripting basics is helpful since hooks are typically written as shell scripts, though they can be written in any language.
You need a Git installation (version 2.9 or later for core.hooksPath support) and a Node.js project if you want to follow the Husky examples. The concepts apply to any language ecosystem, but the tooling examples focus on JavaScript and TypeScript projects.
Concept Overview
Git hooks live in the .git/hooks/ directory of every repository. When you initialize a repository, Git creates sample hook files with a .sample extension. Removing the .sample extension and making the file executable activates the hook. Each hook is named after the Git event it responds to: pre-commit runs before a commit is created, commit-msg runs after the message is written, pre-push runs before data is sent to the remote.
Hooks are divided into client-side and server-side categories. Client-side hooks run on the developer's machine during operations like commit, merge, and push. Server-side hooks run on the Git server during operations like receiving pushes. This guide focuses on client-side hooks because they provide the fastest feedback loop and are the most commonly used in practice.
The hook execution model is simple: Git runs the hook script and checks the exit code. Exit code 0 means success and the operation continues. Any non-zero exit code means failure and the operation is aborted. The hook can print messages to stdout or stderr to explain why it rejected the operation.
The challenge with hooks is distribution. The .git/hooks/ directory is not tracked by Git itself, so hooks are not automatically shared when someone clones the repository. This is where tools like Husky come in, providing a mechanism to install hooks from tracked configuration files.
Step-by-Step Explanation
This section walks through the essential workflow steps in order. Each step builds on the previous one, giving you a practical progression from basic operations to advanced techniques used in professional teams.
The Pre-Commit Hook
The pre-commit hook runs after you type git commit but before Git creates the commit object. It has access to the staged changes and can inspect them to enforce quality standards. If the hook exits non-zero, the commit is aborted and the developer must fix the issues before trying again.
#!/bin/sh
# .git/hooks/pre-commit - Lint and format staged files
# Get list of staged files (excluding deleted files)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=d)
# Exit early if no files are staged
if [ -z "$STAGED_FILES" ]; then
exit 0
fi
# Run ESLint on staged TypeScript and JavaScript files
TS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx|js|jsx)$')
if [ -n "$TS_FILES" ]; then
echo "Running ESLint on staged files..."
echo "$TS_FILES" | xargs npx eslint --max-warnings=0
if [ $? -ne 0 ]; then
echo "ESLint failed. Fix the errors above before committing."
exit 1
fi
fi
# Check for debugging statements
if echo "$STAGED_FILES" | xargs grep -l "console\.log\|debugger" 2>/dev/null; then
echo "Warning: Found console.log or debugger statements in staged files."
echo "Remove them before committing or use --no-verify to bypass."
exit 1
fi
# Run type checking
echo "Running TypeScript type check..."
npx tsc --noEmit
if [ $? -ne 0 ]; then
echo "TypeScript type check failed. Fix type errors before committing."
exit 1
fi
echo "Pre-commit checks passed."
exit 0This hook demonstrates the pattern: inspect staged files, run checks against them, and exit non-zero if any check fails. The developer sees the error message and knows exactly what to fix.
A critical detail is that the hook should only check staged files, not the entire working tree. If a developer has unstaged changes that would fail the linter, those should not block the commit of the staged changes that are clean. The git diff --cached --name-only command gives you exactly the staged file list.
The Commit-Msg Hook
The commit-msg hook runs after the developer writes the commit message but before the commit is finalized. It receives the path to the temporary file containing the message as its first argument. This hook is ideal for enforcing commit message conventions like Conventional Commits.
#!/bin/sh
# .git/hooks/commit-msg - Enforce Conventional Commits format
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Allow merge commits without validation
if echo "$COMMIT_MSG" | grep -qE "^Merge "; then
exit 0
fi
# Conventional Commits pattern: type(scope): description
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{10,72}$"
# Check the first line (subject)
FIRST_LINE=$(echo "$COMMIT_MSG" | head -1)
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo ""
echo "Expected format: type(scope): description"
echo " type: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
echo " scope: optional, in parentheses"
echo " description: 10-72 characters"
echo ""
echo "Examples:"
echo " feat(auth): add OAuth2 login flow"
echo " fix: resolve null pointer in session handler"
echo " docs(api): update rate limiting documentation"
echo ""
echo "Your message: $FIRST_LINE"
exit 1
fi
# Check that subject line does not end with a period
if echo "$FIRST_LINE" | grep -qE "\.$"; then
echo "ERROR: Subject line should not end with a period."
exit 1
fi
# Check for a blank line between subject and body (if body exists)
LINE_COUNT=$(echo "$COMMIT_MSG" | wc -l)
if [ "$LINE_COUNT" -gt 1 ]; then
SECOND_LINE=$(echo "$COMMIT_MSG" | sed -n '2p')
if [ -n "$SECOND_LINE" ]; then
echo "ERROR: There must be a blank line between the subject and body."
exit 1
fi
fi
exit 0Enforcing commit message conventions through a hook ensures that every commit in the repository follows the same format. This makes git log readable, enables automated changelog generation, and supports semantic versioning tools that parse commit types to determine version bumps.
The Pre-Push Hook
The pre-push hook runs before Git sends data to the remote. It receives the remote name and URL as arguments, and the list of refs being pushed on stdin. This is the last line of defense before code leaves the developer's machine.
#!/bin/sh
# .git/hooks/pre-push - Run tests before pushing
REMOTE=$1
URL=$2
echo "Running test suite before push..."
# Run the full test suite
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
echo "Fix failing tests before pushing or use --no-verify to bypass."
exit 1
fi
# Prevent pushing directly to main or master
while read local_ref local_sha remote_ref remote_sha; do
if echo "$remote_ref" | grep -qE "refs/heads/(main|master)$"; then
echo "ERROR: Direct push to main/master is not allowed."
echo "Create a feature branch and open a pull request instead."
exit 1
fi
done
echo "Pre-push checks passed."
exit 0The pre-push hook is heavier than pre-commit because it typically runs the full test suite. This is acceptable because pushes happen less frequently than commits. However, if your test suite takes more than a few minutes, consider running only the tests affected by the changed files or running a fast subset that catches the most common regressions.
The Prepare-Commit-Msg Hook
The prepare-commit-msg hook runs before the commit message editor opens. It can modify the default message template, prepend ticket numbers from the branch name, or add metadata automatically.
#!/bin/sh
# .git/hooks/prepare-commit-msg - Auto-prepend ticket number from branch name
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# Only modify the message for regular commits (not merges, squashes, etc.)
if [ -n "$COMMIT_SOURCE" ]; then
exit 0
fi
# Extract ticket number from branch name (e.g., feature/JIRA-1234-add-search)
BRANCH_NAME=$(git symbolic-ref --short HEAD 2>/dev/null)
TICKET=$(echo "$BRANCH_NAME" | grep -oE '[A-Z]+-[0-9]+')
if [ -n "$TICKET" ]; then
# Prepend ticket number to the commit message
CURRENT_MSG=$(cat "$COMMIT_MSG_FILE")
if ! echo "$CURRENT_MSG" | grep -q "$TICKET"; then
echo "[$TICKET] $CURRENT_MSG" > "$COMMIT_MSG_FILE"
fi
fi
exit 0This hook saves developers from manually typing the ticket number in every commit message. It extracts the ticket reference from the branch name and prepends it automatically. The developer can still edit the message in their editor, but the ticket number is already there.
Setting Up Husky for Team-Wide Hooks
The .git/hooks/ directory is not tracked by Git, which means hooks are not shared when someone clones the repository. Husky solves this by storing hook definitions in tracked files and installing them automatically when dependencies are installed.
# Install Husky as a dev dependency
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory)
npx husky init
# The init command adds a prepare script to package.json:
# "prepare": "husky"
# Create a pre-commit hook
echo "npx lint-staged" > .husky/pre-commit
# Create a commit-msg hook
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
# Create a pre-push hook
echo "npm test" > .husky/pre-push
# Install lint-staged for running linters on staged files only
npm install --save-dev lint-staged
# Add lint-staged configuration to package.json
# "lint-staged": {
# "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
# "*.{json,md,yml}": ["prettier --write"]
# }With Husky, every developer who runs npm install automatically gets the hooks installed. The hook definitions live in the .husky/ directory which is committed to the repository. This ensures consistent enforcement across the entire team without requiring manual setup.
Lint-Staged for Fast Pre-Commit Checks
Running linters on the entire codebase during pre-commit is slow and discouraging. Lint-staged solves this by running linters only on the files that are staged for commit. This keeps the hook fast regardless of project size.
# Install lint-staged
npm install --save-dev lint-staged
# Configuration in package.json
# "lint-staged": {
# "*.{ts,tsx}": [
# "eslint --fix --max-warnings=0",
# "prettier --write"
# ],
# "*.css": ["prettier --write"],
# "*.md": ["prettier --write --prose-wrap=always"]
# }
# The pre-commit hook simply runs:
# npx lint-staged
# lint-staged will:
# 1. Get the list of staged files
# 2. Match them against the configured patterns
# 3. Run the specified commands on matching files
# 4. Re-stage any files that were modified by --fix
# 5. Exit non-zero if any command failsThe combination of Husky and lint-staged is the industry standard for JavaScript and TypeScript projects. It provides fast, focused pre-commit checks that do not slow down the development workflow while ensuring that every committed file meets the team's quality standards.
Real-World Use Cases
Git hooks and automation workflows solve practical problems that teams encounter daily in their development process. The following examples demonstrate how organizations use these techniques to enforce quality standards and streamline repetitive tasks.
Enforcing Code Coverage Thresholds
A team wants to ensure that code coverage never drops below 80%. A pre-push hook runs the test suite with coverage and rejects the push if the threshold is not met.
#!/bin/sh
# .husky/pre-push - Enforce coverage threshold
echo "Running tests with coverage..."
npx vitest run --coverage --coverage.thresholds.lines=80
if [ $? -ne 0 ]; then
echo "Coverage threshold not met. Add tests before pushing."
exit 1
fiThis prevents coverage regressions from reaching the remote repository. Combined with CI pipeline checks, it provides defense in depth: the hook catches problems locally before they waste CI resources.
Preventing Secrets from Being Committed
Accidentally committing API keys, passwords, or tokens is a common security incident. A pre-commit hook can scan staged files for patterns that look like secrets.
#!/bin/sh
# Scan for potential secrets in staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=d)
# Patterns that suggest secrets
SECRET_PATTERNS="(PRIVATE_KEY|api_key|secret_key|password|token).*=.*['\"][^'\"]{8,}"
if echo "$STAGED_FILES" | xargs grep -lE "$SECRET_PATTERNS" 2>/dev/null; then
echo "ERROR: Potential secrets detected in staged files."
echo "Review the files above and remove any sensitive values."
echo "Use environment variables or a secrets manager instead."
exit 1
fi
# Check for common secret file patterns
if echo "$STAGED_FILES" | grep -qE "\.(pem|key|env\.local|env\.production)$"; then
echo "ERROR: Attempting to commit a file that likely contains secrets."
echo "Add it to .gitignore instead."
exit 1
fi
exit 0This hook is a first line of defense. It does not replace proper secrets management, but it catches the most common mistakes before they enter the repository history where they are difficult to remove completely.
Automatic Changelog Generation
Teams using Conventional Commits can generate changelogs automatically from commit messages. A post-merge hook or a CI step can trigger changelog generation after merges to main.
#!/bin/sh
# .husky/post-merge - Generate changelog after merging to main
BRANCH=$(git symbolic-ref --short HEAD)
if [ "$BRANCH" = "main" ]; then
echo "Generating changelog..."
npx conventional-changelog -p angular -i CHANGELOG.md -s
git add CHANGELOG.md
git commit -m "docs: update changelog" --no-verify
fiThe --no-verify flag on the changelog commit prevents the hook from triggering recursively. This is a legitimate use of the bypass flag: the automated commit is generated from validated data and does not need to pass through the same checks as human-authored commits.
Branch Name Validation
Enforce branch naming conventions so that the prepare-commit-msg hook can reliably extract ticket numbers and so that the repository stays organized.
#!/bin/sh
# .husky/pre-push - Validate branch name before pushing
BRANCH=$(git symbolic-ref --short HEAD)
# Allow main, develop, and release branches
if echo "$BRANCH" | grep -qE "^(main|develop|release/.+)$"; then
exit 0
fi
# Require feature, bugfix, hotfix, or chore prefix with ticket number
PATTERN="^(feature|bugfix|hotfix|chore)/[A-Z]+-[0-9]+-[a-z0-9-]+$"
if ! echo "$BRANCH" | grep -qE "$PATTERN"; then
echo "ERROR: Branch name does not follow naming convention."
echo "Expected: type/TICKET-123-short-description"
echo " type: feature|bugfix|hotfix|chore"
echo " Example: feature/PROJ-456-add-user-search"
echo "Your branch: $BRANCH"
exit 1
fi
exit 0This ensures every branch in the remote follows a consistent pattern, making it easy to understand what each branch is for and which ticket it relates to. Combined with branching strategies, naming conventions keep the repository organized as the team grows.
Best Practices
Keep hooks fast. A pre-commit hook that takes more than 5 seconds will frustrate developers and lead them to use --no-verify habitually. Use lint-staged to limit checks to staged files only. Run the full test suite in pre-push rather than pre-commit. If type checking is slow, consider running it only on changed files or moving it to pre-push.
Provide clear error messages. When a hook rejects an operation, the developer needs to know exactly what failed and how to fix it. Print the specific file, line, or rule that triggered the failure. Include examples of the correct format for commit messages or branch names.
Allow legitimate bypasses. The --no-verify flag exists for a reason. Sometimes a developer needs to commit a work-in-progress to switch context, or push an urgent hotfix that cannot wait for the full test suite. Document when bypassing is acceptable and trust your team to use it responsibly. The CI pipeline provides a second layer of enforcement for anything that slips through.
Use core.hooksPath for monorepos. In a monorepo with multiple packages, you might want different hooks for different packages. Set core.hooksPath to a tracked directory that contains the hooks, and use the hook scripts to determine which package is affected and run the appropriate checks.
# Configure a custom hooks directory
git config core.hooksPath .githooks
# Now hooks are read from .githooks/ instead of .git/hooks/
# This directory can be committed and sharedTest your hooks. Hooks are code and should be tested like any other code. Create a test script that simulates the conditions your hook checks for and verifies that the hook correctly accepts or rejects them. This prevents hooks from breaking silently after a refactoring.
Version your hook dependencies. If your hooks depend on tools like ESLint, Prettier, or commitlint, pin their versions in package.json. A hook that works with ESLint 8 might fail with ESLint 9 if the configuration format changed. Consistent versions across the team prevent "works on my machine" problems.
Common Mistakes
Running hooks on the entire codebase instead of staged files makes pre-commit unbearably slow on large projects. A developer who stages one file should not wait for the linter to check thousands of files. Always scope pre-commit checks to staged files using git diff --cached --name-only or lint-staged.
Not handling the --no-verify bypass in CI leads to a false sense of security. Hooks are a convenience, not a guarantee. Any developer can bypass them with --no-verify. Your CI pipeline must run the same checks independently so that bypassed commits are caught before they reach the main branch. Think of hooks as the first line of defense and CI as the authoritative gate.
Modifying files in hooks without re-staging them causes confusion. If your pre-commit hook runs prettier --write to auto-format files, those formatted changes are not automatically staged. Lint-staged handles this correctly by re-staging modified files, but custom hooks must do it explicitly with git add.
Installing hooks that depend on tools not yet installed causes failures for new team members. If your pre-commit hook runs npx eslint but the developer has not run npm install yet, the hook fails with a confusing error. Husky's prepare script solves this by installing hooks as part of npm install, ensuring dependencies are available before hooks run.
Writing hooks that are too strict drives developers to bypass them constantly. If every commit requires passing the full test suite, developers will use --no-verify for quick iterations and forget to remove it for the final commit. Match the strictness to the hook type: pre-commit should be fast and focused (lint, format), pre-push can be more thorough (tests, type check).
Not accounting for merge commits and rebases in commit-msg hooks causes false rejections. Merge commits have auto-generated messages that do not follow Conventional Commits format. Rebase operations replay commits that already passed the hook. Always check the commit source argument and skip validation for merges, squashes, and amends where appropriate.
Forgetting to make hook files executable is the most basic mistake. Git will silently ignore a hook file that does not have execute permission. Always run chmod +x on your hook files, or use Husky which handles permissions automatically.
# Make a hook executable (required for custom hooks)
chmod +x .git/hooks/pre-commit
# Verify the hook is executable
ls -la .git/hooks/pre-commit
# Should show: -rwxr-xr-xServer-Side Hooks
While this guide focuses on client-side hooks, server-side hooks deserve mention because they provide enforcement that cannot be bypassed. Server-side hooks run on the Git server (GitHub, GitLab, Gitea, or a bare repository) and can reject pushes that violate policies.
The three main server-side hooks are pre-receive (runs before any refs are updated), update (runs once per ref being updated), and post-receive (runs after all refs are updated). Pre-receive and update can reject the push by exiting non-zero.
Server-side hooks are configured by repository administrators and cannot be bypassed by developers. They are the authoritative enforcement layer for policies like branch protection, commit signing requirements, and file size limits. Combined with client-side hooks that provide fast feedback, they create a complete quality enforcement system.
For teams using hosted platforms like GitHub or GitLab, server-side hook functionality is exposed through branch protection rules, required status checks, and push rules rather than raw hook scripts. These provide the same enforcement with a more user-friendly configuration interface.
Integration with Docker and CI
Hooks interact with containerized development environments and CI pipelines in specific ways that require attention.
When developing inside Docker containers, hooks run on the host machine by default because Git operations happen on the host. If your hooks depend on tools installed only inside the container, you need to either install those tools on the host or configure the hooks to run inside the container using docker exec.
#!/bin/sh
# Pre-commit hook that runs linting inside a Docker container
docker exec -i dev-container npx eslint $(git diff --cached --name-only --diff-filter=d | grep -E '\.(ts|tsx)$')In CI pipelines, hooks are typically disabled because the CI system runs its own checks independently. Most CI environments do not run npm install with the prepare script, or they set HUSKY=0 to skip hook installation. This is correct behavior: CI should run checks explicitly rather than relying on hooks that are designed for the developer workflow.
# Disable Husky in CI environments
# In your CI configuration:
export HUSKY=0
npm ciSummary
Git hooks automate quality enforcement at the point where it matters most: before bad code enters the repository. Pre-commit hooks catch lint errors and formatting issues instantly. Commit-msg hooks enforce message conventions that keep the log readable and enable automated tooling. Pre-push hooks run tests as a final gate before code leaves the developer's machine.
Use Husky and lint-staged to share hooks across your team reliably and keep pre-commit checks fast. Provide clear error messages so developers know exactly what to fix. Allow bypasses with --no-verify for legitimate situations but back up hooks with CI pipeline checks that cannot be bypassed. Keep hooks focused and fast so developers view them as helpful guardrails rather than obstacles.
Combined with consistent branching strategies, clean commit history practices, and thorough debugging tools, Git hooks complete the automation layer that lets teams ship quality code with confidence while spending less time on manual enforcement and code review nitpicks.