Docs Library

Git Hooks: Automating Git Workflows

A practical guide to Git hooks, covering client-side and server-side hooks, common use cases, and best practices for hook management.

Who This Is For
  • Readers who want the history model before advanced commands
Prerequisites
  • A basic sense that commits are not just a file list
Common Risks
  • Treating a concepts page like a command how-to

What Are Git Hooks?

Git hooks are scripts that run automatically when specific Git events occur. They are the primary mechanism for automating and enforcing policies within a Git repository. Hooks can run on the client side (your local machine) or on the server side (the remote repository), and they cover the entire Git lifecycle from making commits to pushing changes.

Hook scripts can be written in any scripting language — Bash, Python, Ruby, Node.js, or any executable — as long as they have a proper shebang line (e.g., #!/bin/bash) and the executable bit set.

Hook Trigger MechanismGit automatically detects and runs the corresponding hook scripts at key operation points. Pre-* hooks that return a non-zero exit code will interrupt the entire Git operation.
Trigger Events
git commitgit mergegit pushgit checkoutgit rebase
Execution Result
Check passes → continueCheck fails → abortPost-action → notify/cleanup
Each hook corresponds to a script file in .git/hooks/. Remove the .sample suffix to enable it.

Hook Location

Git hooks live in the .git/hooks/ directory of your repository:

my-project/
└── .git/
    └── hooks/
        ├── pre-commit.sample      ← sample hook (rename to activate)
        ├── commit-msg.sample
        ├── pre-push.sample
        ├── pre-receive.sample
        └── ...

By default, Git ships with sample hook files (.sample extension). To activate a hook, remove the .sample extension and make the file executable:

# Activate the pre-commit hook
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

Client-Side Hooks

Client-side hooks run on your local machine and are triggered by local Git operations.

pre-commit

Runs before a commit is created. This is the most commonly used hook and is ideal for code quality checks.

Exit code behavior: If the hook exits with a non-zero status, the commit is aborted.

#!/bin/bash
# .git/hooks/pre-commit

echo "Running pre-commit checks..."

# Run linter
npm run lint
if [ $? -ne 0 ]; then
    echo "❌ Linting failed. Fix errors before committing."
    exit 1
fi

# Run type checking
npm run typecheck
if [ $? -ne 0 ]; then
    echo "❌ Type checking failed."
    exit 1
fi

# Check for debugger statements
if grep -r "debugger" --include="*.js" --include="*.ts" .; then
    echo "❌ Found debugger statements. Remove them before committing."
    exit 1
fi

# Check for console.log in production code
if grep -r "console.log" src/ --include="*.js" --include="*.ts"; then
    echo "⚠️  Found console.log statements in src/"
    echo "   Remove them or use a proper logger."
fi

echo "✅ All pre-commit checks passed!"
exit 0

commit-msg

Runs after the commit message is entered but before the commit is finalized. Use this to enforce commit message conventions.

#!/bin/bash
# .git/hooks/commit-msg

commit_msg=$(cat "$1")

# Enforce Conventional Commits format
pattern="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?!?: .+"

if ! echo "$commit_msg" | grep -Eq "$pattern"; then
    echo "❌ Commit message does not follow Conventional Commits format."
    echo ""
    echo "Expected format:"
    echo "  type(scope): description"
    echo ""
    echo "Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert"
    echo ""
    echo "Examples:"
    echo "  feat(auth): add JWT token refresh"
    echo "  fix(api): handle null response in /users"
    echo "  docs: update README with setup instructions"
    exit 1
fi

echo "✅ Commit message format is valid."
exit 0

prepare-commit-msg

Runs before the commit message editor is launched. It receives the path to the commit message file and can modify the default message.

#!/bin/bash
# .git/hooks/prepare-commit-msg

commit_msg_file="$1"
commit_source="$2"
sha1="$3"

# Add branch name as a prefix for feature branches
branch_name=$(git symbolic-ref --short HEAD 2>/dev/null)

if [ "$commit_source" = "message" ] && [[ "$branch_name" == feat/* ]]; then
    # Prepend ticket number from branch name
    ticket=$(echo "$branch_name" | grep -oE '[A-Z]+-[0-9]+')
    if [ -n "$ticket" ]; then
        current_msg=$(cat "$commit_msg_file")
        echo "[$ticket] $current_msg" > "$commit_msg_file"
    fi
fi

post-commit

Runs after a commit is successfully created. It cannot abort the commit (it runs too late), but is useful for notifications.

#!/bin/bash
# .git/hooks/post-commit

# Send a notification
commit_hash=$(git log -1 --format=%h)
commit_msg=$(git log -1 --format=%s)

echo "📝 Committed: $commit_hash - $commit_msg"

# Could also send to Slack, email, etc.
# curl -X POST -H 'Content-type: application/json' \
#   --data "{\"text\":\"New commit: $commit_msg\"}" \
#   https://hooks.slack.com/services/YOUR/WEBHOOK/URL

pre-push

Runs before a git push is executed. It receives the remote name and URL as arguments, and the list of refs to push on stdin.

#!/bin/bash
# .git/hooks/pre-push

remote="$1"
url="$2"

# Prevent pushing directly to main/production branches
if [[ "$remote" == "origin" ]]; then
    while read local_ref local_sha remote_ref remote_sha; do
        if [[ "$remote_ref" == "refs/heads/main" || "$remote_ref" == "refs/heads/production" ]]; then
            branch_name=$(git symbolic-ref --short HEAD 2>/dev/null)
            if [ "$branch_name" = "main" ] || [ "$branch_name" = "production" ]; then
                echo "❌ Direct push to $branch_name is not allowed."
                echo "   Please use a pull request to merge changes."
                exit 1
            fi
        fi
    done
fi

# Run full test suite before pushing
echo "🧪 Running full test suite before push..."
npm test
if [ $? -ne 0 ]; then
    echo "❌ Tests failed. Push aborted."
    exit 1
fi

echo "✅ Pre-push checks passed."
exit 0

Other Client-Side Hooks

HookWhen it runs
applypatch-msgAfter git am prepares a commit message
pre-applypatchAfter git am patches are applied, before commit
post-applypatchAfter git am patch is applied and committed
pre-rebaseBefore git rebase starts
post-rewriteAfter commands that rewrite commits (rebase, commit --amend)
post-checkoutAfter git checkout or git clone
post-mergeAfter git merge completes successfully

Server-Side Hooks

Server-side hooks run on the remote repository (e.g., a Git server, bare repository, or self-hosted Git server like GitLab/Gitea). They are critical for enforcing server-side policies.

pre-receive

Runs before any refs are updated. It receives the old and new commit hashes and ref names on stdin.

#!/bin/bash
# .git/hooks/pre-receive (on bare repository)

while read old_rev new_rev ref_name; do
    branch=$(echo "$ref_name" | sed 's|refs/heads/||')

    # Prevent force-push to main
    if [ "$branch" = "main" ]; then
        if ! git merge-base --is-ancestor "$old_rev" "$new_rev" 2>/dev/null; then
            echo "❌ Force-push to main is not allowed."
            exit 1
        fi
    fi

    # Enforce signed commits
    unsigned=$(git log "$old_rev..$new_rev" --no-merges --format="%H %G?" | grep "N$")
    if [ -n "$unsigned" ]; then
        echo "❌ All commits must be GPG-signed."
        exit 1
    fi
done

exit 0

update

Similar to pre-receive, but runs once per ref being updated. It receives the ref name, old rev, and new rev as arguments.

#!/bin/bash
# .git/hooks/update

ref_name="$1"
old_rev="$2"
new_rev="$3"

branch=$(echo "$ref_name" | sed 's|refs/heads/||')

# Prevent deletion of protected branches
if [ "$new_rev" = "0000000000000000000000000000000000000000" ]; then
    if [ "$branch" = "main" ] || [ "$branch" = "production" ]; then
        echo "❌ Cannot delete the $branch branch."
        exit 1
    fi
fi

exit 0

post-receive

Runs after all refs have been updated. Cannot abort the push, but useful for notifications and deployment.

#!/bin/bash
# .git/hooks/post-receive

while read old_rev new_rev ref_name; do
    branch=$(echo "$ref_name" | sed 's|refs/heads/||')

    if [ "$branch" = "main" ]; then
        echo "🚀 Triggering deployment for main branch..."
        # Trigger CI/CD, send notifications, etc.
        curl -X POST https://ci-server.example.com/api/deploy \
          -d "{\"branch\": \"$branch\", \"commit\": \"$new_rev\"}"
    fi
done

Sharing Hooks Across a Team

Since .git/hooks/ is not tracked by Git, hooks are not automatically shared when cloning a repository. Here are strategies for sharing hooks:

Method 1: core.hooksPath (Git 2.9+)

Store hooks in a tracked directory and point Git to it:

# Create a hooks directory in your repo
mkdir -p hooks

# Move your hooks there
mv .git/hooks/pre-commit hooks/pre-commit
mv .git/hooks/commit-msg hooks/commit-msg

# Tell Git to use this directory
git config core.hooksPath hooks/

# Commit the hooks directory
git add hooks/
git commit -m "Add shared git hooks"

Now anyone who clones the repo can run:

git config core.hooksPath hooks/

Method 2: Install Script

Create a script that copies hooks into .git/hooks/:

#!/bin/bash
# scripts/install-hooks.sh

HOOKS_DIR="$(git rev-parse --git-dir)/hooks"

cp hooks/* "$HOOKS_DIR"/
chmod +x "$HOOKS_DIR"/*

echo "✅ Git hooks installed."

Add to package.json:

{
  "scripts": {
    "prepare": "node scripts/install-hooks.js"
  }
}

The prepare script runs automatically after npm install.

Method 3: Third-Party Tools

  • Husky: Popular Node.js tool for managing Git hooks via package.json
  • pre-commit: Python-based framework with a large collection of hooks
  • lefthook: Fast, cross-platform hook manager written in Go
  • simple-git-hooks: Lightweight alternative to Husky

Hook Limitations

  1. Local only: Client-side hooks run only on the local machine. They are not enforced on other developers' machines or on the server.
  2. No arguments for some hooks: Some hooks (like post-commit) receive no arguments at all.
  3. Can be bypassed: Developers can skip hooks using git commit --no-verify or git push --no-verify.
  4. Performance: Slow hooks can significantly slow down commit and push operations.
  5. Environment dependency: Hooks that depend on specific tools (linters, compilers) may fail if those tools are not installed.

Best Practices

  1. Keep hooks fast: Pre-commit hooks should run in under a second. Use incremental linting instead of full project scans.
  2. Use server-side hooks for enforcement: Client-side hooks are conveniences; server-side hooks are enforcement.
  3. Provide clear error messages: When a hook blocks an operation, explain exactly what went wrong and how to fix it.
  4. Allow bypassing when needed: Use --no-verify for emergency commits, but document when it's appropriate.
  5. Test hooks: Include hooks in your CI pipeline to ensure they work correctly.
  6. Version your hooks: Keep hooks in a tracked directory so changes are reviewed in PRs.

Summary

HookSideWhenCan Abort?
pre-commitClientBefore commitYes
commit-msgClientAfter message entryYes
post-commitClientAfter commitNo
pre-pushClientBefore pushYes
pre-receiveServerBefore ref updateYes
updateServerPer ref updateYes
post-receiveServerAfter ref updateNo

Git hooks are a powerful mechanism for automating and enforcing workflows. Use client-side hooks for developer convenience (linting, formatting) and server-side hooks for policy enforcement (branch protection, signed commits). Always provide clear error messages and allow reasonable bypass options for edge cases.