Git Automation & CI/CD Hook Engineering: Production-Ready Workflow Architecture Jump to heading
Modern software delivery depends on deterministic, repeatable version-control workflows. When enforcement is inconsistent — different developers running different linters, commit messages that drift from convention, secrets committed because no one checked — the cost accumulates in broken builds, audit failures, and merge conflicts that compound across sprints. Hook engineering is the discipline that closes that gap: it inserts validation at precise lifecycle events so that policy is enforced automatically, not aspirationally.
Platform teams must draw a clear boundary between validation layers. Client-side hooks on each developer’s workstation handle fast, local feedback: formatting, syntax, commit-message structure, and lightweight secret scanning. Remote CI/CD pipelines own integration testing, supply-chain auditing, and deployment gates. Blurring that boundary — running full integration tests in a pre-commit hook, or re-running formatters in CI — wastes compute, lengthens feedback cycles, and trains developers to route around hooks with --no-verify.
This page covers the foundational architecture, the canonical configuration patterns, and the failure modes that trip up teams at scale. The child pages go deep on each layer: local hook configuration with Husky, lint-staged file filtering, pre-push validation rules, and CI/CD pipeline trigger mapping. All configurations target Git v2.30+ and standard POSIX environments.
Architecture Overview: How the Hook Layers Fit Together Jump to heading
The diagram below shows the full lifecycle of a code change, from git commit through to a deployed build, with each validation gate labelled by where it executes and what it enforces.
Each gate is responsible for a distinct class of validation. No gate duplicates another. This is the principle that keeps hook chains fast and maintainable as repositories grow.
Core Concepts Jump to heading
1. Centralized Hook Management with core.hooksPath Jump to heading
Git’s default .git/hooks directory is not tracked by version control. Each clone starts with sample templates, not real hooks. Teams that rely on manual setup or post-clone instructions produce per-developer drift within weeks.
The core.hooksPath configuration redirects Git to a version-controlled directory, so hook scripts are reviewed, updated, and distributed like any other source file:
# Create the version-controlled hooks directory
mkdir -p .githooks
chmod 755 .githooks
# Point the local repository at it
git config --local core.hooksPath .githooks
# Confirm the configuration
git config --get core.hooksPath
# .githooks For teams using Husky, the framework sets core.hooksPath automatically to .husky/ during npx husky init. For polyglot stacks, the pre-commit Python framework manages its own hook installation via pre-commit install. Either way, the principle is the same: one source of truth, version-controlled, reviewed in pull requests.
SAFETY WARNING: When switching
core.hooksPathon an existing repository, any hooks already present in.git/hooks/become silently inactive. Audit.git/hooks/before migrating and move any active scripts to the new location.
2. Pre-Commit Validation: Fast, Staged-File-Scoped Checks Jump to heading
The pre-commit hook runs after git add and before the commit object is created. It receives no arguments from Git, but it can inspect the index directly. The practical constraint is time: anything exceeding two seconds of wall-clock duration trains developers to use --no-verify.
The correct scope for pre-commit is the staged diff — not the entire repository. Lint-staged implements this precisely by extracting the list of staged files and piping them into each configured tool:
# package.json → lint-staged configuration
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{py}": ["ruff check --fix", "black"],
"*.{yaml,yml}": ["yamllint -d relaxed"]
}
} For polyglot repositories, the pre-commit Python framework provides cross-language hook management with dependency isolation and caching:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: local
hooks:
- id: custom-lint
name: Run domain linter
entry: ./scripts/lint.sh
language: system
types: [python]
pass_filenames: true
require_serial: false require_serial: false permits parallel execution for hooks without shared state. Profile with GIT_TRACE=1 time git commit -m "test" to measure real-world hook latency.
3. Pre-Push Validation: Policy Enforcement Before Network Synchronization Jump to heading
The pre-push hook runs after commits are created but before the network write. It receives ref information on stdin — one line per ref being pushed, containing local_ref local_sha remote_ref remote_sha. This makes it the right place for checks that are too slow for pre-commit but too important to defer to CI: secret scanning, Conventional Commits history validation, and dependency vulnerability audits.
The pre-push validation rules layer is not a substitute for CI. It is a fast-fail that prevents the most costly mistakes from entering the remote repository at all:
#!/usr/bin/env bash
# .githooks/pre-push — Git v2.30+ / POSIX
set -euo pipefail
CONVENTIONAL_COMMITS_RE='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+'
while read -r local_ref local_sha remote_ref remote_sha; do
# Skip branch deletions (all-zero sha)
[ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue
# Determine the range of new commits
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
range="$local_sha"
else
range="${remote_sha}..${local_sha}"
fi
while read -r commit; do
msg=$(git log -1 --format=%s "$commit")
if ! echo "$msg" | grep -qE "$CONVENTIONAL_COMMITS_RE"; then
echo "ERROR: Commit $commit has a non-conforming message:" >&2
echo " $msg" >&2
exit 1
fi
done < <(git rev-list "$range")
done
# Lightweight secret scan on the diff being pushed
gitleaks detect --source . --no-git --exit-code 1 2>/dev/null || {
echo "ERROR: Potential secrets detected. Review with: gitleaks detect --report-format json" >&2
exit 1
}
exit 0 SAFETY WARNING: This hook, like all client-side hooks, can be bypassed with
git push --no-verify. Treat it as defence-in-depth, not a security boundary. Server-side checks in CI cannot be bypassed by individual developers and must be the authoritative enforcement layer.
4. CI/CD Trigger Mapping: Complementing Local Hooks Without Duplication Jump to heading
Webhook payloads from the remote Git host trigger distributed pipeline execution. The key design decision is path-based filtering: run only the jobs relevant to the files that changed. Without filtering, every push triggers a full pipeline regardless of scope, burning minutes on irrelevant test suites.
CI/CD pipeline trigger mapping covers the routing logic in detail. The principle is that CI validates integration, not formatting. A local hook failing on a syntax error is a developer-speed issue; a CI pipeline failing on a syntax error means the hook was bypassed, which is a process issue:
# GitHub Actions — selective pipeline triggers
on:
push:
branches: [main, "release/**"]
paths:
- "src/**"
- "tests/**"
- "package.json"
pull_request:
branches: [main]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run integration tests
run: ./scripts/run-integration-tests.sh
- name: Supply-chain audit
run: npm audit --omit=dev --audit-level=high
- name: SAST scan
uses: github/codeql-action/analyze@v3 Path filters are additive: a change to src/api/auth.ts triggers the API tests; a change to docs/README.md does not. For optimizing CI triggers for path-specific changes, the rule is: keep filters narrow and tested; a misconfigured filter that skips a test suite is a silent failure.
Configuration Reference Jump to heading
| Flag / Command | Default | Effect | When to change |
|---|---|---|---|
core.hooksPath | .git/hooks | Redirects Git hook lookup to the specified directory | Set to .githooks/ or .husky/ in every repository to enable version-controlled hooks |
lint-staged --concurrent | true | Runs linter tasks in parallel | Set to false if any linter has global side effects or writes to shared files |
pre-commit install --hook-type | pre-commit | Installs a specific hook type in addition to pre-commit | Add commit-msg when enforcing Conventional Commits; add pre-push for secret scanning |
husky --init | — | Initialises .husky/ directory and sets core.hooksPath | Run once per repository; re-run after switching Node versions if hooks stop firing |
GIT_TRACE | unset | Prints internal Git operations to stderr | Set to 1 to profile hook execution timing |
--no-verify | disabled | Bypasses all client-side hooks for a single git operation | Document any legitimate use in team runbook; alert on CI if pushed commits were authored with it |
git config --global init.templateDir | ~/.git-templates | Copies template files (including hooks) into every new clone | Use to seed a minimal .git/hooks/README warning users to install the hook manager |
Team Rollout Patterns Jump to heading
Adopting hook engineering at scale requires more than distributing a configuration file. Teams that skip the onboarding phase end up with a two-tier codebase: contributors who have hooks installed and those who do not.
Onboarding Checklist Jump to heading
Enforcement Strategies Jump to heading
Client-side hooks are advisory for contributors without them installed. The enforcement model must assume that any hook can be bypassed and compensate at the remote layer:
- Hook manager as development dependency — Husky’s
preparelifecycle script runs automatically afternpm install. This makes hook installation the default for any developer who follows standard Node.js setup. - CI re-runs the fastest local checks — run
eslintandprettier --checkin CI as a lightweight gate. This catches bypassed hooks without adding significant pipeline time. - Branch protection backs everything — use the remote host’s branch protection to require status checks on
main. No hook bypass can circumvent a required CI check. - Interactive rebase before merge — for teams using squash-merge workflows, a pre-merge rebase step in CI can re-validate the final commit message format even if individual commits passed through without Conventional Commits formatting.
CI/CD Integration Touchpoints Jump to heading
The hand-off between local hooks and remote CI is explicit:
- Local (pre-commit): formatting, syntax, import sorting, end-of-file fixers
- Local (commit-msg): Conventional Commits format check
- Local (pre-push): lightweight secret scan, dependency audit (fast mode)
- Remote CI (on PR): integration tests, SAST, full dependency vulnerability audit, container image scanning
- Remote CI (on merge to main): deployment pipeline, smoke tests, change-log generation from Conventional Commits history
Common Failure Modes & Diagnostics Jump to heading
Hooks Not Running After Clone Jump to heading
Symptom: A new contributor reports that pre-commit and commit-msg hooks do not fire. Root cause: core.hooksPath is not set in their local repository config, and the hook manager was not installed because the prepare script was not run. Fix:
# For Husky (Node.js)
npm install # triggers prepare → husky init
git config --get core.hooksPath # should return .husky
# For pre-commit (Python/polyglot)
pre-commit install
pre-commit install --hook-type commit-msg
git config --get core.hooksPath # should return .git/hooks or framework path Hook Silently Exits 0 on Windows Jump to heading
Symptom: Hooks run without error on Linux/macOS but are silently skipped on Windows developer machines. Root cause: Shebang line #!/usr/bin/env bash is not honoured by Git for Windows without Git Bash in PATH. Line endings may also corrupt the script. Fix:
# Confirm line endings are LF in .gitattributes
echo ".githooks/* text eol=lf" >> .gitattributes
git add .gitattributes && git commit -m "fix: enforce LF line endings on hook scripts"
# Or use a hook manager (Husky, pre-commit) which handles cross-platform execution --no-verify Commits Accumulating on Protected Branches Jump to heading
Symptom: CI catches formatting violations or Conventional Commits failures on pull requests even though hooks should have caught them locally. Root cause: Developers are using --no-verify routinely, or the hook manager is not installed consistently. Fix: Add a CI step that re-runs the fastest local checks. Log --no-verify usage with a post-commit hook that appends the commit SHA to a team audit log.
Pre-Push Hook Timing Out in CI Jump to heading
Symptom: The pre-push hook runs correctly locally but times out when the CI system runs a lint check during a simulated push. Root cause: CI environments often have limited network access, and tools like npm audit make network requests. Hook timeout defaults vary by Git version. Fix:
# Use offline mode for audits in CI where network is restricted
npm audit --omit=dev --prefer-offline
# Or skip the pre-push hook in CI and rely on the explicit CI audit step
GIT_PUSH_OPTION_COUNT=0 git push # does not skip hooks; use CI-specific config instead Path Filters Silently Excluding Required Test Jobs Jump to heading
Symptom: A refactor of shared utility code passes CI because the changed files were in lib/ and the test path filter only watches src/. Root cause: Path-based trigger filters were written to optimise for speed but not maintained as the repository structure evolved. Fix: Add an integration test that validates the CI configuration itself. For GitHub Actions, use act to dry-run workflows locally. Audit path filters in every paths: block quarterly or on any directory restructure.
Frequently Asked Questions Jump to heading
What is the difference between client-side and server-side Git hooks? Jump to heading
Client-side hooks (pre-commit, commit-msg, pre-push) run on the developer’s machine and can be bypassed with --no-verify. Server-side hooks (pre-receive, update, post-receive) run on the remote host after a push arrives and cannot be bypassed by the pushing developer. Critical policy — branch naming, signed-commit requirements, tag immutability — must live in server-side hooks or CI branch-protection rules, not only in client-side hooks.
Can Git hooks be skipped? Jump to heading
Any client-side hook can be skipped with git commit --no-verify or git push --no-verify. This is intentional — hooks must not permanently block a developer from committing in an emergency. The correct response is to make bypassing visible (log it, flag it in CI) and to ensure all critical checks also exist in CI pipelines that cannot be bypassed.
What is core.hooksPath and why use it? Jump to heading
core.hooksPath redirects Git from the default .git/hooks/ directory to any path you specify, including one tracked by version control. This allows hook scripts to be reviewed, updated, rolled back, and shared through ordinary git workflows. Without it, each developer’s hooks live only in their local .git/ directory and are never synchronized with the team.
How do I prevent pre-commit hooks from slowing down developer workflows? Jump to heading
Target a two-second wall-clock budget. Use lint-staged to scope checks to staged files only; avoid scanning the full repository on every commit. Run independent linters in parallel. Cache tool outputs where the framework supports it. Profile with GIT_TRACE=1 time git commit -m "test: profile". Move anything that consistently exceeds two seconds into CI.
Should local hooks duplicate CI/CD checks? Jump to heading
No. Local hooks provide fast, developer-facing feedback on the immediate change. CI validates integration — how the change behaves in combination with everything else in the codebase. Duplicating CI checks locally wastes compute and makes hooks slow enough that developers disable them. The CI/CD pipeline trigger mapping guide covers how to draw the boundary precisely.
How do I enforce hooks across a team without trusting each developer’s machine? Jump to heading
Use a hook manager as a development dependency so installation is automatic. Back all critical policy with server-side hooks or CI branch-protection rules. Treat local hooks as a developer-experience improvement, not a security control. See local hook configuration with Husky for initialization patterns that make setup automatic.
Related Jump to heading
- Local Hook Configuration with Husky — initialize Husky v9, wire
core.hooksPath, and share hooks across your team viapackage.json - Lint-Staged & Formatting Automation — configure staged-file filtering so formatters and linters run only on changed files, keeping pre-commit hooks under two seconds
- Pre-Push Validation Rules — build a pre-push hook that enforces Conventional Commits, scans for secrets, and aligns with remote branch-protection policies
- CI/CD Pipeline Trigger Mapping — design deterministic webhook routing and path-based filters so remote pipelines complement rather than duplicate local hooks
- Migrating from Legacy Git Hooks to Husky v9 — step-by-step migration from hand-managed
.git/hooks/scripts to Husky-managed hooks with full team rollout