Git Hooks & Automation
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-receivehook that rejects non-linear history cannot be bypassed with--no-verify.
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 tomain, 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.
A production-grade .pre-commit-config.yaml for a Python/Node repo:
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:
Distributing Hooks Across a Team
Because .git/hooks/ is never committed, distributing hooks requires an explicit strategy. Three options in order of preference:
- pre-commit framework — hooks declared in
.pre-commit-config.yaml(committed), installed by runningpre-commit install. Best for cross-language teams and hook reuse. - core.hooksPath — since Git 2.9, set
git config core.hooksPath .githooksto point Git at a tracked directory. Simpler for pure-shell hooks; configure it in your project bootstrap script. - Makefile bootstrap — a
make setuptarget that symlinks scripts from a tracked directory into.git/hooks/. Common in legacy repos but fragile under upgrades.
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
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.