Server-Side Hooks for Enforcement
Learn Git In Action - Part 106
Designing server-side Git hooks as authoritative repository enforcement points for ref updates, release integrity, and compliance-grade workflow invariants.
Server-Side Hooks for Enforcement
Server-side hooks adalah titik kontrol yang berjalan di repository remote saat client melakukan push.
Jika client-side hooks adalah feedback, server-side hooks adalah enforcement boundary.
Mereka berada di sisi repository yang menerima update. Karena itu server-side hooks cocok untuk mencegah ref update yang melanggar invariant shared repository, misalnya:
- direct push ke protected branch;
- non-fast-forward update ke branch publik;
- release tag digeser;
- unsigned release tag;
- commit author/email tidak sesuai policy;
- commit message tidak memuat metadata wajib;
- path sensitif berubah tanpa metadata tertentu;
- secret pattern terdeteksi di incoming commits;
- file terlalu besar masuk history;
- branch naming melanggar convention;
- push menghapus branch/tag yang tidak boleh dihapus.
Tapi server-side hooks juga punya batas. Mereka berjalan pada waktu push, bukan pada waktu review atau merge queue. Mereka tidak menggantikan CI, branch protection, CODEOWNERS, SAST, dependency scanning, release approval, atau artifact provenance.
Mental model yang benar:
Server-side hook menolak update ref yang tidak boleh pernah masuk remote. CI dan branch protection menilai apakah perubahan layak diintegrasikan. Release gate menilai apakah commit layak menjadi artifact production.
1. Client-Side vs Server-Side Hooks
Perbandingan singkat:
| Aspek | Client-side hook | Server-side hook |
|---|---|---|
| Lokasi | Mesin developer | Remote repository/server |
| Tujuan utama | Fast feedback | Enforcement |
| Bisa di-bypass user biasa | Sering bisa | Tidak, jika user tidak punya server access |
| Akses ke remote refs | Terbatas | Authoritative |
| Cocok untuk | lint cepat, commit message feedback, staged checks | protected ref update, tag immutability, policy wajib |
| Risiko | environment variance | availability/performance/security server |
| Failure impact | developer lokal terganggu | push seluruh tim bisa gagal |
Diagram:
2. Server-Side Hook Lifecycle
Server-side hooks yang paling umum:
pre-receive
update
proc-receive
post-receive
post-update
reference-transaction
push-to-checkout
Untuk enforcement push, tiga hook utama adalah:
pre-receiveupdatepost-receive
pre-receive
Berjalan sekali untuk seluruh push. Menerima daftar ref update lewat stdin.
Format input:
<old-oid> <new-oid> <ref-name>
Contoh:
4a1c... 9f7b... refs/heads/main
0000... a3b2... refs/heads/feature/x
7d8e... 0000... refs/tags/v1.2.0
Cocok untuk policy yang perlu melihat seluruh batch push secara utuh:
- atomic validation banyak ref;
- melarang kombinasi branch/tag tertentu;
- secret scan incoming commits;
- file size scan;
- push-wide audit logging;
- role/policy decision berdasarkan user + ref set.
Jika pre-receive exit non-zero, seluruh push ditolak.
update
Berjalan satu kali per ref yang diupdate.
Argumen:
<ref-name> <old-oid> <new-oid>
Cocok untuk policy per ref:
- branch name;
- fast-forward requirement;
- tag immutability;
- deletion prevention;
- release branch rule.
Jika satu ref gagal, ref itu ditolak. Behavior batch tergantung server/protocol/config.
post-receive
Berjalan setelah update diterima.
Cocok untuk side effects:
- notifikasi;
- audit event;
- trigger deployment;
- mirror sync;
- indexing;
- metrics.
Tidak cocok untuk menolak push karena ref sudah diterima.
3. Ref Update Model
Server-side hook harus memahami ref update sebagai state transition.
Setiap input:
old new ref
mewakili satu transisi:
| Case | old | new | Makna |
|---|---|---|---|
| create | all zero | object id | ref baru dibuat |
| update | object id | object id | ref bergerak |
| delete | object id | all zero | ref dihapus |
Pseudo-state machine:
Policy harus eksplisit untuk setiap case.
Contoh invariant:
refs/heads/main:
create: deny unless bootstrap window
update: allow only fast-forward by merge bot
delete: deny
refs/tags/v*:
create: allow if annotated+signed
update: deny
delete: deny
4. Pre-Receive Hook Skeleton
Contoh minimal:
#!/usr/bin/env bash
set -euo pipefail
zero="0000000000000000000000000000000000000000"
while read -r old new ref; do
echo "Checking $ref"
if [[ "$ref" == "refs/heads/main" ]]; then
if [[ "$new" == "$zero" ]]; then
echo "Reject: deleting main is not allowed"
exit 1
fi
if [[ "$old" != "$zero" ]] && ! git merge-base --is-ancestor "$old" "$new"; then
echo "Reject: non-fast-forward update to main"
exit 1
fi
fi
done
Catatan penting:
- jangan hanya parse branch name;
- cek object type jika perlu;
- cek create/update/delete case;
- handle zero OID;
- gunakan command Git plumbing yang aman;
- tampilkan pesan yang jelas kepada pusher.
5. Enforcing Fast-Forward for Shared Branches
Fast-forward update berarti old commit reachable dari new commit:
git merge-base --is-ancestor "$old" "$new"
Jika return code 0, update fast-forward.
Hook:
is_zero() {
[[ "$1" == "0000000000000000000000000000000000000000" ]]
}
require_fast_forward() {
local old="$1"
local new="$2"
local ref="$3"
if is_zero "$old" || is_zero "$new"; then
return 0
fi
if ! git merge-base --is-ancestor "$old" "$new"; then
echo "Reject: $ref must be updated by fast-forward only."
echo "Old $old is not ancestor of new $new."
return 1
fi
}
Policy nuance:
- feature branches may allow force push;
- protected branches should reject non-fast-forward;
- release branches often require fast-forward only or merge-bot-only;
- tags should generally be immutable, not fast-forward checked.
6. Enforcing Immutable Release Tags
Release tags should be append-only.
Tag update cases:
case "$ref" in
refs/tags/v*)
if is_zero "$old" && ! is_zero "$new"; then
# create
elif ! is_zero "$old" && is_zero "$new"; then
# delete
else
# update/move
fi
;;
esac
Hook:
if [[ "$ref" == refs/tags/v* ]]; then
if ! is_zero "$old"; then
echo "Reject: release tag $ref already exists and is immutable."
exit 1
fi
if ! git cat-file -e "$new^{tag}" 2>/dev/null; then
echo "Reject: release tag $ref must be an annotated tag object."
exit 1
fi
if ! git tag -v "${ref#refs/tags/}" >/dev/null 2>&1; then
echo "Reject: release tag $ref must be signed and verifiable."
exit 1
fi
fi
Caveat: signature verification depends on trust store available on server. A cryptographic signature without trusted key management is just syntax.
7. Branch Naming Policy
Branch naming is not just aesthetics. It affects automation, review routing, release scripts, and permissions.
Example rules:
feature/<ticket>-<slug>
fix/<ticket>-<slug>
hotfix/<ticket>-<slug>
release/<version>
support/<major.minor>
Hook:
branch_name="${ref#refs/heads/}"
if [[ "$ref" == refs/heads/* ]]; then
case "$branch_name" in
main|master|develop) ;;
feature/*|fix/*|hotfix/*|release/*|support/*) ;;
*)
echo "Reject: invalid branch name: $branch_name"
echo "Expected: feature/*, fix/*, hotfix/*, release/*, support/*"
exit 1
;;
esac
fi
Do not overfit regex. If naming policy becomes hard to remember, developers will game it.
8. Commit Message Policy on Incoming Commits
Server-side hook can inspect commits introduced by push.
Find new commits:
git rev-list "$old..$new"
For branch creation, old is zero. Use exclusions to avoid scanning all history:
git rev-list "$new" --not --all
Example:
new_commits() {
local old="$1"
local new="$2"
if is_zero "$new"; then
return 0
fi
if is_zero "$old"; then
git rev-list "$new" --not --all
else
git rev-list "$old..$new"
fi
}
for commit in $(new_commits "$old" "$new"); do
subject=$(git log -1 --format=%s "$commit")
if ! grep -qE '^(feat|fix|refactor|test|docs|chore|build|ci)(\(.+\))?: .+' <<< "$subject"; then
echo "Reject: commit $commit has invalid subject: $subject"
exit 1
fi
done
But avoid checking thousands of old commits on first push of a fork mirror. Policy needs scope.
9. Author and Identity Policy
Server-side hooks can validate commit author/committer metadata.
Example:
email=$(git log -1 --format=%ae "$commit")
if ! grep -qE '@company\.com$' <<< "$email"; then
echo "Reject: commit $commit uses non-company author email: $email"
exit 1
fi
But identity validation is subtle:
- author can be different from committer legitimately;
- bots may use service accounts;
- open-source contributors may use personal emails;
- signed commits may be more meaningful than email domain;
.mailmapaffects display, not object metadata;- rebasing can rewrite committer metadata.
For regulated/internal repos, better policy:
Protected branches require commits introduced by merge bot after reviewed PR.
Human identity is captured by PR review/approval system.
Commit metadata is useful evidence, not sole identity control.
10. Sensitive Path Policy
Some paths need stronger controls:
.github/workflows/**
infra/**
deploy/**
security/**
auth/**
db/migrations/**
policy/**
CODEOWNERS
Server-side hooks can detect path changes in incoming commits:
changed_paths=$(git diff-tree --no-commit-id --name-only -r "$commit")
if grep -qE '^(infra|deploy|security|auth)/' <<< "$changed_paths"; then
if ! git log -1 --format=%B "$commit" | grep -q '^Risk:'; then
echo "Reject: sensitive change $commit requires Risk trailer."
exit 1
fi
fi
But path-based server hooks cannot know PR review status unless integrated with hosting API. In many systems, sensitive path approval belongs in CODEOWNERS/branch protection/CI, not raw Git hook.
Use server hook for hard technical invariants; use hosting policy for review governance.
11. File Size and Binary Policy
Rejecting huge blobs at push time is valuable because once a large blob enters history, deleting the file in a later commit does not remove the object from history.
Example scan:
max_bytes=$((10 * 1024 * 1024))
for commit in $(new_commits "$old" "$new"); do
while read -r mode type oid size path; do
if [[ "$type" == "blob" && "$size" -gt "$max_bytes" ]]; then
echo "Reject: $path in $commit is $size bytes; max is $max_bytes. Use LFS/artifact storage."
exit 1
fi
done < <(git ls-tree -r -l "$commit")
done
Optimization: scanning full tree for every commit is expensive. Better scan changed blobs:
git diff-tree -r --no-commit-id --diff-filter=AM --name-only "$commit"
Then inspect blob at commit:
git cat-file -s "$commit:$path"
Need careful path quoting for spaces/newlines. For production hook, use -z formats.
12. Secret Scanning at Receive Time
Server-side secret scanning is stronger than client-side because every push to remote passes through it.
Simple pattern scan:
for commit in $(new_commits "$old" "$new"); do
if git diff-tree -p "$commit" | grep -E '(AKIA[0-9A-Z]{16}|-----BEGIN PRIVATE KEY-----)'; then
echo "Reject: potential secret detected in commit $commit"
echo "If this is a real credential, rotate it immediately."
exit 1
fi
done
Production-grade caveats:
- avoid printing secret value back to terminal;
- use provider-specific validators;
- handle binary files;
- handle allowlists carefully;
- scan all newly introduced commits, not just tip;
- integrate with secret incident workflow;
- do not assume rejected push means secret was never exposed: it may exist in local clone, logs, terminal scrollback, CI attempts, or another remote.
13. Preventing Dangerous Deletes
Deleting shared branch or tag should be explicit.
Hook:
if is_zero "$new"; then
case "$ref" in
refs/heads/main|refs/heads/master|refs/heads/release/*|refs/tags/v*)
echo "Reject: deletion of protected ref $ref is not allowed."
exit 1
;;
esac
fi
Deletion policy by ref class:
| Ref class | Delete? | Notes |
|---|---|---|
refs/heads/main | never | Protected trunk |
refs/heads/release/* | rarely | Require archival process |
refs/tags/v* | never | Release identity |
refs/heads/feature/* | allowed | After merge/close |
| temporary refs | allowed | With namespace control |
14. Atomic Push and Multi-Ref Validation
A push can update multiple refs.
Example:
git push origin main v1.2.0
If policy depends on relation between branch and tag, use pre-receive because it sees entire batch.
Example invariant:
If pushing release tag vX.Y.Z, the tagged commit must be reachable from refs/heads/main or refs/heads/release/X.Y after considering this push batch.
This is tricky because refs may not be updated yet when pre-receive runs. You may need to reason from proposed old/new values rather than current refs alone.
Simpler practical policy:
- require release tags created only by release bot;
- bot creates tag after merge and CI verification;
- hook validates tag shape/signature/immutability;
- release gate validates reachability and artifact provenance.
Do not overcomplicate raw Git hooks if hosting/CI provides better context.
15. Performance Constraints
Server hooks run in the critical path of push.
Bad server hook can make every push slow or unavailable.
Rules:
- scan only new commits;
- avoid network calls in
pre-receive; - avoid full repository scans on each push;
- cache expensive data if safe;
- put heavy checks in async CI;
- set timeouts;
- provide clear failure messages;
- test hook against large push/mirror push;
- handle pushes with thousands of commits explicitly.
Expensive patterns:
git log --all
find .
git grep pattern $(git rev-list --all)
call external API per commit
Safer:
git rev-list "$old..$new"
git diff-tree for each new commit
batch API call once, if unavoidable
16. Environment Model
Server hooks run in a special environment.
Do not assume:
- interactive terminal;
- normal user shell profile;
- full PATH;
- working tree exists;
- network available;
- secrets are safe to expose;
- arbitrary language runtime is installed.
Use absolute paths or controlled runtime.
Common server-side hook repository is bare. There may be no working tree.
So this is wrong in many server hooks:
npm test
because there may be no checked-out source directory.
Raw Git object inspection is safer:
git cat-file -t "$new"
git diff-tree -r --name-only "$commit"
git show "$commit:path/to/file"
17. Hook Logging and Audit
Enforcement without audit is painful during incidents.
Log:
- timestamp;
- actor if server exposes it;
- remote address/session id if safe;
- ref name;
- old/new OID;
- decision allow/deny;
- policy rule id;
- reason;
- hook version.
Example:
log_decision() {
local decision="$1"
local ref="$2"
local old="$3"
local new="$4"
local reason="$5"
printf '%s decision=%s ref=%s old=%s new=%s reason=%q hook_version=%s\n' \
"$(date -u +%FT%TZ)" "$decision" "$ref" "$old" "$new" "$reason" "2026.07.07" \
>> /var/log/git-policy/pre-receive.log
}
Avoid logging full diff or secret values.
For regulated systems, keep policy versioned and link deny decision to rule id:
GIT-POLICY-REL-TAG-IMMUTABLE
GIT-POLICY-MAIN-FF-ONLY
GIT-POLICY-NO-LARGE-BLOB
18. Policy Versioning
Server hook code must be versioned.
Bad:
Someone edited /srv/git/repo.git/hooks/pre-receive manually.
Better:
policy-repo/
hooks/
pre-receive
update
lib/
ref-policy.sh
secret-scan.sh
file-size.sh
tests/
CHANGELOG.md
policy.yaml
Deployment flow:
Hook policy is production code. Treat it accordingly.
19. Testing Server-Side Hooks Locally
Create a bare repository:
server=$(mktemp -d)/server.git
git init --bare "$server"
Install hook:
cp hooks/pre-receive "$server/hooks/pre-receive"
chmod +x "$server/hooks/pre-receive"
Clone and test:
client=$(mktemp -d)/client
git clone "$server" "$client"
cd "$client"
git config user.name Test
git config user.email test@example.com
echo ok > README.md
git add README.md
git commit -m "docs: add readme"
git push origin HEAD:main
Test reject:
base64 /dev/urandom | head -c 20000000 > big.bin
git add big.bin
git commit -m "chore: add huge binary"
! git push origin HEAD:big-file-test
CI should run hook tests against fixture repositories and push scenarios.
20. Server Hooks vs Hosting Branch Protection
Modern Git hosting systems often provide built-in controls:
- protected branches;
- required reviews;
- required status checks;
- CODEOWNERS;
- required signed commits;
- merge queue;
- protected tags/rulesets;
- secret scanning;
- push rules.
When should you use raw server-side hook?
Use hooks when:
- hosting lacks needed policy;
- self-hosted Git server supports hooks;
- policy is low-level ref/object invariant;
- you need custom branch/tag rules;
- you need receive-time rejection before CI;
- you need central policy across many repos.
Use branch protection/CI when:
- policy involves PR review state;
- policy depends on check results;
- policy depends on CODEOWNERS;
- policy requires UI-visible approvals;
- policy needs merge queue context;
- policy should be manageable by repo admins.
Use release gates when:
- policy concerns artifact, deployment, environment, approval, or provenance.
Layered model:
21. Example: Policy Configuration File
Hardcoding everything in Bash becomes unmaintainable.
Example policy.yaml:
protectedBranches:
- refs/heads/main
- refs/heads/release/*
immutableTags:
- refs/tags/v*
maxBlobBytes: 10485760
branchPatterns:
- refs/heads/feature/*
- refs/heads/fix/*
- refs/heads/hotfix/*
- refs/heads/release/*
- refs/heads/support/*
sensitivePaths:
- .github/workflows/**
- infra/**
- deploy/**
- auth/**
- db/migrations/**
Hook reads config and applies generic checks. This improves consistency across repositories.
But avoid dynamic config from branch being pushed unless carefully designed. An attacker could modify policy in the same push if hook trusts repository content from incoming commit.
Policy should come from trusted server-side config or separately protected policy repository.
22. Avoid Trusting Incoming Code for Policy
Dangerous pattern:
git show "$new:.git-policy.yml" | ./policy-evaluator
Problem: attacker changes .git-policy.yml in the same commit to weaken policy.
Safer patterns:
- policy stored outside repository on server;
- policy file only from protected default branch old state;
- policy file change requires admin/release-manager workflow;
- policy evaluator uses default deny if policy missing/invalid;
- sensitive path changes are checked by central config.
If repository-owned policy is required, define trust rules:
Changes to .git-policy.yml do not affect the same push.
New policy applies only after merge into protected branch and approval by platform team.
23. Failure Modes
Failure mode 1: Hook down blocks all pushes
Mitigation:
- hook has tests;
- deployment can rollback;
- emergency bypass documented;
- canary repo;
- timeout control;
- clear owner.
Failure mode 2: Hook too slow
Mitigation:
- scan new commits only;
- avoid network in pre-receive;
- profile hook;
- move heavy checks to CI.
Failure mode 3: Hook rejects legitimate emergency
Mitigation:
- break-glass role;
- audit reason;
- time-bounded exception;
- follow-up postmortem.
Failure mode 4: Hook validates wrong commit range
Common bug on branch creation. Use new --not --all rather than scanning entire history blindly.
Failure mode 5: Hook can be bypassed by alternate path
Examples:
- admin writes refs directly;
- hosting UI bypass;
- mirror job uses privileged token;
- repository migrated without hooks;
- protected branch rule missing on new repo.
Mitigation: defense in depth.
24. Break-Glass Design
A serious organization needs emergency override, not hidden backdoor.
Break-glass policy:
Allowed only for Sev-1 incident.
Requires incident id.
Requires named approver.
Logs actor, ref, old/new, reason.
Expires automatically.
Triggers post-incident review.
Implementation idea:
if [[ "${GIT_BREAK_GLASS:-}" == "1" ]]; then
if [[ -z "${INCIDENT_ID:-}" ]]; then
echo "Break-glass requires INCIDENT_ID."
exit 1
fi
log_decision "allow-break-glass" "$ref" "$old" "$new" "$INCIDENT_ID"
continue
fi
In hosted systems, prefer official bypass/audit mechanism over environment variable hacks.
25. Production Hook Checklist
Before deploying server-side hooks:
- Policy goals are documented.
- Ref classes are defined.
- Create/update/delete cases are handled.
- Zero OID handling is correct.
- Fast-forward logic is tested.
- Tag immutability is enforced.
- Large blob check scans only new/changing blobs.
- Secret scanning avoids printing secrets.
- Hook is fast on large pushes.
- Hook has fixture tests.
- Hook deployment is versioned.
- Hook logs allow/deny decisions.
- Hook has rollback path.
- Emergency bypass is explicit and audited.
- Heavy checks are delegated to CI/release gates.
- Policy cannot be weakened by incoming code.
26. Mental Model Akhir
Server-side hooks adalah repository firewall.
Firewall yang baik tidak mencoba memahami seluruh bisnis aplikasi. Ia memblokir hal yang jelas tidak boleh lewat:
- ref protected dihapus;
- release tag digeser;
- non-fast-forward masuk branch publik;
- object terlalu besar masuk history;
- secret jelas masuk push;
- branch naming merusak automation;
- unsigned release tag dibuat.
Untuk hal yang membutuhkan context review, test, ownership, deployment, atau artifact provenance, gunakan layer lain.
Git enforcement yang matang bukan satu hook besar. Ia adalah sistem berlapis:
client hooks -> feedback cepat
server hooks -> ref/object invariant
CI -> build/test/security validation
branch protection -> review/status/merge gate
release gates -> artifact/provenance/deployment decision
audit logs -> evidence and accountability
Server-side hook yang baik membuat repository lebih aman tanpa menjadikan push sebagai bottleneck misterius.
References
- Git Documentation:
githooks— https://git-scm.com/docs/githooks - Pro Git: Git Hooks — https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks
- Git Documentation:
git receive-pack— https://git-scm.com/docs/git-receive-pack - Git Documentation:
git merge-base— https://git-scm.com/docs/git-merge-base - Git Documentation:
git rev-list— https://git-scm.com/docs/git-rev-list - Git Documentation:
git diff-tree— https://git-scm.com/docs/git-diff-tree - Git Documentation:
git tag— https://git-scm.com/docs/git-tag
You just completed lesson 106 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.