What Are Git Hooks?
Git hooks are custom scripts that Git executes automatically at specific points during its lifecycle. They reside in the .git/hooks directory of every Git repository and are triggered by events such as committing, merging, pushing, and receiving changes. Think of them as event listeners that allow you to inject custom logic into Git's workflow—turning version control from a passive record-keeper into an active automation engine.
When you initialize a new repository with git init, Git populates the .git/hooks directory with a set of sample scripts. Each sample file ends with .sample, which prevents it from executing. Removing the .sample extension (and ensuring the file is executable) activates the hook. The samples demonstrate common patterns and serve as excellent starting points for your own automation.
Why Git Hooks Matter for Automation
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Manual processes in software development are error-prone and inconsistent. Git hooks eliminate these problems by enforcing rules and running tasks automatically at precisely the right moments. Here are the key benefits:
- Consistency across contributors: Every developer on your team runs the same checks before a commit lands, regardless of individual habits or IDE configurations.
- Early error detection: Catch linting issues, failing tests, or malformed commit messages before they enter the shared history—saving time on code review back-and-forth.
- Policy enforcement on the server: Server-side hooks let you reject pushes that violate project standards, even if a contributor bypassed client-side hooks locally.
- Seamless CI/CD integration: Trigger deployments, notify chat channels, or update issue trackers automatically when branches are pushed or tags are created.
- Reduced cognitive load: Developers can focus on writing code rather than remembering multi-step manual checklists.
The result is a cleaner commit history, higher code quality, and a development workflow that feels frictionless yet rigorous.
How to Use Git Hooks
Exploring the Hooks Directory
Navigate to any Git repository and list the hooks directory to see the available sample hooks:
ls -la .git/hooks/
You'll see files such as:
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
pre-push.sample
pre-rebase.sample
pre-receive.sample
prepare-commit-msg.sample
push-to-checkout.sample
update.sample
Each filename corresponds to a specific Git event. The .sample extension means Git ignores them. To activate a hook, you remove the extension, make the file executable, and add your custom logic.
Client-Side Hooks
Client-side hooks run on the developer's local machine. They're ideal for pre-commit checks, commit message formatting, and pre-push validations. The most commonly used client-side hooks include:
- pre-commit: Runs before the commit is created. Perfect for linting, static analysis, and running fast tests.
- commit-msg: Runs after the commit message is entered but before the commit is finalized. Use it to enforce message conventions.
- pre-push: Runs before code is pushed to a remote. Ideal for running full test suites.
- post-commit: Runs after the commit is completed. Useful for notifications or logging.
- prepare-commit-msg: Runs before the commit message editor opens. Great for injecting default messages or ticket references.
Server-Side Hooks
Server-side hooks execute on the remote repository when it receives pushed commits. These hooks are critical for enforcing policies that cannot be bypassed by developers who might skip or disable local hooks. The three primary server-side hooks are:
- pre-receive: Runs when a push is received but before any refs are updated. It receives information about all pushed refs and can reject the entire push.
- update: Similar to pre-receive but runs once per branch being pushed. It receives the old commit hash, new commit hash, and branch name.
- post-receive: Runs after refs have been updated. Ideal for triggering deployments, sending notifications, or updating CI systems.
Making Hooks Executable
To activate a hook, two conditions must be met: the file must drop the .sample extension, and it must have execute permissions. Here's how to create and activate a pre-commit hook from scratch:
# Create the hook file (overwrite if it exists)
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
echo "Running pre-commit hook..."
# Your automation logic here
EOF
# Make it executable
chmod +x .git/hooks/pre-commit
Now every time you run git commit, this script will execute before the commit is created. If the script exits with a non-zero status, Git aborts the commit.
Writing Hook Scripts
Hook scripts can be written in any language that your system can execute. The shebang line (#!/bin/sh, #!/usr/bin/env python3, #!/usr/bin/env node) determines the interpreter. Here's the critical rule: if a hook exits with code 0, Git proceeds with the operation. Any non-zero exit code aborts the operation. This exit-code convention is how hooks signal pass/fail to Git.
Here's a minimal example in Python that demonstrates the exit-code pattern:
#!/usr/bin/env python3
import sys
def main():
print("Running automated checks...")
# Perform your validation logic here
success = True # Replace with actual checks
if success:
print("All checks passed.")
sys.exit(0) # Zero = success, Git proceeds
else:
print("Checks failed. Commit aborted.")
sys.exit(1) # Non-zero = failure, Git aborts
if __name__ == "__main__":
main()
Practical Examples
Example 1: Lint Before Commit (pre-commit)
This hook runs ESLint on staged JavaScript files before allowing a commit. It extracts the list of staged files from Git's index, filters for JavaScript files, and runs the linter only on those files:
#!/bin/sh
# .git/hooks/pre-commit
echo "Running pre-commit lint check..."
# Get list of staged files that are added, copied, or modified
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
# Filter for JavaScript/TypeScript files
JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|jsx|ts|tsx)$' || true)
if [ -z "$JS_FILES" ]; then
echo "No JavaScript files staged. Skipping lint."
exit 0
fi
echo "Linting staged JavaScript files..."
echo "$JS_FILES"
# Run ESLint; capture output and exit code
ESLINT_OUTPUT=$(npx eslint $JS_FILES 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "❌ Linting errors found:"
echo "$ESLINT_OUTPUT"
echo ""
echo "Commit aborted. Please fix linting errors and try again."
exit 1
fi
echo "✅ Lint passed. Proceeding with commit."
exit 0
Example 2: Enforce Commit Message Format (commit-msg)
This hook validates that every commit message follows a conventional commit format like feat: add login button or fix: resolve memory leak. The commit message is passed to the hook as the first argument—the path to a temporary file containing the message:
#!/bin/sh
# .git/hooks/commit-msg
COMMIT_MSG_FILE="$1"
# Read the commit message
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Regex for conventional commit format
# Pattern: type(optional-scope): description
PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-zA-Z0-9_-]+\))?: .{1,72}$'
if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
echo "❌ Invalid commit message format."
echo ""
echo "Expected format: (): "
echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore"
echo ""
echo "Examples:"
echo " feat: add user authentication"
echo " fix(api): resolve timeout issue"
echo " docs: update README with setup instructions"
echo ""
echo "Your message was:"
echo "$COMMIT_MSG"
exit 1
fi
echo "✅ Commit message format is valid."
exit 0
To test this hook, attempt a commit with an invalid message like git commit -m "updated stuff". The hook will reject it and show the expected format.
Example 3: Run Tests Before Push (pre-push)
The pre-push hook receives information on standard input about the remote and the refs being pushed. This example runs a test suite before allowing the push to proceed. It's more thorough than a pre-commit hook because you can run longer test suites without slowing down every individual commit:
#!/bin/sh
# .git/hooks/pre-push
PROTECTED_BRANCHES="main master production"
# Read push information from stdin
while read LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do
# Extract branch name from the local ref
BRANCH=$(echo "$LOCAL_REF" | sed 's|refs/heads/||')
# Check if pushing to a protected branch
for PROTECTED in $PROTECTED_BRANCHES; do
if [ "$BRANCH" = "$PROTECTED" ]; then
echo "🔒 Pushing to protected branch: $BRANCH"
echo "Running full test suite before push..."
# Run tests (adjust command for your project)
npm test
TEST_EXIT=$?
if [ $TEST_EXIT -ne 0 ]; then
echo "❌ Tests failed! Push to $BRANCH aborted."
echo "Fix failing tests and try again."
exit 1
fi
echo "✅ All tests passed. Proceeding with push."
fi
done
done
exit 0
The hook reads from stdin because Git pipes push metadata line by line. Each line contains four values: the local ref, local SHA, remote ref, and remote SHA. The loop processes each ref being pushed.
Example 4: Notify Team on Deployment (post-receive, Server-Side)
This server-side hook sends a Slack notification whenever commits are pushed to the main branch. It's placed on the remote repository (or your Git server) and uses a webhook URL to post to a chat channel:
#!/bin/sh
# hooks/post-receive (on the remote/server repository)
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
# Read all pushed refs from stdin
while read OLD_SHA NEW_SHA REF; do
BRANCH=$(echo "$REF" | sed 's|refs/heads/||')
if [ "$BRANCH" = "main" ]; then
echo "📣 Detected push to main branch. Sending notification..."
# Gather commit information
COMMIT_COUNT=$(git rev-list --count $OLD_SHA..$NEW_SHA 2>/dev/null || echo "0")
COMMIT_AUTHORS=$(git log $OLD_SHA..$NEW_SHA --format="%an" | sort -u | tr '\n' ', ' | sed 's/, $//')
TOP_COMMIT_MSG=$(git log $OLD_SHA..$NEW_SHA --format="%s" | head -1)
# Build the Slack message payload
PAYLOAD=$(cat < /dev/null 2>&1
echo "✅ Notification sent."
fi
done
exit 0
Replace YOUR/WEBHOOK/URL with your actual Slack incoming webhook URL. This hook fires after the refs are updated, so it doesn't block the push—it runs asynchronously from the pusher's perspective.
Example 5: Automated Changelog Update (post-commit)
A post-commit hook runs after the commit is finalized. It's perfect for tasks that shouldn't block the commit but should happen immediately afterward. This example appends the commit message to a CHANGELOG.md file:
#!/bin/sh
# .git/hooks/post-commit
CHANGELOG="CHANGELOG.md"
COMMIT_MSG=$(git log -1 --format="%s")
COMMIT_DATE=$(git log -1 --format="%cd --date=short")
COMMIT_AUTHOR=$(git log -1 --format="%an")
# Create changelog if it doesn't exist
if [ ! -f "$CHANGELOG" ]; then
echo "# Changelog" > "$CHANGELOG"
echo "" >> "$CHANGELOG"
fi
# Prepend the new entry (insert after the title line)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
ENTRY="- **$COMMIT_DATE** ($COMMIT_AUTHOR): $COMMIT_MSG"
# Insert entry after the first two lines (title + blank line)
sed -i "3i$ENTRY" "$CHANGELOG" 2>/dev/null || {
# Fallback for macOS (BSD sed)
sed -i '' "3i\\
$ENTRY
" "$CHANGELOG"
}
echo "📝 Updated $CHANGELOG with latest commit."
This hook demonstrates a non-blocking pattern. Since post-commit runs after the commit succeeds, its exit code doesn't affect anything—the commit has already been created.
Sharing Hooks Across a Team
Git hooks live in .git/hooks, which is not tracked by Git. This means hooks don't propagate when someone clones the repository. To share hooks with your team, you have several options:
- Symlink strategy: Store hooks in a tracked directory (e.g.,
.githooks/) and have developers symlink or copy them into.git/hooks. You can automate this with a setup script. - Git configuration: Use
git config core.hooksPathto point Git at a tracked hooks directory. Each developer runsgit config core.hooksPath .githooksonce. - Tools and frameworks: Use tools like Husky (for Node.js projects), pre-commit (the Python framework), or Lefthook that manage hook installation and configuration declaratively.
Here's a quick example using the core.hooksPath approach:
# Create a tracked hooks directory
mkdir -p .githooks
# Move your hooks there (or create them directly)
cat > .githooks/pre-commit << 'EOF'
#!/bin/sh
echo "Team pre-commit hook running..."
# Shared checks here
EOF
chmod +x .githooks/pre-commit
# Tell Git to use this directory for hooks
git config core.hooksPath .githooks
# Commit the hooks directory so the team gets it
git add .githooks/
git commit -m "chore: add shared git hooks"
# Add setup instructions to your README:
# After cloning, run: git config core.hooksPath .githooks
Using Husky (Node.js Projects)
If your project uses Node.js, Husky provides a popular and streamlined way to manage Git hooks. It installs hooks into .git/hooks automatically when dependencies are installed:
# Install Husky
npm install --save-dev husky
# Initialize Husky (creates .husky/ directory)
npx husky init
# Add a pre-commit hook
echo "npx lint-staged" > .husky/pre-commit
chmod +x .husky/pre-commit
# The .husky/ directory is tracked in Git
# Team members get hooks automatically on npm install
Husky handles the complexity of hook installation and ensures every team member has the same hooks without manual setup.
Best Practices
- Keep hooks fast, especially pre-commit: A pre-commit hook that takes 30 seconds will frustrate developers and encourage them to bypass it. Aim for under 2-3 seconds. Run slower checks in pre-push or on the CI server instead.
- Limit hook scope to staged changes: In pre-commit hooks, only examine files that are actually staged (
git diff --cached). Linting the entire codebase on every commit is wasteful and slow. - Provide clear, actionable error messages: When a hook fails, tell the developer exactly what went wrong and how to fix it. Include examples and the specific command to run. A cryptic "check failed" message causes frustration.
- Allow intentional bypass with a flag: There are legitimate scenarios where a developer needs to skip hooks (e.g., an emergency hotfix, or when hooks are broken due to environment issues). Support
git commit --no-verifybut document when it's acceptable. Consider adding a confirmation prompt for protected branches. - Use server-side hooks for non-bypassable policies: Client-side hooks can be skipped with
--no-verifyor simply not installed. If a policy is truly critical (e.g., no secrets in code, no commits to protected branches), enforce it with server-side pre-receive hooks. - Version-control your hook scripts: Store shared hooks in a tracked directory (like
.githooks/) and usecore.hooksPathor a setup script. Avoid emailing hook scripts or relying on wiki documentation that gets out of sync. - Handle cross-platform compatibility: Bash scripts may fail on Windows. Either use a cross-platform scripting language (Python, Node.js) or provide PowerShell equivalents. Tools like Husky or the
pre-commitframework abstract away platform differences. - Log hook output for debugging: When a hook fails silently, it's confusing. Always print status messages (pass/fail) and consider logging to a file for complex hooks. Use
echoliberally—Git displays hook output to the user. - Test hooks thoroughly: Write tests for your hooks. Simulate the Git environment by setting up a test repository, staging files, and running the hook script directly with mock data. A buggy hook that blocks all commits is worse than no hook at all.
- Combine hooks with CI/CD: Hooks are your first line of defense, not the last. Use them for fast, local checks. Rely on your CI pipeline for comprehensive testing, security scanning, and deployment. The two work together: hooks catch issues early, CI catches everything else.
Common Pitfalls and How to Avoid Them
- Hook works for you but not for teammates: This usually means the hook isn't executable on their system, or they're on Windows and the script uses bash-specific syntax. Solution: Use a managed hooks tool or cross-platform scripts.
- Pre-commit hook blocks legitimate work: If your lint hook rejects a commit because of pre-existing lint errors in unstaged files, you're scanning the wrong set of files. Always use
git diff --cachedto limit checks to what's staged. - Hook accidentally commits sensitive data: A post-commit hook that runs
git addandgit commitcan create infinite loops. Be careful with hooks that modify the working directory or staging area. Prefer post-commit for read-only operations like notifications. - Bypassed hooks in production: Developers may use
--no-verifyto skip client-side hooks. If a policy is critical, implement it as a server-side hook that cannot be bypassed by the pusher.
Conclusion
Git hooks transform version control from a passive tool into an active participant in your development workflow. By strategically placing automation scripts at key events—before commits, after pushes, on the server receiving changes—you create guardrails that catch errors early, enforce standards consistently, and eliminate repetitive manual tasks. The examples in this tutorial cover the most impactful patterns: linting staged changes, validating commit messages, running tests before pushing to protected branches, sending deployment notifications, and updating changelogs automatically. The key to successful hook adoption is balancing thoroughness with speed, providing clear feedback, and ensuring hooks are shared and maintained across your team. Start with a single pre-commit lint hook, see how it improves your team's rhythm, and gradually layer in additional automation. The investment in crafting thoughtful Git hooks pays dividends in code quality, team efficiency, and a remarkably smoother development experience.