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.

ES
Edrees Salih
1 hour ago

We are still cooking the magic in the way!