← Back to DevBytes

Git Hooks for Automation

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:

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:

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:

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:

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

Common Pitfalls and How to Avoid Them

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles