Git & Collaboration Workflows

Git Hooks & Automation

18 min Lesson 8 of 28

Git Hooks & Automation

Every time you run git commit or git push, Git checks .git/hooks/ for executable scripts with well-known names. If a matching script exists, Git runs it at that lifecycle point. This hook system — baked directly into the Git client and server with zero external dependencies — is the mechanism top engineering organizations use to encode their conventions directly into the developer workflow: stopping bad commits before CI, enforcing message standards company-wide, and triggering cross-team notifications on protected branches.

Client-Side vs. Server-Side Hooks

Git hooks split into two families with fundamentally different trust models:

  • Client-side hooks run on the developer machine, triggered by local operations: committing, merging, rebasing. They live in .git/hooks/ (never committed to the repo), so every developer must install them manually — or a framework manages this. Critically, any client hook can be bypassed with --no-verify.
  • Server-side hooks run on the remote (GitHub, GitLab, or a self-hosted bare repo). They enforce policy for the entire team regardless of local setup. A server-side pre-receive hook that rejects non-linear history cannot be bypassed with --no-verify.
Key rule: Never rely on client-side hooks as your only safety net. They give fast local feedback, but policy must be enforced server-side or in CI. The right mental model: client hooks catch issues in under a second; CI and server hooks are the authoritative gate.

Key Client-Side Hooks

  • pre-commit — runs before the commit message editor opens. Use for linting, formatting, and secret scanning. Exit non-zero to abort.
  • prepare-commit-msg — runs after the template is prepared but before the editor opens. Use to auto-inject ticket IDs or branch names into messages.
  • commit-msg — receives the path to the commit message file as $1. Validate message format here (Conventional Commits, JIRA keys, character limits).
  • post-commit — runs after the commit completes. Exit code is ignored. Use for local notifications or cache invalidation.
  • pre-push — runs before data is sent to the remote. Use for running the full test suite or blocking pushes to protected branches from local machines.

Key Server-Side Hooks

  • pre-receive — runs once when the push arrives, before any refs are updated. All pushed refs arrive on stdin as <old-sha> <new-sha> <refname>. Reject here to abort the entire push with a message to the developer.
  • update — runs once per ref being updated. Use for per-branch policies (block force-push to main, require signed commits on release branches).
  • post-receive — runs after all refs are updated. Use for triggering CI, sending Slack notifications, or updating issue trackers. Exit code is ignored.

The pre-commit Framework

Writing raw shell hooks for every repository is fragile and hard to share. The pre-commit framework (pre-commit.com) solves this: hooks are declared in a YAML file committed to the repo, fetched from upstream repositories, and cached locally. This is the standard approach at companies running hundreds of repositories.

# Install the framework once per machine pip install pre-commit # or: brew install pre-commit # After cloning any repo that has .pre-commit-config.yaml, run once: pre-commit install # Manually run all hooks against all files: pre-commit run --all-files # Update all hook versions to latest and pin them: pre-commit autoupdate

A production-grade .pre-commit-config.yaml for a Python/Node repo:

# .pre-commit-config.yaml — commit this file to the repository repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-json - id: check-merge-conflict - id: detect-private-key # catches bare private keys - repo: https://github.com/psf/black rev: 24.4.2 hooks: - id: black language_version: python3.12 - repo: https://github.com/pre-commit/mirrors-eslint rev: v9.3.0 hooks: - id: eslint files: \.(js|ts|jsx|tsx)$ additional_dependencies: - eslint@9.3.0 - repo: https://github.com/zricethezav/gitleaks rev: v8.18.4 hooks: - id: gitleaks name: Detect secrets and credentials
Pro practice: Always pin hooks to a specific rev (a tag or full SHA), never a branch name. A floating branch means a third-party update can silently change your lint rules and break every developer on Monday morning. Run pre-commit autoupdate in a dedicated PR so changes are reviewed and intentional.

Enforcing Commit Conventions with commit-msg

Conventional Commits is the de-facto standard at DevOps-mature organizations: every commit must follow <type>(<scope>): <description> — for example, feat(auth): add OAuth2 PKCE flow. This structure enables automated changelog generation, semantic versioning, and meaningful git history. The commit-msg hook enforces it locally:

#!/usr/bin/env bash # .githooks/commit-msg (tracked in repo; activated via core.hooksPath) COMMIT_MSG_FILE="$1" COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") # Allow merge commits and fixup/squash commits to pass through if echo "$COMMIT_MSG" | grep -qE "^(Merge|Revert|fixup!|squash!)"; then exit 0 fi PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .{1,100}$" if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then echo "" echo "ERROR: Commit message does not follow Conventional Commits format." echo "Expected: feat(scope): short description" echo "Types: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert" echo "" exit 1 fi SUBJECT=$(echo "$COMMIT_MSG" | head -1) if [ "${#SUBJECT}" -gt 72 ]; then echo "ERROR: Subject line exceeds 72 characters (${#SUBJECT} chars)." exit 1 fi

Distributing Hooks Across a Team

Because .git/hooks/ is never committed, distributing hooks requires an explicit strategy. Three options in order of preference:

  1. pre-commit framework — hooks declared in .pre-commit-config.yaml (committed), installed by running pre-commit install. Best for cross-language teams and hook reuse.
  2. core.hooksPath — since Git 2.9, set git config core.hooksPath .githooks to point Git at a tracked directory. Simpler for pure-shell hooks; configure it in your project bootstrap script.
  3. Makefile bootstrap — a make setup target that symlinks scripts from a tracked directory into .git/hooks/. Common in legacy repos but fragile under upgrades.
Production pitfall: Do not set core.hooksPath in your global git config — it will apply to every repository on the machine and break unrelated projects that do not have that directory. Set it per-project in a bootstrap script or Makefile target that runs once after git clone.

Hook Pipeline: The Full Picture

Git hook execution sequence from local commit to remote enforcement Developer Machine git commit stage files pre-commit lint · format · secrets commit-msg Conventional Commits pre-push run test suite ✗ abort commit ✗ abort commit ✗ abort push git push Remote (GitHub / GitLab / Bare) pre-receive policy enforcement update per-branch rules post-receive trigger CI · notify Slack ✗ reject push ✗ reject ref
Git hook execution sequence: client-side hooks catch issues in milliseconds; server-side hooks enforce team-wide policy that cannot be bypassed locally.

Performance and Bypass Discipline

Slow hooks destroy developer experience. The pre-commit stage should complete in under 3 seconds. Strategies: run linters in parallel with --fork options, use incremental checks (lint only staged files, not the whole tree), and keep expensive checks in pre-push rather than pre-commit. For secret scanning, tools like gitleaks and trufflehog are fast enough for pre-commit on most repos.

On the bypass question: git commit --no-verify skips both pre-commit and commit-msg. This is intentional — genuine emergencies exist. The correct policy is: allow it, log it. A post-commit hook or CI step can detect that a commit bypassed hooks by checking whether it follows your conventions, and flag it in the pull request review. Banning bypass entirely produces workarounds that are harder to audit.