Git Internals for Engineers

Hooks and Automation

A team adds prettier --write to their CI. Every PR with unformatted code fails the check. Engineers fix, push again, wait 8 minutes for CI, fix, wait, fix. They are frustrated. Someone suggests adding prettier to a pre-commit hook — now formatting happens automatically when they commit, and CI almost never fails on it. Productivity jumps. The whole debate is whether work should happen locally (hooks) or in the pipeline (CI). The right answer is usually both, at different layers.

Git hooks are shell scripts that Git runs at specific moments in the commit/push lifecycle. They are ideal for fast feedback — catch issues in seconds, on the developer's machine, before they are ever pushed. CI is for the slow, thorough, authoritative checks. This lesson covers the hook types, how to install them, the pre-commit framework that makes them team-wide, and the trade-offs with CI.


The Hook Types

Git hooks are shell scripts in .git/hooks/. They fire at specific moments. The useful ones:

HookWhen it runsTypical use
pre-commitBefore the commit is createdLinting, formatting, fast tests
prepare-commit-msgBefore the commit message editor opensAuto-populate messages (e.g., include ticket ID)
commit-msgAfter the message is writtenValidate message format (Conventional Commits)
post-commitAfter commit is createdNotifications, side effects (rare)
pre-pushBefore push to remoteRun tests, prevent pushes to main
pre-rebaseBefore rebase startsRefuse rebase of protected branches
post-mergeAfter pull/merge completesAuto-rebuild dependencies, migrations
pre-receive, post-receive, updateServer-side (on remotes)Enforce policies on push target

Client-side hooks (pre-commit, commit-msg, pre-push) run on the developer's machine. Server-side hooks (pre-receive) run on the remote (rare in modern workflows; managed platforms have PR-based controls instead).


Creating a Hook Manually

# Each hook is a script at .git/hooks/<name>
# Git ships sample hooks with a .sample suffix
ls .git/hooks/
# applypatch-msg.sample  pre-commit.sample  pre-push.sample  ...

# Create a real hook by saving the file without .sample
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
# Refuse commits that contain "TODO" without a ticket number
if git diff --cached | grep -E '^\+.*\bTODO\b[^#]*$' > /dev/null; then
  echo "TODO without ticket number. Add 'TODO(TICKET-123):'"
  exit 1
fi
EOF

chmod +x .git/hooks/pre-commit

# Now every commit runs this check
git commit -m "test"
# If a staged line added 'TODO foo', the commit fails with the message

Key properties

  • Executable. chmod +x required.
  • First line is a shebang. #!/bin/sh, #!/bin/bash, #!/usr/bin/env python3, etc.
  • Exit code decides. 0 = allow; non-zero = block.
  • Stdout/stderr are visible to the user.
  • .git/hooks/ is local. Not cloned, not pushed. Each developer must install their own.

That last point is the problem: hooks do not travel with the repo. A hook you write solves it for you, but not for the team. Enter the pre-commit framework.


The pre-commit Framework

pre-commit is a Python tool (installable via pip or Homebrew) that makes hook management repo-level and team-shareable:

# Install once
pip install pre-commit
# or: brew install pre-commit

Each repo has a .pre-commit-config.yaml:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
        args: ['--maxkb=500']

  - repo: https://github.com/psf/black
    rev: 24.3.0
    hooks:
      - id: black

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.0
    hooks:
      - id: ruff
        args: [--fix]

Install into the repo:

pre-commit install
# Installs a hook at .git/hooks/pre-commit that runs the framework

Now every commit automatically runs:

  • trailing-whitespace — strip trailing whitespace from modified lines.
  • end-of-file-fixer — ensure files end with a newline.
  • check-yaml — validate YAML syntax.
  • check-added-large-files — block commits with files > 500 KB.
  • black — auto-format Python.
  • ruff — lint Python, auto-fix what it can.

Why this is a big deal

  1. Config in the repo. .pre-commit-config.yaml is committed. Everyone who clones gets it.
  2. One command to activate. pre-commit install — a README line.
  3. Hook versions pinned. rev: v0.3.0 makes builds reproducible.
  4. Thousands of hooks available. Every popular linter and formatter has a pre-commit hook.
  5. Run on CI too. pre-commit run --all-files in CI ensures forgotten installs do not bypass checks.
# Go
- repo: https://github.com/dnephin/pre-commit-golang
  rev: v0.5.1
  hooks:
    - id: go-fmt
    - id: go-vet
    - id: go-mod-tidy

# JavaScript / TypeScript
- repo: https://github.com/pre-commit/mirrors-prettier
  rev: v4.0.0-alpha.8
  hooks:
    - id: prettier

- repo: https://github.com/pre-commit/mirrors-eslint
  rev: v9.0.0
  hooks:
    - id: eslint

# Security
- repo: https://github.com/gitleaks/gitleaks
  rev: v8.18.0
  hooks:
    - id: gitleaks

# General
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v5.0.0
  hooks:
    - id: detect-private-key
    - id: no-commit-to-branch
      args: [--branch, main]    # refuse commits directly to main
KEY CONCEPT

The pre-commit framework + .pre-commit-config.yaml is the standard professional answer to "how do we share Git hooks?" It turns hooks from a personal tool into a team-wide one, with version-pinned, CI-verified, easy-to-install checks. Adopting it is a single-digit number of minutes and pays off for years.


Hooks vs CI: Where Each Fits

CheckPre-commit hookCI
Fast lint / format✓ immediate feedback✓ as a safety net
Unit tests (< 10s)SometimesAlways
Full test suite (minutes)✗ too slow locally
Build / compileDepends on speed
Security scans✓ for quick ones (secrets)✓ for deep ones (CVE scans)
Code style✓ auto-fix on commit✓ verify in CI
Commit message format✓ (via commit-msg hook)✓ (redundant check)
Integration tests
Deploy / release

The layers principle

  • Pre-commit: fast, local, blocks obviously-wrong work before it enters the repo. Optional to bypass with --no-verify for emergencies.
  • Pre-push: slower local checks before work hits the remote. Useful for "run tests before I push."
  • CI: authoritative, thorough, required. Whatever passes CI is what merges.

Hooks are convenience; CI is policy. Hooks speed up the dev loop; CI enforces quality at the merge gate. Do both.

--no-verify is the escape hatch

git commit --no-verify -m "hotfix: emergency rollback"
git push --no-verify

Skips client-side hooks. Use for emergencies. CI still runs — you cannot merge broken code just by skipping hooks locally.


The commit-msg Hook

Validates the commit message after you write it:

# .git/hooks/commit-msg
#!/bin/sh
# Enforce Conventional Commits format: type(scope): subject

commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")

pattern='^(feat|fix|docs|style|refactor|test|chore|perf|build|ci)(\(.+\))?: .{1,72}'

if ! echo "$commit_msg" | head -n 1 | grep -qE "$pattern"; then
    echo "Commit message does not match Conventional Commits format."
    echo "Example: feat(auth): add login endpoint"
    exit 1
fi

Or via pre-commit / commitizen:

- repo: https://github.com/compilerla/conventional-pre-commit
  rev: v3.2.0
  hooks:
    - id: conventional-pre-commit
      stages: [commit-msg]

This catches bad messages at commit time (not at push / PR time). Fast feedback, no wasted context switches.


The pre-push Hook

Runs before git push. Good place for:

  • Running unit tests ("do not push broken code").
  • Preventing push to protected branches ("do not push to main").
  • Running scans that are too slow for every commit but fast enough for pre-push.
# .git/hooks/pre-push
#!/bin/sh
protected_branch='main'
current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')

if [ "$current_branch" = "$protected_branch" ]; then
    echo "Pushing directly to $protected_branch is not allowed."
    exit 1
fi

# Run tests
npm test || { echo "Tests failed; not pushing"; exit 1; }

Server-Side Hooks (Rare Today)

On self-hosted Git servers (GitLab, Gitea, self-hosted GitHub Enterprise, raw git init --bare), you can install server-side hooks that run on the remote:

  • pre-receive — runs before a push is accepted. Reject bad pushes.
  • update — per-ref equivalent, runs once per pushed branch.
  • post-receive — runs after accepting. Trigger CI, send notifications.

Example: reject pushes with commits not matching a policy:

# /path/to/bare-repo.git/hooks/pre-receive
#!/bin/sh
while read oldrev newrev refname; do
    for commit in $(git rev-list $oldrev..$newrev); do
        msg=$(git log -1 --format=%s $commit)
        if ! echo "$msg" | grep -qE '^(feat|fix)'; then
            echo "Commit $commit has non-compliant message."
            exit 1
        fi
    done
done

On managed platforms (GitHub.com, GitLab.com, Bitbucket Cloud), server-side hooks are not accessible. Use branch protection rules, required status checks, and PR review requirements instead — they accomplish the same goals with better UX.


Auto-Formatting Workflows

The sweet spot for hooks: auto-fixers that edit your code in place on commit.

# .pre-commit-config.yaml
- repo: https://github.com/psf/black
  rev: 24.3.0
  hooks:
    - id: black

- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.3.0
  hooks:
    - id: ruff
      args: [--fix]

When you commit:

  1. pre-commit runs black on staged files.
  2. If black made changes, the hook "fails" and shows what it fixed.
  3. You git add the updated files and re-commit.
  4. Second time, black has nothing to change; commit proceeds.

To reduce friction, add pre-commit's auto-stage:

- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.3.0
  hooks:
    - id: ruff-format    # equivalent to ruff 0.3+; reformats in-place and stages

Many teams configure auto-format hooks with always_run: true so every commit is guaranteed-formatted.

PRO TIP

Auto-formatters in pre-commit are a productivity win. Engineers stop thinking about style entirely. Code reviews stop debating formatting. CI never fails on lint. The one-time cost (adopt a formatter, reformat the whole repo, add .git-blame-ignore-revs) is paid back in days.


Dealing With Long-Running Hooks

Some checks (type-check, full lint, security scan) take 30+ seconds. Too slow for every commit. Options:

1. Run only on changed files

- id: mypy
  files: \.py$
  # (default — only runs on staged .py files)

pre-commit runs each hook only against files matching its files pattern. Much faster than "lint the whole repo."

2. Move to pre-push instead of pre-commit

- id: mypy
  stages: [pre-push]
  # runs on `git push`, not every commit

3. Move to CI entirely

Some checks belong in CI, not as hooks. Full build, integration tests, security scans over the whole tree — these are CI's job.

4. Parallel execution

pre-commit runs hooks in parallel by default when possible. For faster overall time, enable:

default_language_version:
  python: python3.11
# ...
- id: fast-hook
  # no special args
- id: another-fast-hook
  # no special args
# Both will run in parallel

CI-Side pre-commit

Even with hooks installed locally, engineers can --no-verify. CI should re-run the checks to catch bypasses:

# .github/workflows/ci.yml
- name: Run pre-commit
  run: |
    pip install pre-commit
    pre-commit run --all-files --show-diff-on-failure

This is the belt-and-suspenders pattern: hooks speed up development; CI enforces policy.


Custom Hooks In .pre-commit-config.yaml

For project-specific checks:

- repo: local
  hooks:
    - id: check-database-migrations
      name: Check database migrations
      entry: scripts/check-migrations.sh
      language: script
      files: ^migrations/

    - id: no-print-in-production
      name: No print() in production code
      entry: bash -c 'grep -rn "print(" src/ && exit 1 || exit 0'
      language: system
      pass_filenames: false

Any executable is a valid hook. Languages supported: python, node, ruby, go, rust, docker, system (shell), script (a path in the repo).


Hook-Skipping Workflow

Sometimes you genuinely need to commit without hooks (disaster recovery, half-finished work stash):

# Skip hooks once
git commit --no-verify -m "wip"
git push --no-verify

# Skip via env var (for scripted use)
SKIP=ruff,black git commit -m "temp: skip formatters this time"

# Skip entirely (revert with pre-commit install)
pre-commit uninstall

Teams sometimes disable hooks during large refactors ("we're reformatting everything; stop auto-formatting temporarily"). Re-enable afterward.


Security Hooks

Some of the highest-value hooks:

Secret detection

- repo: https://github.com/gitleaks/gitleaks
  rev: v8.18.0
  hooks:
    - id: gitleaks

Catches AWS keys, private SSH keys, API tokens, etc. in staged content before the commit. Prevents the ugly "I committed my GCP service account JSON" incident.

Large file detection

- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v5.0.0
  hooks:
    - id: check-added-large-files
      args: ['--maxkb=500']

Prevents accidental commits of huge binaries. For files that legitimately need to be tracked, use Git LFS.

Private key detection

- id: detect-private-key

Part of pre-commit-hooks. Catches -----BEGIN RSA PRIVATE KEY----- blobs.

WARNING

Secret-detection hooks are a safety net, not a guarantee. Sophisticated secrets (obfuscated tokens, entropy-encoded keys) may slip through. Combine with: GitHub secret scanning (free for public repos), TruffleHog in CI for deeper scans, and — most importantly — rotation discipline (if a secret was ever committed anywhere, rotate it; do not trust history rewrites).


Team Adoption

A realistic rollout:

Step 1: propose and agree

Discuss in a team meeting. Get buy-in on what hooks and why. Resistance: "another slow thing on my commit." Address with speed expectations and auto-fix positioning.

Step 2: baseline format

Run pre-commit run --all-files once; commit the reformat as a single commit. Add the SHA to .git-blame-ignore-revs:

# .git-blame-ignore-revs
abc123def456abcd  # Mass reformat

GitHub honors this file automatically in its blame UI. Locally, configure:

git config blame.ignoreRevsFile .git-blame-ignore-revs

Step 3: add to CI

# .github/workflows/ci.yml
- uses: pre-commit/action@v3.0.1

CI rejects PRs that fail hooks — enforces the policy even for engineers who forgot to pre-commit install.

Step 4: add to onboarding

The new-hire checklist should include pre-commit install. Or use direnv / Makefile / setup scripts to auto-install.

Step 5: evolve

Add more hooks as needs surface. Review quarterly — remove ones that are no longer adding value, add ones that address recent recurring issues.


Key Concepts Summary

  • Git hooks are shell scripts at specific lifecycle moments (pre-commit, commit-msg, pre-push, etc.).
  • Hooks are local (.git/hooks/); they do not travel with the repo by default.
  • The pre-commit framework (pre-commit.com) makes hooks team-shared via .pre-commit-config.yaml.
  • Hook types: pre-commit (fast, blocks bad commits), commit-msg (validate message), pre-push (pre-remote verification).
  • Hooks for speed, CI for authority. Both layers are valuable.
  • Auto-format on commit is the highest-value pattern — style debates disappear.
  • Secrets, large files, private keys are detectable via standard hooks.
  • --no-verify is the escape hatch for emergencies; CI catches bypasses.
  • Server-side hooks exist but are rare; platforms use branch protection instead.
  • .git-blame-ignore-revs preserves blame after mass reformat commits.

Common Mistakes

  • Writing personal hooks in .git/hooks/ without sharing. Pointless unless everyone has the same setup.
  • Making hooks too slow. 30-second pre-commit = engineers disable them. Keep fast; move slow checks to pre-push or CI.
  • Hooks without CI enforcement. --no-verify bypasses; CI is the real gatekeeper.
  • Not pinning hook versions (rev). Behavior changes across versions; builds become non-reproducible.
  • Applying reformat hooks without a baseline reformat. Every PR touches unrelated lines forever after.
  • Forgetting .git-blame-ignore-revs after a mass reformat. git blame points at the reformatter for every line.
  • Secret detection in hooks but no rotation policy. Secret leaks are not fixable just by detection; they need rotation.
  • Custom Python/Node hooks in .pre-commit-config.yaml without listing them as repo: local. Hook fails to find the tool.
  • Running pre-commit hooks on a CI machine without installing the tool. pip install pre-commit in CI setup, always.
  • Running style hooks on generated / vendored code. Exclude via exclude: ^vendor/|^gen/.

KNOWLEDGE CHECK

Your team wants to enforce that no commit message ever contains the word 'WIP' (to prevent work-in-progress commits landing on main). You want this applied to every team member without each person manually installing a hook. What is the professional approach?