Skip to content

Git Hooks

Git hooks are automation points that fire at specific moments in the Git workflow, like pre-commit checks that run linters before allowing a commit, or pre-push hooks that run tests before allowing a push. Client-side hooks enforce local conventions and catch issues early, while server-side hooks enforce repository-wide policies that cannot be bypassed. The hook’s exit code is the enforcement mechanism: zero means proceed, anything else means abort. Understanding hook lifecycle helps you build automated quality gates into your development process.

Git hooks are scripts that Git executes automatically before or after specific events in the Repository lifecycle — commits, pushes, rebases, checkouts, and so on. They live at the boundary Between your workflow and Git”s internal state machine, and they are the primary mechanism for Enforcing local policy without requiring a central server.

Hooks execute at precisely defined points in Git’s operation. There is no ambiguity about when they Fire: the hook name encodes the event, and Git invokes it synchronously at that exact point in the Control flow. A hook either succeeds (exit code 0) or fails (non-zero exit code). If a hook fails, Git aborts the operation — this is the entire enforcement mechanism.

Hooks are divided into two categories based on where they execute:

Client-side hooks run on the local machine performing the Git operation. They are triggered by Commands like git commit``git push``git checkoutAnd git rebase. Client-side hooks cannot Be enforced remotely — a user with filesystem access can always bypass them with --no-verify. Their purpose is convenience and local policy, not security.

Server-side hooks run on the remote repository when it receives a push. They are triggered by The git-receive-pack process. Server-side hooks are the only hooks that can be truly enforced, Because the remote administrator controls the filesystem. A malicious client cannot bypass a Server-side pre-receive hook.

Git discovers hooks through a well-defined search path:

  1. .git/hooks/. The default location. When you git init a repository, Git populates this directory with sample hook scripts (all suffixed with .sample so they do not execute). Git only looks for files that are exactly named after the hook event — no extensions, no suffixes. A file named pre-commit.sh will never run; it must be named pre-commit.

  2. core.hooksPath. A configuration override that points Git to an alternative directory. This is the mechanism that tools like Husky and Lefthook use to redirect hook execution to a managed directory:

Terminal window
## Point Git to a custom hooks directory
$ git config core.hooksPath .githooks
## Verify the override is in effect
$ git config core.hooksPath
.githooks

When core.hooksPath is set, Git does not fall through to .git/hooks/. The override is Absolute. If the directory does not exist or the named hook file is absent, Git skips the Hook (it does not error).

Every hook has a fixed name. Git iterates through the hooks directory and matches filenames against A hardcoded list. The naming is not configurable:

Hook NameTypeTrigger Point
pre-commitClientBefore commit, after staging
prepare-commit-msgClientBefore commit message editor
commit-msgClientAfter message written, pre-save
post-commitClientAfter commit completes
pre-rebaseClientBefore rebase begins
post-checkoutClientAfter checkout/switch
post-mergeClientAfter merge completes
pre-pushClientBefore push to remote
pre-receiveServerBefore refs are updated
updateServerOnce per ref being updated
post-receiveServerAfter refs are updated

The contract is simple: exit code 0 means success, anything else means failure. When a pre-hook (hooks prefixed with pre-) returns non-zero, Git halts the operation and prints the hook’s stderr. The user sees something like:

error: failed to push some refs to 'origin'
hint: Updates were rejected because the pre-push hook exited with error code 1.

Post-hooks (post-commit``post-receiveEtc.) are informational. If a post-hook fails, Git prints A warning but does not abort the operation — the commit or push has already succeeded.

HookArgumentsstdinFail Aborts?Typical Use
pre-commitNoneNoneYesLinting, formatting, tests on staged files
prepare-commit-msg$1=msg file, $2=source (message/template/merge/squash/commit), $3=SHA (if amend)NoneNoTemplate manipulation, ticket injection
commit-msg$1=path to commit message fileNoneYesMessage validation, conventional commits
post-commitNoneNoneNoNotifications, CI trigger
pre-rebase$1=upstream, $2=branch being rebasedNoneYesPrevent rebasing protected branches
post-checkout$1=prev HEAD, $2=new HEAD, $3=flag (1=branch, 0=file checkout)NoneNoEnvironment setup, submodule init
post-merge$1=flag (1=squash merge)NoneNoSubmodule update, dependency install
pre-pushNoneLines: <local ref> <local sha1> <remote ref> <remote sha1>YesTest suite, prevent force-push to main
pre-auto-gcNoneNoneYesPrevent automatic garbage collection

The pre-commit hook runs after git commit is invoked but before the commit object is Created. At this point, the staging area (index) is locked — you cannot modify it from within the Hook. The hook receives no arguments and reads no stdin. To inspect what is about to be committed, You must query the index directly:

#!/usr/bin/env bash
# pre-commit: run linters against staged files
# Get the list of staged files (not deleted ones)
staged=$(git diff --cached --name-only --diff-filter=d)
if [ -z "$staged" ]; then
exit 0
fi
echo "$staged" | grep '\.py$' | xargs python -m py_compile
if [ $? -ne 0 ]; then
echo "ERROR: Python syntax check failed."
exit 1
fi
echo "$staged" | grep '\.sh$' | xargs shellcheck
if [ $? -ne 0 ]; then
echo "ERROR: ShellCheck found issues in shell scripts."
exit 1
fi

The critical detail: pre-commit sees the staged content, not the working tree. If you modified A file after staging it, the hook inspects the staged version, not the working copy. This is by Design — the commit reflects the index, not the working tree.

This hook fires after the default commit message is prepared but before the editor is opened (or before the -m message is finalized). It receives the path to a temporary file containing the Message. You can modify this file in-place to inject content:

#!/usr/bin/env bash
# prepare-commit-msg: inject Jira ticket number from branch name
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
# Only inject for regular commits (not merges, squashes, amends, etc.)
if [ "$COMMIT_SOURCE" = "commit" ] || [ -z "$COMMIT_SOURCE" ]; then
BRANCH=$(git rev-parse --abbrev-ref HEAD)
TICKET=$(echo "$BRANCH" | grep -oE '[A-Z]+-[0-9]+')
if [ -n "$TICKET" ]; then
# Prepend ticket number to the first line
sed -i "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE"
fi
fi

The COMMIT_SOURCE argument tells you where the message came from:

  • message — passed via -m or --message
  • template — from .git/commit-template or commit.template config
  • merge — a merge commit
  • squash — a squash commit
  • commit — the default ( means the editor will open)

The commit-msg hook receives the path to the file containing the final commit message (after the Editor has closed or -m was used). This is your last chance to reject the commit based on message Content:

#!/usr/bin/env bash
# commit-msg: enforce Conventional Commits format
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Skip merge commits
if echo "$COMMIT_MSG" | head -1 | grep -q "^Merge "; then
exit 0
fi
# Enforce conventional commits: type(scope): description
if ! echo "$COMMIT_MSG" | head -1 | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+'; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo ""
echo "Expected: type(scope): description"
echo "Example: feat(auth): add OAuth2 login flow"
echo ""
echo "Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
exit 1
fi
# Enforce maximum subject line length
SUBJECT=$(echo "$COMMIT_MSG" | head -1)
if [ ${#SUBJECT} -gt 72 ]; then
echo "ERROR: Subject line exceeds 72 characters (${#SUBJECT} chars)."
exit 1
fi

This hook runs after the commit object is created and HEAD is updated. Since the commit is already Finalized, a non-zero exit cannot undo it. Use this for side effects:

#!/usr/bin/env bash
# post-commit: notify team channel, trigger CI
COMMIT_SHA=$(git rev-parse HEAD)
COMMIT_MSG=$(git log -1 --format=%s)
# Trigger a downstream CI pipeline
curl -s -X POST "https://ci.example.com/hooks/git" \
-d "{\"sha\": \"$COMMIT_SHA\", \"message\": \"$COMMIT_MSG\"}" \
> /dev/null 2>&1

The pre-rebase hook runs before git rebase begins. It receives two arguments: the upstream Branch and the branch being rebased. You can use it to prevent rebasing branches that should never Be rebased:

#!/usr/bin/env bash
# pre-rebase: prevent rebasing the main branch
UPSTREAM=$1
BRANCH=$2
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "ERROR: Rebasing the main branch is prohibited."
exit 1
fi

This hook fires after git checkout``git switchOr git clone. It receives the previous HEAD, The new HEAD, and a flag indicating whether it was a branch checkout (1) or a file checkout (0):

#!/usr/bin/env bash
# post-checkout: auto-initialize submodules on branch switch
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_FLAG=$3
# Only act on branch switches, not file checkouts
if [ "$BRANCH_FLAG" = "1" ]; then
# Initialize submodules if .gitmodules changed
DIFF=$(git diff --name-only "$PREV_HEAD" "$NEW_HEAD" -- .gitmodules 2>/dev/null)
if [ -n "$DIFF" ]; then
echo "Submodules changed — updating..."
git submodule update --init --recursive
fi
fi

This hook runs after a successful git merge. It receives a single argument: 1 if the merge was a Squash merge, 0 otherwise:

#!/usr/bin/env bash
# post-merge: update submodules and install dependencies
git submodule update --init --recursive
# If package.json changed, reinstall
MERGE_BASE=$(git merge-base HEAD HEAD@{1} 2>/dev/null)
CHANGED=$(git diff --name-only "$MERGE_BASE" HEAD -- package.json 2>/dev/null)
if [ -n "$CHANGED" ]; then
echo "package.json changed — running npm install..."
npm install
fi

The pre-push hook runs after the local objects have been packed and before they are sent to the Remote. It receives no arguments, but reads from stdin a series of lines, each formatted as:

<local ref> <local sha1> <remote ref> <remote sha1>

This makes it ideal for preventing destructive pushes:

#!/usr/bin/env bash
# pre-push: prevent force-push to main, run tests
while read local_ref local_sha remote_ref remote_sha; do
# Prevent force-push to main/master
if [ "$remote_ref" = "refs/heads/main" ] || [ "$remote_ref" = "refs/heads/master" ]; then
# A force-push is when the remote SHA is not an ancestor of the local SHA
if [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then
MERGE_BASE=$(git merge-base "$local_sha" "$remote_sha" 2>/dev/null)
if [ "$MERGE_BASE" != "$remote_sha" ]; then
echo "ERROR: Force-push to $remote_ref is not allowed."
exit 1
fi
fi
fi
done
# Run the test suite
echo "Running tests before push..."
if ! make test; then
echo "ERROR: Tests failed. Push aborted."
exit 1
fi

Server-side hooks live in the bare repository on the remote. They execute within the git-receive-pack process on the server. A client cannot bypass them because the server controls The filesystem.

The pre-receive hook runs once before any refs are updated. It reads from stdin all ref Updates that are about to be applied:

<old sha> <new sha> <ref name>

If this hook exits non-zero, all ref updates are rejected. This is atomic — either everything Updates or nothing does:

#!/usr/bin/env bash
# pre-receive: enforce that all commits pass linting
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
while read old_sha new_sha ref_name; do
if [ "$new_sha" = "0000000000000000000000000000000000000000" ]; then
continue # Branch deletion, skip
fi
if [ "$old_sha" = "0000000000000000000000000000000000000000" ]; then
# New branch — check all commits reachable from new_sha
# but not from any existing ref
RANGE=$(git for-each-ref --format='%(refname)' | sed 's/^/^/')
COMMITS=$(git rev-list "$new_sha" $RANGE 2>/dev/null)
else
COMMITS=$(git rev-list "$old_sha".."$new_sha")
fi
for commit in $COMMITS; do
# Check commit message format
MSG=$(git log -1 --format=%s "$commit")
if ! echo "$MSG" | grep -qE '^(feat|fix|docs|refactor|test|chore): .+'; then
echo "ERROR: Commit $commit has invalid message format: $MSG"
exit 1
fi
done
done
exit 0

The update hook runs once per ref being updated. It receives three arguments: the ref name, The old SHA, and the new SHA. Unlike pre-receiveThis hook can reject individual refs without Rejecting the entire push:

#!/usr/bin/env bash
# update: per-ref access control
REF=$1
OLD_SHA=$2
NEW_SHA=$3
# Prevent deletion of the main branch
if [ "$REF" = "refs/heads/main" ] && [ "$NEW_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "ERROR: Deleting the main branch is not allowed."
exit 1
fi
# Prevent force-push to release tags
if echo "$REF" | grep -q '^refs/tags/v'; then
if [ "$OLD_SHA" != "0000000000000000000000000000000000000000" ]; then
echo "ERROR: Overwriting release tags is not allowed."
exit 1
fi
fi

The post-receive hook runs after all refs have been updated successfully. It reads the same stdin Format as pre-receive. This is the standard hook for deployment triggers:

#!/usr/bin/env bash
# post-receive: deploy on push to main
while read old_sha new_sha ref_name; do
if [ "$ref_name" = "refs/heads/main" ]; then
echo "Deploying $new_sha to production..."
GIT_WORK_TREE=/var/www/app git checkout -f main
cd /var/www/app && make build
systemctl restart app.service
echo "Deployment complete."
fi
done

The simplest and most portable approach. A hook is just an executable file:

#!/usr/bin/env bash
set -euo pipefail
echo "Running pre-commit checks..."
# ... your checks ...
exit 0

The shebang line matters. If you write #!/bin/bashThe hook will fail on systems where bash is Not at /bin/bash (Alpine Linux, for example). Use #!/usr/bin/env bash for portability.

#!/usr/bin/env python3
import subprocess
import sys
def get_staged_files():
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=d"],
capture_output=True, text=True
)
return result.stdout.strip().split("\n")
def main():
staged = get_staged_files()
python_files = [f for f in staged if f.endswith(".py")]
if not python_files:
return 0
for f in python_files:
result = subprocess.run(["python", "-m", "py_compile", f])
if result.returncode != 0:
print(f"ERROR: Syntax check failed for {f}", file=sys.stderr)
return 1
return 0
sys.exit(main())
#!/usr/bin/env node
const { execSync } = require("child_process");
try {
const staged = execSync(
"git diff --cached --name-only --diff-filter=d",
{ encoding: "utf-8" }
)
.trim()
.split("\n");
const jsFiles = staged.filter((f) => f.endsWith(".js") || f.endsWith(".ts"));
if (jsFiles.length > 0) {
console.log("Linting staged files...");
execSync(`npx eslint ${jsFiles.join(" ")}`, { stdio: "inherit" });
}
} catch (err) {
process.exit(1);
}

Git will not run a hook that is not executable. This is the single most common reason hooks silently Fail:

Terminal window
$ chmod +x .git/hooks/pre-commit
$ chmod +x .githooks/pre-commit

Git sets several environment variables before invoking a hook. These provide context about the Repository and the operation in progress:

VariableAvailable InDescription
GIT_DIRAll hooksPath to the .git directory
GIT_WORK_TREEAll hooksPath to the working tree
GIT_AUTHOR_NAMEcommit-msgThe author’s name from config
GIT_AUTHOR_EMAILcommit-msgThe author’s email from config
GIT_AUTHOR_DATEcommit-msgThe author date
GIT_COMMITTER_NAMEcommit-msgThe committer’s name
GIT_COMMITTER_EMAILcommit-msgThe committer’s email
GIT_COMMITpost-commitSHA of the newly created commit
GIT_PREFIXpost-checkoutThe path to the worktree root (for subdirs)
GIT_REFLOG_ACTIONAll hooksThe operation being performed

Important: the PATH environment variable in hooks is often minimal. If your hook invokes tools Like eslint``shellcheckOr custom binaries, they may not be found. Either use absolute paths or Explicitly set PATH at the top of your hook:

#!/usr/bin/env bash
export PATH="/usr/local/bin:$HOME/.local/bin:$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ 2>/dev/null | tail -1)/bin:$PATH"

Hooks in .git/hooks/ are not tracked by Git. They are local to each clone. This means every Developer on a team must manually install and maintain their own hooks. This is unmaintainable at Scale. The industry has converged on two solutions: store hooks in the repository and redirect Git To them, or use a framework.

The simplest approach — commit a hooks directory to the repository and point Git to it:

# Create a hooks directory tracked by the repo
$ mkdir -p .githooks
$ cat > .githooks/pre-commit << 'EOF'
#!/usr/bin/env bash
echo "Running shared pre-commit hook..."
EOF
$ chmod +x .githooks/pre-commit
# Configure Git to use it
$ git config core.hooksPath .githooks
# Commit the hooks directory and the config change
$ git add .githooks
$ git commit -m "chore: add shared hooks directory"

Caveat: git config core.hooksPath is a local config change — it is not automatically applied When someone clones the repo. New contributors must still run the config command manually. This is documented in README.md or automated in a make setup target.

Husky is an npm package that manages Git hooks through the core.hooksPath mechanism. It installs a Thin .husky/ directory and patches the prepare npm lifecycle script to set up hooks:

Terminal window
$ npm install husky --save-dev
$ npx husky init

This creates .husky/ and sets core.hooksPath to .husky/. Individual hooks are files in that Directory:

.husky/pre-commit
npx lint-staged

Husky works, but it ties your hook infrastructure to npm. If your project is not a Node.js project, Or if contributors do not have npm available, Husky adds unnecessary friction.

Lefthook is a language-agnostic hook manager written in Go. It reads a configuration file (.lefthook.yml) and generates hook scripts that orchestrate parallel execution:

.lefthook.yml
pre-commit:
commands:
lint-js:
glob: "*.{js,ts}''
run: npx eslint {staged_files}
lint-python:
glob: "*.py'
run: python -m py_compile {staged_files}
check-yaml:
glob: "*.yaml''
run: yamllint {staged_files}

Lefthook”s advantages over Husky:

  • Language-agnostic: no npm dependency required for contributors
  • Parallel execution: hooks run concurrently when possible
  • Glob matching: automatically filters staged files by pattern
  • Partial staging awareness: only lints files that are actually staged (not all modified files)
Terminal window
# Install lefthook
$ lefthook install
# Run hooks manually (without needing a commit)
$ lefthook run pre-commit

The pre-commit Python package is a dedicated hook management framework. It provides a declarative Configuration file, a library of community-maintained hooks, and CI integration.

.pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args: ['--unsafe'] # Allow custom YAML tags
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
exclude: "tests/fixtures/''
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
- repo: local
hooks:
- id: check-changelog
name: Check changelog entry
entry: ./scripts/check-changelog.sh
language: script
files: "\.py$'
pass_filenames: false

The pre-commit-hooks repository provides widely-used, battle-tested hooks:

Hook IDWhat It DoesFix?
trailing-whitespaceRemoves trailing whitespace from all linesYes
end-of-file-fixerEnsures files end with a single newlineYes
check-yamlValidates YAML syntax with pyyamlNo
check-jsonValidates JSON syntaxNo
check-merge-conflictDetects unresolved merge conflict markersNo
detect-private-keyScans for files containing private key materialNo
check-added-large-filesRejects files over a configurable size limitNo
check-case-conflictDetects filename case conflicts (Linux vs macOS)No
check-executables-have-shebangsEnsures executable files have shebangsNo
check-shebang-scripts-are-executableEnsures files with shebangs are executableNo

Run all hooks against all files in CI to catch issues that contributors might bypass locally:

Terminal window
# Install pre-commit
$ pip install pre-commit
# Run against all files (not just staged)
$ pre-commit run --all-files
# Run specific hooks
$ pre-commit run trailing-whitespace --all-files
# Run hooks on only the files that changed in the last commit
$ pre-commit run --from-ref HEAD~1 --to-ref HEAD

A typical CI configuration:

.github/workflows/lint.yml
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12''
- run: pip install pre-commit
- run: pre-commit run --all-files
Terminal window
# Update all hooks to their latest versions
$ pre-commit autoupdate
# Show what would change without modifying anything
$ pre-commit autoupdate --freeze

You can write custom hooks as local scripts:

.pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: my-custom-lint
name: My Custom Linter
entry: bash -c "for f in "$@"; do python -m pylint "$f"; done' --
language: system
types: [python]

The language field determines how the hook is executed. system runs the entry directly on the Host. Other options include python``node``dockerAnd docker_image for sandboxed execution.

Terminal window
# Run a specific hook manually
$ git hook run pre-commit
# Run with the same environment as a real commit
$ git hook run pre-commit -- --verbose
Terminal window
# Show all Git internal operations including hook execution
$ GIT_TRACE=1 git commit -m "test"
# Example output:
# 12:34:56.789000 git.c:439 trace: exec: ".githooks/pre-commit''
# 12:34:56.790000 run-command.c:654 trace: run_command: ".githooks/pre-commit'

For more detail on what the hook itself is doing:

Terminal window
# Trace shell execution within the hook
$ GIT_TRACE=1 bash -x .githooks/pre-commit
# Trace environment variables available to the hook
$ git commit -m "test" # In your hook: env | sort > /tmp/hook-env.log

The challenge: you want to test your hook, but you do not want an actual commit. Strategies:

Terminal window
# 1. Stage files, run the hook, then reset
$ git add -A
$ .githooks/pre-commit
$ git reset HEAD
# 2. Use git hook run (Git 2.36+)
$ git hook run pre-commit
# 3. Create a temporary branch, commit there, then delete it
$ git checkout -b temp-hook-test
$ git commit -m "test hooks"
$ git checkout -
$ git branch -D temp-hook-test
# 4. For pre-push hooks, use --dry-run if available
$ git push --dry-run origin main
Terminal window
# Bypass all hooks for a single commit
$ git commit --no-verify -m "emergency fix"
# Bypass hooks for a push
$ git push --no-verify origin main
# Short form
$ git commit -n -m "emergency fix"

Warning: --no-verify bypasses all hooks — pre-commit, commit-msg, pre-push, everything. Use it only in exceptional circumstances. If you find yourself using --no-verify regularly, your hooks are too strict or too slow. Fix the hooks, don’t bypass them.

Legitimate uses of --no-verify:

  • Emergency hotfixes where every second matters
  • Fixing a broken hook that blocks all commits
  • Initial commit in a new repository
  • Commits generated by automated tools that don’t need linting

Files in .git/hooks/ are ignored by Git. If you want version-controlled hooks, you must use core.hooksPath to point to a tracked directory. Forgetting this and wondering why teammates don’t Have your hooks is the single most common hook-related mistake.

Git checks the execute bit. If your hook file lacks it, Git silently skips the hook:

Terminal window
# Check if your hook is executable
$ ls -la .githooks/pre-commit
-rw-r--r-- 1 user staff 1234 Jun 5 10:00 pre-commit # Missing 'x'
# Fix it
$ chmod +x .githooks/pre-commit

Hooks run with a minimal PATH. Tools installed in ~/.local/bin``~/go/binOr via nvm may not Be found. Always explicitly set PATH or use absolute paths to tools in your hook scripts.

pre-commit Sees Staged Content, Not Working Tree

Section titled “pre-commit Sees Staged Content, Not Working Tree”

If you edit a file after staging it, the hook inspects the staged version. This causes confusion When developers run git add file.pyThen fix a lint error, then commit — the hook still sees the Old staged content. Run git add again after fixing.

Hooks Do Not Apply to Amended Commits by Default

Section titled “Hooks Do Not Apply to Amended Commits by Default”

git commit --amend does trigger pre-commit and commit-msg hooks, but the prepare-commit-msg Hook receives COMMIT_SOURCE=commitWhich is the same as a new commit. If your hook tries to Inject a ticket number based on the branch, it may double-inject on amend. Check for the amend case:

Terminal window
if [ "$COMMIT_SOURCE" = "commit" ]; then
# Check if this is an amend (the third argument is non-empty)
if [ -n "${3:-}" ]; then
exit 0 # Skip injection on amend
fi
fi

A server-side pre-receive hook that exits non-zero rejects the entire push atomically. If you Want to reject some refs but accept others, use the update hook instead, which is called per-ref.

pre-commit Framework: Virtual Environment Isolation

Section titled “pre-commit Framework: Virtual Environment Isolation”

The pre-commit framework creates isolated virtual environments for each hook repository. This Means hooks run with their own dependencies, not your project’s dependencies. If a hook needs access To your project’s Python environment, use language: system and manage dependencies yourself.

Hooks that scan every staged file can be slow in large repositories. Mitigate this by:

  1. Using glob patterns to limit which files each hook processes
  2. Running hooks in parallel (Lefthook does this automatically)
  3. Caching results (the pre-commit framework caches hook results and skips unchanged files)
  4. Only running expensive checks (full test suites) on pre-pushNot pre-commit

On Windows, Git may create hook files with CRLF line endings. The shebang line #!/usr/bin/env bash\r will fail because the \r is interpreted as part of the interpreter path. Configure Git to use LF line endings for hooks:

Terminal window
$ git config core.autocrlf input

Or configure your text editor to save files in .githooks/ with LF line endings.

This topic covers the core concepts of git hooks, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • Git fundamentals (add, commit, push, pull)
  • branching and merging strategies
  • resolving merge conflicts
  • rebasing and cherry-picking
  • Git workflows (GitFlow, trunk-based)

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.


  • Commit Signing extends hooks by adding cryptographic verification to the commit process that hooks can enforce.
  • Cherry-Pick selectively applies commits where hooks may trigger on the newly applied changes.
  • Remote Operations shows how hooks interact with server-side workflows during push and receive operations.