Git Hooks

Git hooks for enforcing standards, running tests, and automating workflows at commit, push, and checkout time.

Hook Fundamentals

List available hook templates in a repo
ls .git/hooks/*.sample

Git ships sample hooks for every supported event. Remove the .sample suffix and make executable to activate.

Make a hook executable — hooks fail silently without this
chmod +x .git/hooks/pre-commit

Hooks must be executable. This is the number one reason a hook "doesn’t work." There is no error message — git silently skips non-executable hooks.

Hook exit codes — the contract
# Exit 0 = allow the operation to proceed
# Exit non-zero = abort the operation
# Hooks receive arguments via $1, $2, etc.
# Hooks receive stdin for some events (pre-receive, post-rewrite)

Pre-commit Hook

Lint staged files before commit — prevent bad code from entering history
#!/usr/bin/env bash
# .git/hooks/pre-commit

# Run shellcheck on staged .sh files
staged_sh=$(git diff --cached --name-only --diff-filter=ACM -- '*.sh')
if [[ -n "$staged_sh" ]]; then
    echo "$staged_sh" | xargs shellcheck || exit 1
fi

# Run Python linting on staged .py files
staged_py=$(git diff --cached --name-only --diff-filter=ACM -- '*.py')
if [[ -n "$staged_py" ]]; then
    echo "$staged_py" | xargs ruff check || exit 1
fi

--diff-filter=ACM catches Added, Copied, and Modified files — skips Deleted files (which would cause "file not found" errors in linters). This pattern scales to any language.

Prevent commits to main — force branch workflow
#!/usr/bin/env bash
# .git/hooks/pre-commit

branch=$(git rev-parse --abbrev-ref HEAD)
if [[ "$branch" == "main" || "$branch" == "master" ]]; then
    echo "ERROR: Direct commits to $branch are not allowed."
    echo "Create a feature branch: git checkout -b feature/your-change"
    exit 1
fi
Check for secrets before commit — catch leaked credentials
#!/usr/bin/env bash
# .git/hooks/pre-commit

patterns='(API_KEY|SECRET|PASSWORD|PRIVATE.KEY|aws_secret|token.*=.*[A-Za-z0-9]{20,})'
if git diff --cached --diff-filter=ACM -U0 | grep -iEq "$patterns"; then
    echo "ERROR: Potential secret detected in staged changes."
    echo "Review with: git diff --cached | grep -iE '$patterns'"
    exit 1
fi

Commit-msg Hook

Enforce conventional commit format
#!/usr/bin/env bash
# .git/hooks/commit-msg

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

if ! echo "$commit_msg" | head -1 | grep -Eq "$pattern"; then
    echo "ERROR: Commit message does not follow conventional format."
    echo "Expected: type(scope): description"
    echo "Types: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert"
    echo "Got: $commit_msg"
    exit 1
fi

The head -1 checks only the first line — the subject. Body and footer lines are unconstrained. The 72-character limit keeps subjects readable in git log --oneline.

Append ticket number from branch name
#!/usr/bin/env bash
# .git/hooks/commit-msg

branch=$(git rev-parse --abbrev-ref HEAD)
ticket=$(echo "$branch" | grep -oE '[A-Z]+-[0-9]+')

if [[ -n "$ticket" ]]; then
    # Only append if not already present
    if ! grep -q "$ticket" "$1"; then
        echo "" >> "$1"
        echo "Refs: $ticket" >> "$1"
    fi
fi

If your branch is feature/INFRA-342-vault-setup, this appends Refs: INFRA-342 to the commit message automatically. The guard prevents duplication on --amend.

Pre-push Hook

Run tests before push — catch failures before they hit CI
#!/usr/bin/env bash
# .git/hooks/pre-push

echo "Running tests before push..."
if command -v pytest &>/dev/null; then
    pytest --tb=short -q || exit 1
fi

Pre-push receives the remote name and URL as arguments ($1 and $2). Stdin receives lines of <local ref> <local sha> <remote ref> <remote sha> — useful for conditional logic based on which branch is being pushed.

Prevent force-push to protected branches
#!/usr/bin/env bash
# .git/hooks/pre-push

protected_branches="main master production"
current_branch=$(git rev-parse --abbrev-ref HEAD)

for branch in $protected_branches; do
    if [[ "$current_branch" == "$branch" ]]; then
        # Check if this is a force push
        while read -r local_ref local_sha remote_ref remote_sha; do
            if [[ "$local_sha" != "$(git merge-base "$local_sha" "$remote_sha" 2>/dev/null)" ]]; then
                echo "ERROR: Force push to $branch is not allowed."
                exit 1
            fi
        done
    fi
done

Post-checkout Hook

Rebuild dependencies after branch switch
#!/usr/bin/env bash
# .git/hooks/post-checkout
# $1 = previous HEAD, $2 = new HEAD, $3 = 1 if branch checkout (0 if file checkout)

prev_head="$1"
new_head="$2"
is_branch="$3"

[[ "$is_branch" -eq 0 ]] && exit 0  # Skip file checkouts

# Reinstall if lock file changed between branches
if ! git diff --quiet "$prev_head" "$new_head" -- package-lock.json 2>/dev/null; then
    echo "package-lock.json changed — running npm install..."
    npm install
fi

if ! git diff --quiet "$prev_head" "$new_head" -- uv.lock 2>/dev/null; then
    echo "uv.lock changed — running uv sync..."
    uv sync
fi

The $3 flag distinguishes branch checkouts from file checkouts (git checkout — file.txt). Without the guard, restoring a single file would trigger a full reinstall.

Post-merge Hook

Reinstall dependencies after pull — keep environment in sync
#!/usr/bin/env bash
# .git/hooks/post-merge
# $1 = squash flag (1 if squash merge, 0 otherwise)

changed_files=$(git diff-tree -r --name-only ORIG_HEAD HEAD)

if echo "$changed_files" | grep -q "package-lock.json"; then
    echo "Dependencies changed — running npm install..."
    npm install
fi

if echo "$changed_files" | grep -q "requirements.txt\|uv.lock"; then
    echo "Python dependencies changed — running uv sync..."
    uv sync
fi

ORIG_HEAD points to where HEAD was before the merge. git diff-tree between those two refs shows exactly what the merge brought in.

Shared Hooks

Set a shared hooks directory — version-control your hooks
git config core.hooksPath .githooks

This redirects git from .git/hooks/ to .githooks/ (or any path). Since .githooks/ lives in the working tree, it’s tracked by git. The entire team shares the same hooks.

Project setup with shared hooks directory
mkdir -p .githooks
cp .git/hooks/pre-commit.sample .githooks/pre-commit
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
Set shared hooks globally — apply to all repos
git config --global core.hooksPath ~/.config/git/hooks

Every repo on your machine uses these hooks. Individual repos can override with --local.

Bypassing Hooks

Skip hooks for a single operation — use deliberately
git commit --no-verify -m "WIP: checkpoint before refactor"
git push --no-verify

--no-verify skips pre-commit, commit-msg, and pre-push hooks. Legitimate uses: WIP commits, emergency hotfixes, commits where the hook is known-broken and you’re fixing it. Not legitimate: skipping because the linter found real problems.

--no-verify is a scalpel, not a hammer. If you’re using it regularly, your hooks are either too slow or catching real issues you should fix.

Hook Management Tools

Lefthook — fast, polyglot hook manager
# lefthook.yml
pre-commit:
  parallel: true
  commands:
    shellcheck:
      glob: "*.sh"
      run: shellcheck {staged_files}
    ruff:
      glob: "*.py"
      run: ruff check {staged_files}
    asciidoc-lint:
      glob: "*.adoc"
      run: vale {staged_files}
Install lefthook and activate hooks in the repo
lefthook install

Lefthook is a single binary with no runtime dependencies. It runs hooks in parallel by default, uses glob patterns for file filtering, and supports {staged_files} interpolation. Faster than Husky (no Node.js required) and simpler than pre-commit (no Python virtualenvs).

Debugging Hooks

Test a hook manually without committing
bash -x .git/hooks/pre-commit

bash -x traces every command as it executes. If the hook exits non-zero, you see exactly which line failed.

Check if hooks are being skipped — verify hooksPath
git config core.hooksPath

If this returns a path, git is using that directory instead of .git/hooks/. Empty output means the default .git/hooks/ is active.

List all hooks currently installed
find "$(git rev-parse --git-dir)/hooks" -type f -executable ! -name '*.sample'

Shows only active hooks — executable files without the .sample suffix. If this returns nothing, no hooks are installed.

See Also

  • Config — core.hooksPath and shared hook setup

  • Branches — branch protection via pre-push hooks