Server-Side Hooks

A server-side hook is a script that lives on the machine hosting a Git repository and runs automatically when that repository receives a push. Unlike client-side hooks, which sit in a developer’s local .git/hooks folder and can simply be deleted, skipped, or never installed in the first place, server-side hooks run on infrastructure the pusher doesn’t control — which makes them the only reliable place to enforce rules like commit message conventions, protected branches, or “no giant binary files,” and the natural place to trigger continuous integration or deployment the moment new commits land. This lesson covers the three server-side hooks Git ships with (pre-receive, update, and post-receive), how to write and install them on a repository you host yourself, and how to get the same effect on GitHub.com, where you can’t drop a script onto their servers.

Overview: How Server-Side Hooks Work

Every Git repository has a hooks directory (inside .git/hooks for a normal repository, or directly inside hooks/ for a bare repository — the kind you host on a server, since it has no working tree). When Git initializes a repository it populates that directory with a set of *.sample files showing example hooks; none of them run until you save an executable file with the exact hook name and no extension. Server-side hooks matter specifically for bare repositories on a machine other people push to: a self-hosted Git server (accessed over SSH or the git:///HTTP protocols), or, for organizations running GitHub Enterprise Server, the hosted repositories themselves. Plain GitHub.com does not give you shell access to the servers storing your repository, so you can’t install a pre-receive script there — later in this lesson we cover the GitHub-native equivalents.

To understand what a server-side hook actually sees, recall Git’s object model: a commit object points to a tree (a snapshot of directory structure), a tree points to blobs (file contents) and other trees, and a branch is nothing more than a file containing the SHA of a commit — a movable pointer, not a copy of anything. When a client runs git push, it sends a packfile containing every object the server is missing, and the server unpacks those objects into its object database before any hook runs. At that point the new commits genuinely exist in the repository’s storage, but the branch pointers (refs) have not moved yet. This is exactly the window server-side hooks operate in: a pre-receive or update hook can run commands like git log or git cat-file against the incoming commits to inspect them, then decide — through its exit code — whether the ref should actually be updated to point at them. If a hook rejects the push, the objects stay in the database (unreferenced, and eventually garbage-collected) but no branch ever points to them, so nothing appears to have happened from the client’s side except an error message.

Git invokes up to four hooks during a push, in this order: pre-receive, then update (once per ref), then, once refs are actually updated, post-receive and the older post-update. pre-receive and post-receive are each invoked once per push and receive every ref being updated as lines on standard input, in the form <old-sha> <new-sha> <full-ref-name> — for example a1b2c3d e4f5g6h refs/heads/main. A SHA of all zeroes means the ref is being created (if it’s the old SHA) or deleted (if it’s the new SHA). The update hook instead runs once per ref and receives three positional arguments, $1 (ref name), $2 (old SHA), $3 (new SHA), which lets it approve some branches in a push while rejecting others.

Syntax

A server-side hook is just an executable file, so there’s no special Git syntax to learn — write it in whatever scripting language you like (this lesson uses POSIX shell, #!/bin/sh, since it’s available on virtually every server), save it with the correct name inside the repository’s hooks/ directory, and make it executable with chmod +x. The table below summarizes the four server-side hooks.

Hook Runs Input Can reject? Typical use
pre-receive Once per push, before any ref is updated All ref updates on stdin Yes — rejects the entire push Protect branches, block force-pushes, enforce policy across everything being pushed
update Once per ref, before that ref is updated Three args: refname, old SHA, new SHA Yes — rejects only that one ref Per-branch rules, e.g. stricter checks on main
post-receive Once per push, after all refs are updated All updated refs on stdin No — refs already moved Notifications, CI triggers, deploy on push
post-update After post-receive, legacy hook Updated ref names as arguments No Historically ran git update-server-info for the dumb HTTP transport; rarely needed today

Setting one up starts with looking inside a bare repository’s hooks folder:

git init --bare /srv/git/project.git
cd /srv/git/project.git/hooks
ls

Output:

Initialized empty Git repository in /srv/git/project.git/
applypatch-msg.sample  pre-commit.sample        pre-receive.sample
commit-msg.sample      pre-merge-commit.sample  prepare-commit-msg.sample
post-update.sample     pre-push.sample          push-to-checkout.sample
fsmonitor-watchman.sample  pre-rebase.sample     update.sample

None of these .sample files run. To activate one, save a file named exactly pre-receive (no extension) in that same directory and make it executable.

Examples

Example 1: Protecting main with a pre-receive hook

This hook reads every ref update Git is about to apply from standard input and, for refs/heads/main specifically, rejects the push if the new SHA is all zeroes (a deletion) or if the old SHA isn’t an ancestor of the new SHA (a history-rewriting force-push, detected with git merge-base). Because pre-receive runs once for the whole push and hasn’t updated anything yet, exiting non-zero anywhere in the loop cancels every ref in that push, not just main.

#!/bin/sh
# pre-receive: protect main from force-pushes and deletion
zero="0000000000000000000000000000000000000000"

while read oldrev newrev refname; do
  if [ "$refname" = "refs/heads/main" ]; then
    if [ "$newrev" = "$zero" ]; then
      echo "Rejected: deleting main is not allowed." >&2
      exit 1
    fi
    if [ "$oldrev" != "$zero" ]; then
      mb=$(git merge-base "$oldrev" "$newrev")
      if [ "$mb" != "$oldrev" ]; then
        echo "Rejected: force-push to main is not allowed." >&2
        exit 1
      fi
    fi
  fi
done

exit 0

Save that as hooks/pre-receive, make it executable, then attempt a force-push from a clone:

chmod +x hooks/pre-receive
# from a developer's clone, attempting to rewrite main's history:
git push origin +feature/login-page:main

Output:

remote: Rejected: force-push to main is not allowed.
To /srv/git/project.git
 ! [remote rejected] feature/login-page -> main (pre-receive hook declined)
error: failed to push some refs to '/srv/git/project.git'

Git prints whatever the hook wrote to standard error, prefixed with remote:, and reports the ref as rejected. Because the rejection happened in pre-receive, this is decided before Git even considers moving any pointer — the repository on the server is left exactly as it was.

Example 2: Enforcing commit message rules per branch with update

The update hook runs once for each ref in the push and receives the ref name and the old/new SHAs as $1, $2, and $3 instead of on stdin. This example walks every new commit being pushed to that one ref with git rev-list and rejects the push if any commit subject doesn’t match a Conventional Commits pattern like feat: add login page.

#!/bin/sh
# update: enforce Conventional Commits messages for new commits on this ref
refname="$1"
oldrev="$2"
newrev="$3"

zero="0000000000000000000000000000000000000000"
[ "$oldrev" = "$zero" ] && range="$newrev" || range="$oldrev..$newrev"

for sha in $(git rev-list "$range"); do
  subject=$(git log -1 --format=%s "$sha")
  if ! echo "$subject" | grep -Eq '^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+'; then
    echo "Rejected commit $sha: \"$subject\" does not follow Conventional Commits." >&2
    exit 1
  fi
done

exit 0

Install it as hooks/update and push a commit with a message that breaks the rule:

chmod +x hooks/update
# from a clone, with a commit message that doesn't follow the convention:
git commit -am "fixed the login bug"
git push origin main

Output:

remote: Rejected commit 9f2ab31: "fixed the login bug" does not follow Conventional Commits.
To /srv/git/project.git
 ! [remote rejected] main -> main (hook declined)
error: failed to push some refs to '/srv/git/project.git'

The specific commit and its message are named in the rejection, which makes it easy for the developer to fix it (with git commit --amend, for a single bad commit) and push again.

Example 3: Deploying on push with post-receive

A post-receive hook can’t reject anything — by the time it runs, the refs have already moved — but it’s the right place to react to a successful push. This classic “push to deploy” pattern checks out the latest main into a live web root whenever main changes:

#!/bin/sh
# post-receive: deploy main to the live webroot whenever it changes
while read oldrev newrev refname; do
  if [ "$refname" = "refs/heads/main" ]; then
    echo "Deploying main to /var/www/myapp ..."
    git --work-tree=/var/www/myapp --git-dir=/srv/git/project.git checkout -f main
  fi
done

Install it as hooks/post-receive and push to main:

chmod +x hooks/post-receive
git push origin main

Output:

remote: Deploying main to /var/www/myapp ...
To /srv/git/project.git
   a1b2c3d..e4f5g6h  main -> main

git --work-tree=<dir> --git-dir=<dir> checkout -f main populates a working tree from the bare repository without a separate clone. In production you’d typically call an external deploy script here and log the deployment, but the mechanism — a hook reacting to the ref update — is the same one behind many self-hosted CI/CD setups.

GitHub.com and Server-Side Hooks

GitHub.com doesn’t let you place a script in a repository’s hooks/ directory — you have no shell access to the servers storing the repository. (If you run GitHub Enterprise Server on your own infrastructure, it does support real pre-receive hooks, configured by an administrator, using exactly the mechanism described above.) On GitHub.com, the equivalent policies are built from three features instead:

  • Branch protection rules (repository Settings, then Branches) can block force-pushes and deletions of a branch like main — the same outcome as the pre-receive example above, configured through the UI instead of a script.
  • Required status checks make a pull request unmergeable until specific checks report success, playing the role a strict update hook would — policy enforced before history changes, just gated on merging a pull request rather than on every push.
  • GitHub Actions reacts to events (a push, a pull request) the same way a post-receive hook does, and can run tests, build artifacts, or deploy.

A minimal workflow that acts as a required status check looks like this:

name: CI

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test

With this workflow saved as .github/workflows/ci.yml and the test job selected as a required status check under branch protection for main, a pull request cannot be merged until it passes — the same net effect as a policy-enforcing server-side hook, implemented on GitHub’s infrastructure instead of your own.

How It Works, Step by Step

When a developer runs git push origin main, here’s the full sequence on the server:

  1. The client and server negotiate which objects the server is missing, then the client sends them as a single packfile over the transport (SSH, HTTPS, or git://).
  2. The server’s git-receive-pack process unpacks that packfile into the repository’s object database. The new commits, trees, and blobs now exist on disk, but no ref points to them yet.
  3. git-receive-pack invokes pre-receive exactly once, passing every ref update in the push on standard input. If it exits non-zero, the entire push is rejected here and no ref is touched.
  4. For each ref that survived pre-receive, git-receive-pack invokes update with that ref’s name and old/new SHAs. A non-zero exit rejects only that ref; other refs in the same push can still proceed.
  5. Every ref that passed both hooks is actually updated — Git writes the new SHA into refs/heads/<branch> (or into packed-refs).
  6. post-receive runs once, receiving the list of refs that were actually updated. Its exit code is ignored by Git — the push has already succeeded from the client’s point of view.
  7. The legacy post-update hook runs last, receiving the updated ref names as plain arguments (not old/new SHAs). It exists mainly for repositories served over the “dumb” HTTP protocol, which need git update-server-info re-run after every change.
  8. git-receive-pack reports success or failure for each ref back to the client, which git push then prints.

Common Mistakes

Forgetting to make the hook executable

Saving a file at hooks/pre-receive without running chmod +x on it doesn’t produce an error — Git silently skips hook files that aren’t executable and the push just goes through as if no policy existed. If a hook doesn’t seem to be firing, run ls -l hooks/ and check for the executable bit before debugging the script itself.

Editing the .sample file instead of creating a new one

The sample scripts Git ships (pre-receive.sample, and so on) are never executed under their .sample name. Editing pre-receive.sample and expecting it to run is a common first mistake — copy or rename it (or write your own) to a file named exactly pre-receive.

Assuming a hook enforces policy on every clone

Hooks live in a repository’s own hooks/ directory, which is not part of the object model and is never transferred by git clone, git push, or git pull. Installing a strict pre-receive hook only affects pushes to that one server-side copy; anyone’s local clone still has a normal, hook-free .git/hooks. If you need every clone to share client-side hooks, that’s a separate mechanism (git config core.hooksPath pointing at a versioned folder), and it still can’t stop a user from disabling it locally — which is exactly why enforcement belongs on the server.

Not consuming all of standard input

Because pre-receive and post-receive receive every ref update on stdin, a hook that exits early without reading the remaining lines can leave the pipe from git-receive-pack partially unread. Loop through all of stdin (as the examples in this lesson do with while read ...; do ... done) even when you already know you’re going to reject the push, so the hook exits cleanly.

Best Practices

  • Keep hooks fast. They run synchronously in the middle of every push; a slow pre-receive makes every developer’s git push feel slow.
  • Write clearly to standard error (>&2) when rejecting a push — that text is exactly what Git prefixes with remote: and shows the developer, so a vague or missing message just looks like an unexplained failure.
  • Keep hook source in version control alongside the project, and deploy it to the server with a script rather than editing files by hand over SSH.
  • Prefer pre-receive over update for any rule that should apply atomically to the whole push, since update‘s per-ref rejection can leave a push half-applied.
  • Test hooks locally first: git init --bare a throwaway repository, install the hook, and push to it from a scratch clone before deploying to a shared server.
  • On GitHub.com, reach for branch protection rules and required status checks before reaching for a bespoke solution — they cover most of what a pre-receive hook would, without needing your own server.
  • Check every new commit in a push, not just its tip — a hook that only inspects the newest commit (rather than the full range, as the update example does with git rev-list) can be bypassed by burying a bad commit in the middle of a larger push.

Practice Exercises

  1. Create a bare repository with git init --bare, add a pre-receive hook that rejects any commit whose author email doesn’t end in your organization’s domain (hint: read each ref’s new SHA from stdin and check git log -1 --format=%ae <sha>), then clone it and confirm a push is rejected with a clear message.
  2. Extend the update hook example from this lesson so that pushes to any branch matching release/* also require every commit message to include a ticket ID like PROJ-123, while other branches keep the plain Conventional Commits rule.
  3. On a real GitHub repository, enable a branch protection rule on main requiring a pull request before merging and requiring a status check to pass, add the sample CI workflow from this lesson, and open a pull request with a failing test to confirm GitHub blocks the merge button.

Summary

  • Server-side hooks are executable scripts in a bare repository’s hooks/ directory that run on the server when it receives a push — unlike client-side hooks, they can’t be bypassed by the person pushing.
  • pre-receive runs once per push, before any ref moves, and can reject the entire push; update runs once per ref and can reject just that ref; post-receive and post-update run after refs are updated and can’t reject anything, making them suited to notifications, CI, and deployment.
  • Hooks read ref updates as <old-sha> <new-sha> <refname> lines on stdin (pre-receive/post-receive) or as positional arguments (update); an all-zero SHA means a branch is being created or deleted.
  • By the time any hook runs, the pushed objects are already in the object database — only the ref pointer is still undecided, which is exactly what the hook’s exit code controls.
  • GitHub.com has no shell access for custom hooks; branch protection rules, required status checks, and GitHub Actions reproduce the same policies at the hosted-product level.