Search…

Git hooks and automation

In this series (8 parts)
  1. Git internals: how Git actually works
  2. Everyday Git: the commands that matter
  3. Branching and merging
  4. Branching strategies for teams
  5. Git rebase and history rewriting
  6. Git hooks and automation
  7. Monorepos and large repo management
  8. GitOps

Git hooks are scripts that run automatically when specific Git events occur. They are the simplest way to automate quality checks before code reaches a remote repository. Combined with a shift-left security approach, hooks catch problems at the earliest possible moment.

How hooks work

Every Git repository has a .git/hooks/ directory. It contains sample scripts with a .sample extension. Remove the extension and make the file executable to activate a hook.

ls .git/hooks/
# pre-commit.sample  commit-msg.sample  pre-push.sample  ...

Hooks are executable files. They can be written in any language: Bash, Python, Node.js, Ruby. Git runs them and checks the exit code. Zero means success. Non-zero means failure, and Git aborts the operation.

Client-side hooks

Client-side hooks run on the developer’s machine. They are not pushed to the remote. Each developer must install them locally.

pre-commit

Runs before Git creates a commit. This is the most commonly used hook.

#!/bin/sh
# .git/hooks/pre-commit

# Run linter on staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')

if [ -n "$STAGED_FILES" ]; then
  npx eslint $STAGED_FILES
  if [ $? -ne 0 ]; then
    echo "Lint errors found. Fix them before committing."
    exit 1
  fi
fi

Common uses:

  • Run linters (ESLint, Pylint, Rubocop).
  • Format code (Prettier, Black, gofmt).
  • Check for debugging statements (console.log, debugger).
  • Scan for secrets (API keys, passwords).

commit-msg

Runs after the user writes the commit message. Receives the message file path as an argument.

#!/bin/sh
# .git/hooks/commit-msg

MSG_FILE=$1
MSG=$(cat "$MSG_FILE")

# Enforce conventional commits format
if ! echo "$MSG" | grep -qE '^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}'; then
  echo "Commit message must follow Conventional Commits format."
  echo "Example: feat(auth): add login endpoint"
  exit 1
fi

Enforcing commit message conventions automates changelog generation and semantic versioning. Tools like commitlint provide more sophisticated validation.

prepare-commit-msg

Runs before the commit message editor opens. Useful for prepopulating messages.

#!/bin/sh
# .git/hooks/prepare-commit-msg

# Prepend branch name as ticket reference
BRANCH=$(git symbolic-ref --short HEAD)
TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+')

if [ -n "$TICKET" ]; then
  sed -i.bak "1s/^/[$TICKET] /" "$1"
fi

If your branch is feature/PROJ-123-add-auth, the commit message starts with [PROJ-123] automatically.

pre-push

Runs before data is transferred to the remote. Useful for final validation.

#!/bin/sh
# .git/hooks/pre-push

# Run tests before pushing
npm test
if [ $? -ne 0 ]; then
  echo "Tests failed. Push aborted."
  exit 1
fi

Pre-push hooks are heavier than pre-commit hooks. Running a full test suite on every push is reasonable. Running it on every commit is too slow.

post-merge

Runs after a successful merge. Useful for installing dependencies.

#!/bin/sh
# .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

Server-side hooks

Server-side hooks run on the Git server (or hosting platform). They cannot be bypassed by clients.

pre-receive

Runs when the server receives a push. Can reject the entire push.

#!/bin/sh
# server: hooks/pre-receive

while read oldrev newrev refname; do
  # Prevent force pushes to main
  if [ "$refname" = "refs/heads/main" ]; then
    FORCED=$(git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; echo $?)
    if [ "$FORCED" -ne 0 ]; then
      echo "Force push to main is not allowed."
      exit 1
    fi
  fi
done

update

Similar to pre-receive but runs once per branch being updated. Allows granular per-branch policies.

post-receive

Runs after a push completes. Used for notifications, CI triggers, and deployments.

#!/bin/sh
# server: hooks/post-receive

while read oldrev newrev refname; do
  if [ "$refname" = "refs/heads/main" ]; then
    # Trigger deployment
    curl -X POST https://deploy.example.com/trigger \
      -H "Authorization: Bearer $DEPLOY_TOKEN"
  fi
done

The hook lifecycle

graph TD
A["Developer runs git commit"] --> B["pre-commit hook"]
B -->|pass| C["prepare-commit-msg hook"]
C --> D["User edits message"]
D --> E["commit-msg hook"]
E -->|pass| F["Commit created"]
F --> G["post-commit hook"]
B -->|fail| X["Commit aborted"]
E -->|fail| X
H["Developer runs git push"] --> I["pre-push hook"]
I -->|pass| J["Data sent to server"]
J --> K["pre-receive hook"]
K -->|pass| L["update hook"]
L -->|pass| M["Refs updated"]
M --> N["post-receive hook"]
I -->|fail| Y["Push aborted"]
K -->|fail| Y

The hook lifecycle from commit to push. Any hook that fails aborts its operation.

Managing hooks with Husky

The main problem with Git hooks: they live in .git/hooks/, which is not version-controlled. Every developer must install them manually. Husky solves this.

Setup

npm install --save-dev husky
npx husky init

This creates a .husky/ directory in your project root and configures Git to use it.

Adding hooks

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

# Create a commit-msg hook
echo "npx commitlint --edit \$1" > .husky/commit-msg

Since .husky/ is version-controlled, every developer gets the hooks when they clone the repo and run npm install.

lint-staged

Husky pairs well with lint-staged, which runs linters only on staged files.

{
  "lint-staged": {
    "*.{js,ts}": ["eslint --fix", "prettier --write"],
    "*.css": ["stylelint --fix"],
    "*.md": ["prettier --write"]
  }
}

This keeps pre-commit hooks fast. Only changed files are linted, not the entire codebase.

Secret scanning with hooks

Preventing secrets from entering the repository is critical. A pre-commit hook can scan for patterns.

#!/bin/sh
# Scan for common secret patterns

PATTERNS="AKIA[0-9A-Z]{16}|password\s*=\s*['\"][^'\"]+|-----BEGIN (RSA |EC )?PRIVATE KEY"

STAGED=$(git diff --cached --name-only)
for FILE in $STAGED; do
  if git show ":$FILE" | grep -qEi "$PATTERNS"; then
    echo "Potential secret detected in $FILE"
    exit 1
  fi
done

Tools like gitleaks and detect-secrets provide more comprehensive scanning with lower false positive rates.

# Using gitleaks as a pre-commit hook
npx gitleaks detect --staged --verbose

Bypassing hooks

Hooks can be skipped when necessary.

# Skip pre-commit and commit-msg hooks
git commit --no-verify -m "Emergency fix"

# Skip pre-push hook
git push --no-verify

Use --no-verify sparingly. If you find yourself using it often, the hooks need adjustment. They should be fast and accurate enough that skipping them is rare.

Hook best practices

Keep hooks fast. A pre-commit hook that takes 30 seconds will be bypassed constantly. Target under 5 seconds.

Use lint-staged. Run checks only on changed files.

Fail with clear messages. Tell the developer what went wrong and how to fix it.

Version-control hooks. Use Husky, pre-commit (Python), or Lefthook to share hooks across the team.

Layer your checks. Pre-commit for fast checks (lint, format). Pre-push for slower checks (tests). CI for everything else.

Alternatives to Husky

ToolLanguageNotes
HuskyJavaScriptMost popular in JS ecosystem
pre-commitPythonLanguage-agnostic hook framework
LefthookGoFast, config-driven, polyglot
overcommitRubyRuby ecosystem standard

All solve the same problem: making hooks portable and version-controlled.

What comes next

Hooks automate checks at the developer level. The next article covers monorepos and large repository management, where the challenges shift to scale. Sparse checkout, shallow clones, and Git LFS keep large repositories workable.

Start typing to search across all content
navigate Enter open Esc close