GitServer-Side Hooks

Server-Side Hooks

Server-side hooks run on the remote Git server (the bare repository) in response to push operations. Unlike client-side hooks, they cannot be bypassed by individual developers with --no-verify. They are the definitive enforcement layer for policy, and they power deployment pipelines and notification systems in self-hosted Git environments.

Where server hooks live

On a bare Git repository the hooks directory is hooks/ (not .git/hooks/):

Bash
# Bare repo structure
/srv/git/myproject.git/
├── HEAD
├── config
├── hooks/           ← hooks live here
│   ├── pre-receive
│   ├── update
│   └── post-receive
├── objects/
└── refs/

Bash
# On the server, activate a hook
chmod +x /srv/git/myproject.git/hooks/pre-receive
The three server-side hooks

Hook

Fires

Receives on stdin

Can reject?

pre-receive

Once, before any refs are updated

All refs being pushed (one per line)

Yes — exit non-zero rejects the entire push

update

Once per ref being updated

Three arguments: <ref-name> <old-sha> <new-sha>

Yes — exit non-zero rejects only that ref

post-receive

Once, after all refs are updated

Same as pre-receive (all updated refs)

No — push has already landed

pre-receive

pre-receive is the most powerful server hook. It runs once per push and can inspect all the proposed ref updates together. If it exits non-zero, the entire push is rejected and nothing is changed on the server.

It reads from stdin — each line has the format: <old-sha> <new-sha> <ref-name>

stdin for a push that updates two refs

Text
0000000000000000000000000000000000000000 a1b2c3d4... refs/heads/feature/login
b3c4d5e6... f7a8b9c0... refs/heads/main

A zero SHA for the old value means the ref is being created (new branch/tag). A zero SHA for the new value means the ref is being deleted.

hooks/pre-receive — reject force-pushes to protected branches

Bash
#!/usr/bin/env bash
set -euo pipefail

ZERO_SHA="0000000000000000000000000000000000000000"
PROTECTED="main master release"

while read old_sha new_sha ref_name; do
  branch="${ref_name#refs/heads/}"

  for protected in $PROTECTED; do
    if [ "$branch" = "$protected" ]; then
      # Detect force-push: old commit is not an ancestor of new commit
      if [ "$old_sha" != "$ZERO_SHA" ]; then
        if ! git merge-base --is-ancestor "$old_sha" "$new_sha" 2>/dev/null; then
          echo "✗ Force-push to '$branch' is not allowed."
          echo "  Branch '$branch' is protected."
          exit 1
        fi
      fi
    fi
  done
done

exit 0
update

update fires once per ref being pushed (as opposed to pre-receive which fires once for the entire push). It receives three positional arguments: the ref name, the old SHA, and the new SHA. It can selectively reject individual refs while allowing others through.

hooks/update — enforce branch naming conventions

Bash
#!/usr/bin/env bash
set -euo pipefail

REF_NAME="$1"
OLD_SHA="$2"
NEW_SHA="$3"

# Only validate branch refs
if [[ "$REF_NAME" != refs/heads/* ]]; then
  exit 0
fi

BRANCH="${REF_NAME#refs/heads/}"
ZERO_SHA="0000000000000000000000000000000000000000"

# Allow deletions
if [ "$NEW_SHA" = "$ZERO_SHA" ]; then
  exit 0
fi

# Enforce naming: main, develop, feature/*, fix/*, hotfix/*, release/*, chore/*
VALID_PATTERN='^(main|master|develop|feature/.+|fix/.+|hotfix/.+|release/.+|chore/.+|docs/.+)$'

if ! echo "$BRANCH" | grep -qE "$VALID_PATTERN"; then
  echo "✗ Branch name '$BRANCH' does not follow the naming convention."
  echo ""
  echo "  Allowed patterns:"
  echo "    main, develop"
  echo "    feature/<description>"
  echo "    fix/<description>"
  echo "    hotfix/<description>"
  echo "    release/<version>"
  echo "    chore/<description>"
  echo "    docs/<description>"
  echo ""
  echo "  Example: git branch -m $BRANCH feature/$(echo $BRANCH | tr ' ' '-')"
  exit 1
fi

# Enforce max branch name length
if [ ${#BRANCH} -gt 100 ]; then
  echo "✗ Branch name is too long (${#BRANCH} chars). Max: 100."
  exit 1
fi

exit 0
post-receive

post-receive runs after the push completes successfully. It cannot reject the push — the data is already stored. It is used for notifications, triggering CI/CD, updating issue trackers, and deployments. It receives the same stdin format as pre-receive.

hooks/post-receive — send a Slack notification

Bash
#!/usr/bin/env bash

SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../B.../..."

while read old_sha new_sha ref_name; do
  branch="${ref_name#refs/heads/}"
  pusher=$(git log -1 --pretty=format:'%an' "$new_sha")
  message=$(git log -1 --pretty=format:'%s' "$new_sha")
  short_sha=$(echo "$new_sha" | cut -c1-8)

  payload=$(cat <<JSON
{
  "text": "*[$branch]* $pusher pushed `$short_sha`: $message"
}
JSON
)

  curl -s -X POST -H 'Content-type: application/json'     --data "$payload"     "$SLACK_WEBHOOK_URL"
done

hooks/post-receive — trigger a CI build via webhook

Bash
#!/usr/bin/env bash

CI_WEBHOOK="https://ci.example.com/webhook/build"
REPO_NAME=$(basename "$(pwd)" .git)

while read old_sha new_sha ref_name; do
  branch="${ref_name#refs/heads/}"

  # Only trigger CI for main and feature branches
  if [[ "$branch" != "main" && "$branch" != feature/* ]]; then
    continue
  fi

  curl -s -X POST "$CI_WEBHOOK"     -H "Content-Type: application/json"     -d "{"repo":"$REPO_NAME","branch":"$branch","sha":"$new_sha"}"

  echo "CI build triggered for $branch ($new_sha)"
done

hooks/post-receive — deploy on push to main

Bash
#!/usr/bin/env bash

while read old_sha new_sha ref_name; do
  branch="${ref_name#refs/heads/}"

  if [ "$branch" = "main" ]; then
    echo "Deploying to production..."
    # Run your deployment script
    /srv/deploy/deploy.sh "$new_sha" >> /var/log/deploy.log 2>&1 &
    echo "Deployment started in background. Check /var/log/deploy.log"
  fi
done
Validate commit messages server-side

hooks/update — reject commits with invalid messages

Bash
#!/usr/bin/env bash
set -euo pipefail

REF_NAME="$1"
OLD_SHA="$2"
NEW_SHA="$3"
ZERO_SHA="0000000000000000000000000000000000000000"

# Only check branch updates (not tags or deletions)
if [[ "$REF_NAME" != refs/heads/* ]] || [ "$NEW_SHA" = "$ZERO_SHA" ]; then
  exit 0
fi

# Determine the range of new commits
if [ "$OLD_SHA" = "$ZERO_SHA" ]; then
  # New branch — check all commits reachable from NEW_SHA but not from other branches
  RANGE="$NEW_SHA"
  COMMITS=$(git rev-list --not --branches "$RANGE" 2>/dev/null || git rev-list "$RANGE" | head -50)
else
  COMMITS=$(git rev-list "$OLD_SHA..$NEW_SHA")
fi

PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)((.+))?: .{1,72}'

while IFS= read -r sha; do
  [ -z "$sha" ] && continue
  MSG=$(git log -1 --pretty=%s "$sha")

  # Skip merge commits
  if echo "$MSG" | grep -qE '^Merge '; then
    continue
  fi

  if ! echo "$MSG" | grep -qE "$PATTERN"; then
    echo "✗ Commit $sha has an invalid message:"
    echo "  '$MSG'"
    echo "  Expected: <type>(scope): <description>"
    exit 1
  fi
done <<< "$COMMITS"

exit 0
How GitHub/GitLab webhooks relate to post-receive

Hosted Git platforms (GitHub, GitLab, Bitbucket) do not give you direct access to the server's hooks/ directory. Instead, they expose webhooks — HTTP callbacks sent to a URL you configure whenever a push (or PR, or issue, etc.) occurs. Webhooks are functionally equivalent to post-receive but run on the platform's infrastructure.

Aspect

Server hooks (self-hosted)

Platform webhooks (GitHub/GitLab)

Access

Full shell access on the server

HTTP endpoint you control

Can reject push

Yes (pre-receive/update)

No — push already landed when webhook fires

Payload

Raw Git refs on stdin

JSON body with rich metadata

Setup

Write a script in hooks/

Configure a URL in repo settings

Language

Any (shell, Python, Node, …)

Any HTTP server

Availability

Only for bare repos you control

Available on all hosted repos

GitHub branch protection rules replace pre-receive
GitHub's branch protection rules (require PR, require status checks, restrict force-push) are the hosted equivalent of `pre-receive` hooks. If you're on GitHub, use branch protection rather than trying to self-host a bare repo just for hooks.
Setting up hooks on a bare Git server
  1. SSH to the server: ssh git@server

  2. Initialise a bare repo: git init --bare /srv/git/myproject.git

  3. Write the hook script: vim /srv/git/myproject.git/hooks/pre-receive

  4. Make it executable: chmod +x /srv/git/myproject.git/hooks/pre-receive

  5. Test by pushing from a client: git push git@server:/srv/git/myproject.git main

Minimal test on the server

Bash
# On the server
git init --bare /srv/git/testrepo.git

cat > /srv/git/testrepo.git/hooks/pre-receive << 'EOF'
#!/usr/bin/env bash
echo "pre-receive: push received"
while read old new ref; do
  echo "  Updating $ref: $old -> $new"
done
exit 0
EOF

chmod +x /srv/git/testrepo.git/hooks/pre-receive

# From a client
git remote add server git@server:/srv/git/testrepo.git
git push server main
Common server-side hook use cases
  • Reject force-pushes to protected branches (pre-receive).

  • Enforce branch naming conventions (update).

  • Require GPG-signed commits (update — check git log --show-signature).

  • Validate commit messages against Conventional Commits (update).

  • Trigger CI builds on push (post-receive).

  • Send Slack/Teams/email notifications on new pushes or tags (post-receive).

  • Auto-deploy a staging environment when a specific branch is pushed (post-receive).

  • Update a ticket tracker (e.g., close Jira issues referenced in commit messages) (post-receive).

  • Reject large files to prevent accidental binary uploads (pre-receive).

Warning
Server-side hooks run as the Git server's process user (often `git`). Be careful with scripts that write to disk, make network calls, or run long-running processes. A blocking hook delays ALL developers pushing to the repo. Keep synchronous logic fast; fire long operations in the background with `&` and redirect output to a log file.
Tip
Test server hooks locally by pointing `core.hooksPath` at a temporary directory. Simulate stdin with a pipe: `echo "0000... abc123... refs/heads/feature/test" | bash hooks/pre-receive`. This lets you iterate without pushing to the real server.