Annotated Git Hooks: Automating Your Local Workflow with a Practical, Portable Hook System
Annotated Git Hooks: Automating Your Local Workflow with a Practical, Portable Hook System
When teams talk about Git workflows, they often fixate on branch models and CI/CD pipelines. But the real power of version control happens locally, where small, well-placed automation can prevent mistakes, speed up routine tasks, and enforce personal or team conventions without nagging prompts. This tutorial shows you how to build a practical, portable set of Git hooks that you can reuse across projects, share in a repo, and customize for your own workflows. You’ll learn what hooks are, how to structure them, how to write robust scripts, and how to test and maintain them so they don’t become a source of frustration.
Outline
- What are Git hooks and why use them locally
- Planning a portable hook suite
- Setting up a reusable hook framework
- Core hooks to improve daily workflow
- Safeguards and error handling
- Testing hooks locally
- Sharing hooks across projects
- Practical examples: pre-commit checks, commit message guidance, auto-formatting, and post-merge validation
- Versioning and maintenance tips
What are Git hooks and why use them locally
- Git hooks are scripts placed in the .git/hooks directory that run at specific points in the Git lifecycle (pre-commit, commit-msg, post-commit, pre-push, etc.).
- Hooks run locally on your machine, before or after commands, allowing you to enforce checks, format code, or prompt for decisions.
- They are easy to customize but can be brittle if not written carefully. The key is portability and safety: don’t rely on environment-specific paths, and make hooks fail closed (prevent bad actions) or pass-through when appropriate.
Planning a portable hook suite
- Focus on universal value: ensure commits are clean, code is formatted, tests pass, and essential metadata (like commit messages) is meaningful.
- Make hooks decoupled: each hook should be a small script with a clear purpose; allow enabling/disabling per project.
- Favor cross-platform scripts: write in a language most developers have, such as Bash with POSIX sh, or Node.js for more complex logic. If you must, provide plain shell scripts with clear shebangs and portability notes.
- Provide a simple configuration layer: a .githooks/config.json or .git/hooks-config/. This lets you enable/disable checks and specify tool paths without editing each script.
- Ensure non-destructive defaults: hooks should not modify code unless explicitly intended (e.g., auto-formatting should be opt-in or require a separate command).
Setting up a reusable hook framework
- Create a central place to store shared hooks (e.g., in a common repository or a submodule, or as a local directory that’s symlinked into .git/hooks).
- Standardize the interface: each hook script should return a non-zero exit code on failure and print a concise error message.
- Use a small bootstrap script to wire hooks into a project’s .git/hooks directory, or maintain a wrapper script to install hooks from a template.
Example structure
- .githooks/
- config.json
- pre-commit
- commit-msg
- pre-push
- post-merge
- scripts/
- ensure_clean_worktree.sh
- run_tests.sh
- format_code.sh
- check_commit_message.sh
- ensure_dependencies.sh
- Install script (e.g., .git/hooks/install-hooks.sh) copies or symlinks the hooks into .git/hooks and makes them executable.
Core hooks to improve daily workflow
- pre-commit: ensure a clean worktree, run formatters, lint, run tests, and verify dependencies are installed.
- commit-msg: enforce a minimal conventional-commit-like message style or a project-specific prefix.
- pre-push: run the test suite to catch issues before pushing.
- post-merge: run a quick rebase or re-run tests if needed after a merge.
In-code example (Bash)
- pre-commit (basic) #!/usr/bin/env bash set -euo pipefail
Load config
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE}")" && pwd)"
BASE_DIR="$(git rev-parse show-toplevel)"
CONFIG="$BASE_DIR/.githooks/config.json"
printerr() { echo "hook error: $1" >&2; }
Stop early if not in a git repo
if ! git rev-parse is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi
1) Ensure a clean working tree
if ! git diff-index quiet HEAD ; then
printerr "Working tree not clean. Please commit or stash changes."
exit 1
fi
2) Run formatter (if available)
if command -v prettier >/dev/null 2>&1; then
echo "Running prettier..."
if ! npm run format silent; then
printerr "Code formatting failed. Run 'npm run format' locally."
exit 1
fi
fi
3) Run tests (optional)
if [ -f package.json ] && jq -e '.scripts.test' package.json >/dev/null 2>&1; then
echo "Running tests..."
if ! npm test silent; then
printerr "Tests failed."
exit 1
fi
fi
exit 0
- commit-msg (minimal conventional-commit) #!/usr/bin/env bash set -euo pipefail
COMMIT_MSG_FILE="$1"
COMMIT_MSG="$(cat "$COMMIT_MSG_FILE" | head -n 1)"
Example rule: require a scope and a subject
e.g., feat(auth): add login flow
if ! echo "$COMMIT_MSG" | grep -E -q '^[a-z]+$$.*$$: .+'; then
echo "ERROR: Commit message does not follow the convention: type(scope): subject"
echo "Example: feat(auth): add login flow"
exit 1
fi
exit 0
- pre-push (run tests) #!/usr/bin/env bash set -euo pipefail if command -v npm >/dev/null 2>&1 && [ -f package.json ]; then echo "Running test suite before push..." npm test silent || { echo "Tests failed. Aborting push."; exit 1; } fi exit 0
Safeguards and error handling
- Fail-safe defaults: if a hook cannot run due to missing tools, either skip gracefully or fail fast with a clear message. Avoid halting critical flows unexpectedly on teammates’ machines unless strictly required.
- Non-destructive behavior: avoid auto-committing or auto-staging unless explicitly configured. If you auto-format, present changes to the user and require a follow-up commit.
- Clear logging: print helpful messages with a consistent prefix (e.g., [hook:pre-commit]) so users can skim logs quickly.
Testing hooks locally
- Create a dedicated test repository to validate hook logic without risking real work.
- Simulate various scenarios: clean vs dirty worktree, commit with good vs bad messages, hooks failing or passing.
- Use set -e and trap errors to surface meaningful output in test runs.
Sharing hooks across projects
- Use a single source of truth: host a small “hook library” repo (GitHub/GitLab) and pull it as a submodule or use a package manager (e.g., a tiny npm package or Python package) to install into projects.
- Use a .githooks/config.json to opt-in per project: { "enabled": true, "pre-commit": true, "commit-msg": true, "pre-push": true }
- On new projects, run a setup script that pulls hooks and creates symlinks: ./install-hooks.sh. This minimizes drift and ensures consistency.
Practical examples: concrete hooks you can adopt
- Pre-commit: enforce that all files are formatted and linted; ensure no TODOs remain in code that’s being committed.
- Commit-msg: enforce a conventional commit format or project-specific prefix guidelines.
- Pre-push: run unit tests and linting to catch regressions before teammates pull.
- Post-merge: re-run a quick project status to remind developers to pull the latest changes and run tests.
Example: ensuring a clean working tree and formatting
- pre-commit combines two checks:
- git diff-index quiet HEAD to ensure a clean state
- run a formatter and re-check for changes; if formatting changed files, fail with guidance to add and commit those changes
Notes on portability and maintenance
- Document how to install and customize hooks in a README within the central hook repository.
- Include OS-specific notes: if you rely on macOS or Windows differences (e.g., path handling, line endings), provide guidance or separate scripts per OS.
- Version your hook library, and tag releases so projects can lock to a known-good version.
Example: simple config and install script
- .githooks/config.json { "pre-commit": true, "commit-msg": true, "pre-push": true }
- .git/hooks/install-hooks.sh #!/usr/bin/env bash set -euo pipefail REPO_URL="https://example.com/your-hook-library.git" TARGET="$HOME/.githooks"
if [ ! -d "$TARGET" ]; then
git clone "$REPO_URL" "$TARGET"
fi
HOOKS_DIR="$TARGET/.githooks"
rm -f .git/hooks/*
ln -s "$HOOKS_DIR/pre-commit" .git/hooks/pre-commit
ln -s "$HOOKS_DIR/commit-msg" .git/hooks/commit-msg
ln -s "$HOOKS_DIR/pre-push" .git/hooks/pre-push
echo "Hooks installed from $REPO_URL"
This approach keeps your local workflow consistent while letting you tailor specifics per project.
Step-by-step guide to implement in your setup
1) Create a central hook repository or local folder with a minimal set of hooks (pre-commit, commit-msg, pre-push) and a clean, documented interface.
2) Write portable scripts with robust error handling and helpful messages.
3) Add a configuration file to each project to opt into the hooks and tune behavior.
4) Add an install script to clone or copy the hooks into the project’s .git/hooks directory or to configure a symlink to a shared hook location.
5) Test hooks in a safe repository to verify they behave as intended before enabling them in real work.
6) Document how to customize, extend, or disable hooks for individual projects or teammates.
Recommended starter hooks to implement first
- pre-commit: enforce code formatting and a quick lint pass
- commit-msg: enforce a conventional-style message
- pre-push: run tests and lint to catch breaking changes early
Illustration: a practical scenario
- You begin a new feature branch. Before you can commit, the pre-commit hook runs:
- checks that your workspace is clean
- runs a formatter (e.g., Prettier or Black)
- runs a fast linter (e.g., ESLint or Flake8)
- You fix any issues revealed by the hooks, then the commit proceeds.
- Before you push, pre-push runs the full test suite; if tests fail, you fix them locally before sharing your work.
If you want, I can tailor a starter hook suite for your typical tech stack (language, formatter, linter, and test runner) and provide a ready-to-run set of scripts plus a concise README for setup in your projects. Would you like me to customize this for your environment (e.g., Node.js with ESLint/Prettier and Jest, or Python with Black/Flake8 and PyTest)?
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)