DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

A Practical Guide to Git Hooks for High-Quality, Reproducible Builds

A Practical Guide to Git Hooks for High-Quality, Reproducible Builds

A Practical Guide to Git Hooks for High-Quality, Reproducible Builds

Version control is more than just committing and pushing code. Git hooks let you automate quality gates, enforce standards, and ensure reproducible builds at every stage of your workflow. This tutorial walks you through choosing the right hooks, implementing reliable scripts, integrating with CI, and avoiding common pitfalls. By the end, you’ll have a lightweight, dependable hook system that protects your codebase without slowing you down.

Why hooks matter

  • Enforce code quality before changes enter shared history.
  • Catch mistakes early (lint errors, test failures, sensitive data exposure).
  • Promote reproducible builds by standardizing environment setup and install steps.
  • Reduce human error in collaboration-heavy projects. ### Pick the right hooks to start

Focus on a small, effective set first. The most useful, low-friction starting points are:

  • pre-commit: run linting, tests, or formatting before a commit is created.
  • commit-msg: enforce commit message conventions (structure, scope, references).
  • pre-push: run full test suite and lightweight checks before pushing.
  • prepare-commit-msg: automatically prefill or augment commit messages.
  • post-merge: clean up or re-run hooks after merging.

As you mature, you can add more hooks for release workflows, tag validation, or repository-wide checks.

Local setup: enable and organize hooks

1) Locate the hooks directory

  • In your repo, Git hooks live in .git/hooks.
  • By default, these are sample scripts ending in .sample.

2) Use a dedicated tool to manage hooks

  • Over time, direct shell scripts become hard to maintain. Consider:
    • pre-commit framework (pre-commit.com) for portable, shareable hooks.
    • Husky (for JavaScript/TypeScript projects) to run hooks in npm scripts.
    • simple Makefile targets or a small Node/Python utility to orchestrate checks.

3) Create a versioned hook workflow

  • Do not rely on .git/hooks being preserved when cloning. Keep your hooks under version control, and symlink or bootstrap them on install.
  • Common pattern: have a script in dev-tools/hooks/setup.sh that copies/links the active hooks into .git/hooks and makes them executable.

Example structure:

  • .git/hooks/ (generated)
  • .githooks/ (versioned hook scripts)
    • pre-commit
    • commit-msg
    • pre-push
  • dev-tools/hooks/bootstrap.sh

    Implementing a solid pre-commit workflow

Goals:

  • Catch lint/style issues
  • Run unit tests selectively
  • Ensure no secrets slip into commits
  • Enforce consistent formatting

What to include:

  • Linting (e.g., ESLint, golint, flake8)
  • Type/compile checks (e.g., TypeScript tsc, Rust cargo check)
  • Tests that run fast (unit tests)
  • Security checks for secrets (grep for AWS keys, tokens)

Basic pre-commit script (bash) outline:

  • Stop on error: set -e
  • Run formatters (e.g., prettier, black) if available
  • Run linters
  • Run unit tests on changed files only (or a tiny subset)
  • Scan for secrets

Example (pseudo-code-style):

!/usr/bin/env bash

set -euo pipefail

echo "Running pre-commit checks..."

Formatters

if command -v prettier >/dev/null; then
echo "Running prettier..."
npx prettier check "*/.{js,ts,css,md}"
fi

if command -v black >/dev/null; then
echo "Running black..."
python -m black check .
fi

Linters

if command -v eslint >/dev/null; then
echo "Running ESLint..."
npx eslint .
fi

if command -v flake8 >/dev/null; then
echo "Running flake8..."
flake8 .
fi

Secret scanning

if command -v git-secrets >/dev/null; then
echo "Scanning for secrets..."
git secrets scan
fi

Tests (fast subset)

if [ -f package.json ]; then
if npm test silent if-present watchAll=false; then
echo "Tests passed"
else
echo "Tests failed"
exit 1
fi
fi

echo "Pre-commit checks passed."
exit 0

Notes:

  • Make the script idempotent and fast. Prefer incremental checks rather than full-suite when possible.
  • Use environment variables to toggle heavy checks (e.g., SKIP_TESTS=1). ### Enforcing commit message conventions

A clean, consistent git history helps reviews and release notes. A common standard is Conventional Commits or Angular commit message format.

Rules to enforce:

  • type(scope): subject
  • Example: feat(auth): add 2FA flow
  • Footer for issues or breaking changes

Commit-msg hook example (bash):

!/usr/bin/env bash

set -euo pipefail

VALID_MSG_REGEX="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\$$[a-z0-9-]+\$$)?: .{1,72}$"

MSG=$(cat "$1")
if [[ ! $MSG =~ $VALID_MSG_REGEX ]]; then
echo "Invalid commit message. Use: type(scope): short description"
echo "Examples: feat(auth): add login with OAuth"
exit 1
fi

Pre-push checks: a lightweight, reliable gate

Pre-push should be quick and deterministic. Typical checks:

  • Run unit tests or a subset
  • Ensure build succeeds (if a compiled language)
  • Verify that dependencies are not unexpectedly upgraded (lockfile unchanged)

Example pre-push script:

!/usr/bin/env bash

set -euo pipefail

echo "Running pre-push checks..."

Build check (if applicable)

if [ -f package.json ]; then
npm ci silent
npm run build silent || { echo "Build failed"; exit 1; }
fi

Run unit tests (short)

if [ -f package.json ]; then
npm test silent watchAll=false || { echo "Tests failed"; exit 1; }
fi

echo "Pre-push checks passed."
exit 0

Integrating with CI for reproducible builds

Hooks are local quality gates. CI is where you enforce them across all PRs and forks.

  • Mirror pre-commit checks in CI jobs (lint, tests, secrets scan)
  • Run the full test matrix to ensure cross-platform correctness
  • Pin tool versions in CI (use package.json.lock, poetry.lock, go.sum, etc.)
  • Cache dependencies to speed up builds (GitHub Actions: actions/cache)

Example GitHub Actions snippet (lint and test):
name: Lint and Test
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm run lint || npm run lint:fix
- run: npm test

Notes:

  • Keep CI deterministic and fast. Fail CI if hooks would have blocked a commit in the local environment.

    Secrets and sensitive data: keeping your repo clean

  • Never commit credentials, tokens, or secret keys.

  • Use environment variables or secret managers in CI/CD.

  • Add a robust secret-scanning tool to your pre-commit and CI pipelines.

Tools:

  • git-secrets
  • detect-secrets
  • truffleHog
  • GitHub Advanced Security (for secrets scanning in PRs)

Hook practice:

  • Run a secrets scan on every commit and push.
  • If a secret is detected, abort the commit/push and prompt remediation. ### Testing hooks themselves

Hooks are code too. Treat them as part of the codebase.

  • Write unit tests for your hook logic (where practical).
  • Store test data in a separate test-data directory.
  • Use a small test harness to run hooks locally in isolation.
  • Validate cross-platform compatibility (bash vs zsh on macOS, Windows Subsystem for Linux, etc.).

Tip:

  • Use a dedicated test script: npm run test-hooks or pytest tests/hooks/ ### Example workflow: end-to-end hook setup

1) Create versioned hooks in .githooks/

  • pre-commit: your pre-commit script
  • commit-msg: your commit-msg script
  • pre-push: your pre-push script

2) Bootstrap script to install hooks

!/usr/bin/env bash

set -euo pipefail
HOOKS_DIR="$(pwd)/.githooks"
TARGET_DIR=".git/hooks"

rm -f "$TARGET_DIR"/pre-commit "$TARGET_DIR"/commit-msg "$TARGET_DIR"/pre-push

ln -s "$HOOKS_DIR"/pre-commit "$TARGET_DIR"/pre-commit
ln -s "$HOOKS_DIR"/commit-msg "$TARGET_DIR"/commit-msg
ln -s "$HOOKS_DIR"/pre-push "$TARGET_DIR"/pre-push

echo "Git hooks installed."
3) Run bootstrap on clone
bash dev-tools/hooks/bootstrap.sh

4) Try a commit and push, fix issues as they arise.

Common pitfalls and how to avoid them

  • Hooks slowing you down: optimize for speed, run heavy checks in CI or on-demand.
  • Platform differences: test hooks on macOS, Linux, and Windows (WSL) to catch path or shell issues.
  • Hooks drifting from repo state: keep them under version control and automate bootstrap.
  • Over-automation: start small and gradually add checks as your confidence grows.

    Quick-start checklist

  • [ ] Decide on a minimal hook set: pre-commit, commit-msg, pre-push

  • [ ] Move hooks into a version-controlled .githooks directory

  • [ ] Implement a fast pre-commit with formatting, linting, and fast tests

  • [ ] Add commit-message validation for consistency

  • [ ] Configure pre-push to run a quick test/build

  • [ ] Integrate with CI to mirror local checks

  • [ ] Add secret-scanning to pre-commit and CI

  • [ ] Document the workflow for your team and automate bootstrap
    If you’d like, I can tailor a minimal, language-specific hook suite for your project (e.g., Node, Python, or Go) and generate ready-to-use scripts plus a bootstrap workflow you can drop into your repo. Tell me your primary tech stack and any constraints (CI platform, preferred linting tools, or commit message conventions).

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)