Stage 2 · Tools
Hooks & Automation
Server-Side Hooks
Enforce policy at the server level — reject force pushes, require sign-offs, and trigger deployments.
Server vs Client Hooks
Client-side hooks run on the developer's machine and are easy to bypass with --no-verify. Server-side hooks run on the Git server (GitHub Enterprise, GitLab, Bitbucket Server, or a self-hosted Git server) and cannot be skipped. They are the enforcement layer for policies that must apply to every push.
| Aspect | Client Hooks | Server Hooks |
|---|---|---|
| Location | .git/hooks/ on developer machine | Server-side, repo or server config |
| Can be bypassed? | Yes (--no-verify) | No |
| Shared with team? | Not automatically | Always enforced |
| Use case | Linting, formatting, local checks | Policy enforcement, deploy triggers |
| Requires server access? | No | Yes (admin access) |
Use client hooks for fast feedback and server hooks as the backstop. A developer might skip a pre-commit hook locally, but the server will catch the same issue on push.
pre-receive Hook
The pre-receive hook runs once per push, before any refs are updated on the server. It receives a list of ref updates on stdin — each line contains the old SHA, new SHA, and ref name. If the hook exits non-zero, the entire push is rejected.
#!/bin/sh
#
# pre-receive: Block force pushes to main and master.
#
while read OLD_SHA NEW_SHA REF; do
BRANCH=$(echo "$REF" | sed -e 's|^refs/heads/||')
# Only apply to main/master.
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
# A force push has a non-zero OLD_SHA and a different NEW_SHA.
if [ "$OLD_SHA" != "0000000000000000000000000000000000000000" ]; then
# Check if the push is a fast-forward.
git merge-base --is-ancestor "$OLD_SHA" "$NEW_SHA" 2>/dev/null
if [ $? -ne 0 ]; then
echo ""
echo "❌ Force push to $BRANCH is not allowed."
echo " Create a new commit instead."
echo ""
exit 1
fi
fi
fi
done
exit 0This hook iterates over every ref in the push. For main/master, it checks if the push is a fast-forward using git merge-base --is-ancestor. If the old SHA is not an ancestor of the new SHA, it's a force push — and the hook rejects it.
The pre-receive hook runs once for the entire push, regardless of how many branches or refs are being updated. This makes it efficient for checks that apply globally.
update Hook
The update hook runs once per branch being updated, before the ref is changed. Unlike pre-receive, which gets all ref updates at once, update is called separately for each ref. It receives three arguments: the ref name, the old SHA, and the new SHA.
#!/bin/sh
#
# update: Require Signed-Off-By in every commit.
#
REF=$1
OLD_SHA=$2
NEW_SHA=$3
# Only apply to branches.
echo "$REF" | grep -qE '^refs/heads/' || exit 0
# Find commits that are new in this push.
NEW_COMMITS=$(git rev-list "$OLD_SHA".."$NEW_SHA" 2>/dev/null)
for COMMIT in $NEW_COMMITS; do
if ! git log -1 --format='%B' "$COMMIT" | grep -q 'Signed-off-by:'; then
echo ""
echo "❌ Commit $COMMIT is missing a Signed-off-by line."
echo " Use 'git commit -s' to add one."
echo ""
exit 1
fi
done
exit 0This hook loops through every new commit in the push and checks for a Signed-off-by: trailer. It uses git rev-list to find the range of new commits, then inspects each one individually.
| Feature | pre-receive | update |
|---|---|---|
| Runs | Once per push | Once per ref |
| Arguments | stdin (all refs) | ref, old SHA, new SHA |
| Use case | Global policy (force push, secrets) | Per-branch checks (sign-off, reviewers) |
| Can reject selectively? | No — rejects entire push | Yes — can reject one branch and allow others |
post-receive Hook
The post-receive hook runs after all refs have been updated. It receives the same input as pre-receive (old SHA, new SHA, ref name on stdin) but its exit code is ignored — it cannot block a push. Use it for notifications, deployment triggers, or logging.
#!/bin/sh
#
# post-receive: Deploy to staging on push to main, notify Slack.
#
while read OLD_SHA NEW_SHA REF; do
BRANCH=$(echo "$REF" | sed -e 's|^refs/heads/||')
# Deploy on push to main.
if [ "$BRANCH" = "main" ]; then
echo "🚀 Deploying $NEW_SHA to staging..."
# Run deployment script (e.g., CI trigger, rsync, Docker build).
/opt/deploy/staging.sh "$NEW_SHA" "$BRANCH"
# Send Slack notification.
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{
\"text\": \"🚀 Deployed $(echo $NEW_SHA | cut -c1-7) to staging\"
}" > /dev/null 2>&1
fi
# Notify on any push.
echo "[post-receive] $REF updated: $(echo $OLD_SHA | cut -c1-7) -> $(echo $NEW_SHA | cut -c1-7)"
doneThis hook deploys to staging when someone pushes to main and sends a Slack notification. Since it runs after the push succeeds, there's no risk of blocking work.
GitHub & GitLab Server Hooks
GitHub and GitLab abstract server hooks behind their own APIs and UIs. You don't need to write shell scripts for most use cases — their built-in protections cover branch protection, required reviews, status checks, and deployment rules.
| Feature | GitHub | GitLab |
|---|---|---|
| Branch protection | Settings → Branches → Add rule | Settings → Repository → Protected Branches |
| Required reviews | Pull Requests → Require approvals | Merge Requests → Approval rules |
| Status checks | Require status checks to pass | Push rules → Branch approval → CI must pass |
| Deploy keys | Settings → Deploy keys | Settings → Repository → Deploy keys |
| Custom server hooks | GitHub Enterprise only | Available via /opt/gitlab/etc/ |
On GitHub Enterprise and self-hosted GitLab, you can install custom server hooks in the repository's custom_hooks/ directory or the server's hooks/ directory. These hooks follow the same shell script format as traditional Git hooks.
Before writing a custom server hook, check if your platform's built-in protections cover the use case. Branch protection rules, CODEOWNERS, and CI status checks are easier to maintain than custom scripts.
Writing a Pre-Receive Hook
Here is a comprehensive pre-receive hook that enforces multiple policies in a single script. It combines force-push protection, secret scanning, and commit message validation.
#!/bin/sh
#
# pre-receive: Enforce multiple push policies.
#
HAS_ERROR=0
reject() {
echo "❌ $1" >&2
HAS_ERROR=1
}
while read OLD_SHA NEW_SHA REF; do
BRANCH=$(echo "$REF" | sed -e 's|^refs/heads/||')
echo "Checking: $BRANCH"
# --- Policy 1: Block force pushes to main/master ---
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
if [ "$OLD_SHA" != "0000000000000000000000000000000000000000" ]; then
git merge-base --is-ancestor "$OLD_SHA" "$NEW_SHA" 2>/dev/null
if [ $? -ne 0 ]; then
reject "Force push to $BRANCH is not allowed."
fi
fi
fi
# --- Policy 2: Scan new commits for secrets ---
NEW_COMMITS=$(git rev-list "$OLD_SHA".."$NEW_SHA" 2>/dev/null)
for COMMIT in $NEW_COMMITS; do
DIFF=$(git diff-tree -p "$COMMIT" 2>/dev/null)
if echo "$DIFF" | grep -qE '(AKIA[0-9A-Z]{16}|sk_live_[a-zA-Z0-9]+|-----BEGIN (RSA )?PRIVATE KEY-----)'; then
reject "Potential secret found in commit $(echo $COMMIT | cut -c1-7)."
fi
# --- Policy 3: Require Signed-off-by ---
if ! git log -1 --format='%B' "$COMMIT" | grep -q 'Signed-off-by:'; then
reject "Commit $(echo $COMMIT | cut -c1-7) is missing Signed-off-by."
fi
done
done
if [ $HAS_ERROR -eq 1 ]; then
echo "" >&2
echo "Push rejected. Fix the above issues and try again." >&2
exit 1
fi
echo "✅ All policies passed."
exit 0This hook combines three policies: force push protection, secret scanning in diffs, and sign-off enforcement. The reject function collects errors so the developer sees all violations at once rather than fixing them one at a time.
A broken server hook can reject all pushes, effectively locking out your team. Test hooks on a staging server first, and always include a rollback plan. Keep a copy of the hook in version control so you can restore it quickly.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.