Final StretchOrdered learning track

Client-Side Hooks for Feedback

Learn Git In Action - Part 105

Designing client-side Git hooks as fast local feedback loops without mistaking them for enforceable repository policy.

13 min read2445 words
PrevNext
Lesson 105126 lesson track104–126 Final Stretch
#git#version-control#hooks#developer-experience+2 more

Client-Side Hooks for Feedback

Client-side hooks adalah script lokal yang dijalankan Git pada titik tertentu di lifecycle command seperti commit, merge, rebase, dan push.

Hook lokal sangat berguna untuk satu hal: memberi feedback cepat sebelum kesalahan masuk ke review, CI, atau remote repository.

Tapi hook lokal bukan enforcement boundary yang kuat.

Kenapa?

Karena hook lokal:

  • tidak otomatis ikut ter-clone sebagai executable policy;
  • bisa tidak terpasang di mesin developer;
  • bisa berubah antar mesin;
  • bisa di-bypass dengan opsi seperti --no-verify untuk beberapa hook;
  • bisa gagal karena environment lokal berbeda;
  • tidak berjalan untuk perubahan yang dibuat langsung di remote atau oleh bot tertentu;
  • tidak bisa dipercaya sebagai satu-satunya kontrol compliance/security.

Jadi mental model yang benar:

Client-side hooks adalah local feedback system. Enforcement tetap harus berada di server-side hooks, CI, branch protection, rulesets, merge queue, atau release gates.

Jika hook lokal gagal, developer mendapat sinyal cepat. Jika hook lokal tidak jalan, repository tetap harus aman karena invariant penting ditegakkan di tempat lain.


1. Posisi Client-Side Hooks dalam Workflow

Git hooks adalah program yang ditempatkan di hooks directory. Secara default lokasinya adalah:

.git/hooks

Lokasi ini bisa diganti dengan config:

git config core.hooksPath .githooks

Ketika Git mencapai event tertentu, Git mencari executable file dengan nama hook yang sesuai. Misalnya:

pre-commit
prepare-commit-msg
commit-msg
post-commit
pre-rebase
post-checkout
post-merge
pre-push

Diagram lifecycle umum:

Perhatikan batasnya: pre-push masih terjadi di mesin lokal sebelum transfer final ke remote. Server belum tentu menerima perubahan. Setelah remote menerima request, boundary berubah menjadi server-side policy.


2. Hook Lokal Bukan Bukti Kepatuhan

Kesalahan desain paling umum adalah menyatakan:

“Kita aman karena ada pre-commit hook.”

Itu lemah.

Pre-commit hook mungkin tidak terpasang. Developer mungkin memakai GUI yang tidak menjalankan setup internal. Hook mungkin tidak executable di filesystem tertentu. Hook mungkin di-bypass. Developer mungkin push dari environment lain. Bot mungkin membuat commit langsung. Admin mungkin merge dari UI.

Client-side hook boleh mencegah kesalahan umum, tetapi tidak boleh menjadi satu-satunya penjaga invariant penting seperti:

  • secret tidak boleh masuk history;
  • release tag tidak boleh digeser;
  • commit ke main harus lewat review;
  • migration harus punya rollback note;
  • artifact release harus punya provenance;
  • protected path harus direview owner;
  • dependency critical harus pin ke commit/digest;
  • generated file harus konsisten dengan source.

Invariant seperti itu harus ditegakkan ulang di CI/server.

Rule praktis:

Anything important enough to block production must be checked outside the developer machine.


3. Hook yang Paling Sering Dipakai

pre-commit

Berjalan sebelum Git membuka commit message editor.

Cocok untuk:

  • formatting cepat;
  • lint ringan;
  • staged-only validation;
  • deteksi debug statement;
  • deteksi file besar;
  • deteksi secret pattern lokal;
  • memastikan generated file tidak stale;
  • mencegah accidental commit file temporary.

Tidak cocok untuk:

  • test suite lambat;
  • integration test berat;
  • policy yang butuh remote state;
  • validasi approval;
  • validasi branch protection;
  • pemeriksaan release artifact.

Contoh sederhana:

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

if git diff --cached --name-only | grep -E '(^|/)\.env$'; then
  echo "Refusing to commit .env file"
  exit 1
fi

if git diff --cached --check; then
  exit 0
else
  echo "Whitespace errors detected"
  exit 1
fi

Catatan: git diff --cached membaca staged snapshot, bukan semua working tree. Itu penting karena commit akan dibuat dari index.


prepare-commit-msg

Berjalan setelah default commit message disiapkan, sebelum editor commit message muncul.

Cocok untuk:

  • memasukkan issue id dari branch name;
  • memasukkan template metadata;
  • memberi hint tanpa memaksa developer menulis format tertentu;
  • mengisi context untuk merge/squash/commit biasa secara berbeda.

Contoh:

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

msg_file="$1"
commit_source="${2:-}"

# Jangan ganggu merge commit message otomatis.
if [[ "$commit_source" == "merge" ]]; then
  exit 0
fi

branch=$(git symbolic-ref --short HEAD 2>/dev/null || true)
issue=$(printf '%s' "$branch" | grep -Eo '[A-Z]+-[0-9]+' | head -n1 || true)

if [[ -n "$issue" ]] && ! grep -q "$issue" "$msg_file"; then
  sed -i.bak "1s/^/[$issue] /" "$msg_file"
  rm -f "$msg_file.bak"
fi

Risiko: terlalu agresif mengubah message bisa merusak commit message merge, revert, squash, atau generated commit.


commit-msg

Berjalan setelah commit message selesai, sebelum commit object dibuat.

Cocok untuk:

  • format commit message;
  • required trailer;
  • Signed-off-by policy lokal;
  • issue id convention;
  • Conventional Commits sebagai feedback;
  • reminder untuk migration/security note.

Contoh validasi minimal:

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

msg_file="$1"
subject=$(head -n1 "$msg_file")

if [[ ${#subject} -gt 72 ]]; then
  echo "Commit subject is too long: ${#subject} chars. Keep it <= 72."
  exit 1
fi

if ! grep -qE '^(feat|fix|refactor|test|docs|chore|build|ci)(\(.+\))?: .+' <<< "$subject"; then
  echo "Commit subject should follow: type(scope): summary"
  exit 1
fi

Tapi hati-hati: jika format terlalu sempit, developer akan menulis message palsu yang lolos regex tapi buruk sebagai audit trail.

Lebih baik hook memberi feedback kualitas minimum, bukan mengganti judgment engineering.


post-commit

Berjalan setelah commit berhasil dibuat.

Cocok untuk:

  • notifikasi lokal;
  • update lightweight metadata;
  • membuka checklist;
  • mengingatkan jika branch belum punya upstream;
  • menjalankan script non-blocking.

Tidak cocok untuk blocking policy, karena commit sudah dibuat.


pre-rebase

Berjalan sebelum rebase.

Cocok untuk:

  • mencegah rebase branch publik tertentu;
  • mencegah rewrite branch release;
  • memperingatkan jika branch punya upstream shared;
  • memastikan working tree bersih sebelum rewrite.

Contoh:

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

branch=$(git symbolic-ref --short HEAD 2>/dev/null || true)

case "$branch" in
  main|master|release/*|hotfix/*)
    echo "Refusing to rebase protected/local policy branch: $branch"
    exit 1
    ;;
esac

Ini hanya local guardrail. Protected branch dan server-side hook tetap diperlukan.


post-checkout dan post-merge

Cocok untuk sinkronisasi environment setelah working tree berubah.

Contoh penggunaan:

  • install dependency setelah lockfile berubah;
  • refresh generated code;
  • regenerate IDE metadata;
  • update sparse profile warning;
  • refresh submodule.

Contoh ringan:

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

if git diff --name-only HEAD@{1} HEAD -- 2>/dev/null | grep -q '^package-lock.json$'; then
  echo "package-lock.json changed. Run npm ci."
fi

Jangan menjalankan dependency install besar secara otomatis tanpa izin. Itu mengganggu flow dan berisiko menjalankan script dependency yang tidak di-review.


pre-push

Berjalan sebelum push benar-benar dikirim ke remote.

Cocok untuk:

  • mencegah push branch lokal yang salah remote;
  • menjalankan test subset sebelum push;
  • validasi branch naming;
  • mencegah push tag release tanpa signed tag;
  • mencegah push commit dengan marker WIP;
  • memberi warning jika branch diverged.

Contoh input pre-push dibaca dari stdin. Git mengirim baris berisi local ref, local sha, remote ref, remote sha.

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

remote_name="$1"
remote_url="$2"

while read -r local_ref local_sha remote_ref remote_sha; do
  if [[ "$remote_ref" == "refs/heads/main" ]]; then
    echo "Direct push to main is blocked by local policy. Use PR."
    exit 1
  fi

  if git log --format=%s "$remote_sha..$local_sha" 2>/dev/null | grep -q '^WIP'; then
    echo "Refusing to push WIP commit."
    exit 1
  fi
done

Tetap perlu server-side protection, karena local hook bisa tidak jalan.


4. Staged-Only Validation

Pre-commit hook harus memvalidasi snapshot yang akan menjadi commit, bukan working tree sembarang.

Kesalahan umum:

npm test
eslint .

Masalahnya: working tree bisa mengandung perubahan unstaged yang tidak akan masuk commit. Hook bisa lulus karena perubahan unstaged membantu test, padahal commit actual akan gagal.

Lebih benar:

  • validasi file staged;
  • atau stash unstaged changes sementara;
  • atau jalankan test dari temporary tree;
  • atau batasi hook hanya untuk check yang tidak bergantung pada unstaged state.

Command penting:

git diff --cached --name-only

git diff --cached --check

git diff --cached --name-only --diff-filter=ACMR

Untuk path yang aman terhadap spasi/newline, gunakan NUL separator:

git diff --cached --name-only -z | xargs -0 ./scripts/check-file

Mental model:


5. Hook Performance Budget

Hook yang lambat akan dimatikan, di-bypass, atau dibenci.

Budget praktis:

HookTarget latencyCocok
pre-commit0.5-5 detikstaged lint, format check, secret pattern ringan
commit-msg< 1 detikmessage validation
pre-push5-60 detiktest subset, build quick check
CImenitfull test, integration, security scan

Jangan memindahkan full CI ke pre-commit. Itu membuat developer takut commit kecil. Commit harus murah.

Prinsip:

Pre-commit menjaga commit loop cepat. Pre-push menjaga remote tidak terlalu kotor. CI menjaga shared truth.


6. Designing Hooks as Product UX

Hook adalah bagian dari developer experience. Treat it like product.

Hook yang buruk:

Error: invalid commit.

Hook yang baik:

Commit message rejected.

Expected:
  fix(auth): reject expired session token

Found:
  update stuff

Why this matters:
  Release notes and incident forensics use commit subject as first-level signal.

Bypass:
  git commit --no-verify is allowed only for emergency local recovery.
  CI will still enforce required release metadata.

Hook harus menjawab:

  • apa yang salah;
  • kenapa aturan ada;
  • bagaimana memperbaiki;
  • apakah bypass ada;
  • apakah check juga berjalan di CI/server.

7. Distribution Strategy

Karena Git tidak otomatis version-control .git/hooks, hook perlu didistribusikan.

Pilihan umum:

Option A: core.hooksPath ke directory versioned

git config core.hooksPath .githooks

Repository menyimpan:

.githooks/
  pre-commit
  commit-msg
  pre-push

Kelebihan:

  • hook versioned bersama source;
  • reviewable;
  • mudah diperbarui.

Kekurangan:

  • developer tetap harus menjalankan bootstrap;
  • config lokal bisa override;
  • perlu executable bit;
  • platform Windows perlu perhatian shebang/shell.

Option B: hook manager/framework

Contoh kategori:

  • language-specific hook manager;
  • pre-commit framework;
  • package manager lifecycle script;
  • internal CLI.

Kelebihan:

  • install dependency hook lebih rapi;
  • multi-language support;
  • caching;
  • config declarative.

Kekurangan:

  • dependency tambahan;
  • bootstrap complexity;
  • version mismatch;
  • bisa bertabrakan dengan core.hooksPath organisasi.

Option C: global hooks

git config --global core.hooksPath ~/.config/git/hooks

Cocok untuk personal guardrail, bukan team policy. Jangan paksa semua developer memakai global hooks yang tidak versioned dengan repo.


8. Bootstrap Hook yang Aman

Buat script bootstrap yang idempotent:

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

repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"

git config core.hooksPath .githooks

echo "Configured core.hooksPath=.githooks"

for hook in .githooks/*; do
  if [[ -f "$hook" ]]; then
    chmod +x "$hook"
  fi
done

echo "Hooks installed. Run ./scripts/git-doctor to verify."

Lalu sediakan doctor:

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

expected=".githooks"
actual=$(git config --get core.hooksPath || true)

if [[ "$actual" != "$expected" ]]; then
  echo "core.hooksPath mismatch: expected $expected, got ${actual:-<unset>}"
  exit 1
fi

for required in pre-commit commit-msg pre-push; do
  if [[ ! -x "$expected/$required" ]]; then
    echo "Missing or non-executable hook: $expected/$required"
    exit 1
  fi
done

echo "Git hook setup looks good."

9. Hook Security Model

Hooks execute code on developer machines.

That is powerful and dangerous.

Rules:

  • keep hooks small and reviewable;
  • do not curl-pipe arbitrary remote script;
  • pin hook tool versions;
  • avoid running dependency lifecycle scripts automatically;
  • prefer explicit ./scripts/bootstrap over hidden auto-install;
  • do not collect personal data from developer machines;
  • document what hook runs and why;
  • avoid network calls in pre-commit;
  • avoid secrets in hook config;
  • treat hook updates like code changes.

Bad pattern:

curl https://internal.example.com/install.sh | bash

Better pattern:

./scripts/bootstrap-git-hooks

with script source committed and reviewed.


10. Pre-commit Secret Scanning

Local secret scanning is useful, but not sufficient.

Minimal local hook:

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

patterns='(AKIA[0-9A-Z]{16}|-----BEGIN PRIVATE KEY-----|ghp_[A-Za-z0-9_]{30,})'

if git diff --cached --text | grep -E "$patterns" >/dev/null; then
  echo "Potential secret detected in staged diff."
  echo "Do not commit secrets. Rotate if this was a real credential."
  exit 1
fi

But production-grade secret scanning needs:

  • entropy detection;
  • provider-specific validators;
  • allowlist/denylist;
  • binary scan strategy;
  • CI/server scan;
  • post-commit repository scan;
  • incident response process.

Local hook can prevent obvious mistakes. It cannot prove secrets never enter history.


11. Generated File Consistency

Generated files are common sources of noisy PRs.

Example policy:

  • if API schema changes, generated client must be updated;
  • if protobuf changes, generated code must match;
  • if lockfile changes, package manifest must correspond;
  • if migration changes, generated snapshot must match.

Pre-commit hook pattern:

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

changed=$(git diff --cached --name-only)

if grep -q '^proto/' <<< "$changed"; then
  ./scripts/generate-proto

  if ! git diff --quiet -- generated/; then
    echo "Generated files are stale. Run ./scripts/generate-proto and stage results."
    exit 1
  fi
fi

Caveat: this mutates working tree during commit. It may surprise developers.

Alternative: only detect stale generated files and print command to run.


12. Branch-Aware Hooks

Branch-aware hooks can prevent obvious wrong-target mistakes.

Example pre-push block direct push to protected branches:

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

while read -r local_ref local_sha remote_ref remote_sha; do
  case "$remote_ref" in
    refs/heads/main|refs/heads/master|refs/heads/release/*)
      echo "Direct push to $remote_ref is blocked locally. Open a PR."
      exit 1
      ;;
  esac
done

But branch-aware hooks often need exceptions:

  • release manager;
  • automation bot;
  • emergency break-glass;
  • mirror sync;
  • migration window.

Do not encode complex exception logic only in local hooks. Put real exception handling in server-side controls and audit logs.


13. Commit Message Hook with Domain Trailers

For regulated or operational systems, commit trailers can encode useful metadata.

Example:

Fixes: CASE-1234
Risk: medium
Migration: no
Rollback: revert-only

A commit-msg hook can enforce basic shape:

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

msg_file="$1"
body=$(cat "$msg_file")

if grep -qE '^Risk: (high|critical)$' "$msg_file"; then
  if ! grep -qE '^Rollback: .+' "$msg_file"; then
    echo "High/critical risk commits require Rollback trailer."
    exit 1
  fi
fi

But do not let trailers become bureaucratic noise. If everyone writes Risk: low to pass hook, the system has failed.

Better model:

  • hook enforces presence only for sensitive paths;
  • CI validates sensitive path ownership;
  • PR template captures reviewer acknowledgment;
  • release evidence records final decision.

14. Hook Bypass Policy

Several hooks can be bypassed by --no-verify.

Examples:

git commit --no-verify

git push --no-verify

This is not inherently bad. Bypass is useful for:

  • recovering from broken hook;
  • committing WIP during incident debugging;
  • operating offline;
  • temporary toolchain failure;
  • fixing the hook itself.

But policy should be explicit:

Local hook bypass is allowed for local progress.
Bypass does not bypass CI, server-side hooks, protected branches, release gates, or review requirements.

If bypass is never allowed, do not rely on client hook. Enforce server-side.


15. Hooks and GUI Tools

Git GUI, IDE, and desktop clients often call Git commands under the hood, but behavior can differ:

  • shell environment may be minimal;
  • PATH may differ;
  • interactive prompts may fail;
  • Windows shell compatibility may break;
  • hooks may not show output clearly;
  • GUI may use embedded Git version.

Hook design rule:

Hooks must be non-interactive, deterministic, and explicit about missing dependencies.

Bad:

read -p "Run formatter?" answer

Good:

if ! command -v node >/dev/null; then
  echo "node is required for pre-commit lint. Run ./scripts/bootstrap."
  exit 1
fi

16. Hooks in Monorepo

Monorepo hooks should be path-aware and dependency-aware.

Bad:

npm test

Better:

changed=$(git diff --cached --name-only)

if grep -q '^services/billing/' <<< "$changed"; then
  ./tools/test-affected billing
fi

if grep -q '^libs/auth/' <<< "$changed"; then
  ./tools/test-affected auth --dependents
fi

But affected test logic must be shared with CI. Otherwise local and CI disagree.

A good architecture:


17. Hooks and Partial/Sparse Clone

In sparse checkout or partial clone, not all files/objects are present locally.

Hook implications:

  • hook must not assume full working tree exists;
  • path scan should operate on staged diff, not recursive filesystem scan;
  • hook may trigger lazy fetch if it reads missing blobs;
  • generated file validation may fail if generator inputs are outside sparse cone;
  • LFS files may appear as pointer if not fetched.

Safer command patterns:

git diff --cached --name-only

git show :path/to/file

git cat-file -e HEAD:path/to/file

Avoid:

find . -type f

for repository-wide policy unless you explicitly handle sparse checkout.


18. Hook Observability

Hook failure should be easy to debug.

Add a debug mode:

if [[ "${GIT_HOOK_DEBUG:-}" == "1" ]]; then
  set -x
fi

Print useful context:

echo "Hook: pre-commit"
echo "Repo: $(git rev-parse --show-toplevel)"
echo "Branch: $(git symbolic-ref --short HEAD 2>/dev/null || echo detached)"
echo "Git: $(git --version)"

But avoid leaking secrets in logs.


19. Anti-Patterns

Anti-pattern 1: Full test suite on every commit

Developer stops committing frequently. Atomic commits die.

Move heavy checks to pre-push or CI.

Anti-pattern 2: Hook silently mutates staged files

Developer commits code different from what they reviewed.

Prefer explicit failure with command to fix, or print clear mutation notice.

Anti-pattern 3: Local hook as security boundary

Bypass and environment variance make this unsafe.

Duplicate important checks on server/CI.

Anti-pattern 4: Hook depends on unpinned global tool

Different machines produce different outcomes.

Pin versions or use repository-local toolchain.

Anti-pattern 5: Hook has network dependency

Commit becomes impossible when VPN/offline/registry down.

Pre-commit should mostly be local.

Anti-pattern 6: Hook output is hostile

Bad UX causes bypass culture.

Make failure actionable.


20. Production-Grade Hook Layout

A maintainable layout:

.githooks/
  pre-commit
  commit-msg
  pre-push
scripts/
  git-hooks/
    lib.sh
    check-staged-secrets.sh
    check-commit-message.sh
    check-generated.sh
    check-branch-policy.sh
  bootstrap-git-hooks
  git-doctor

Thin hook wrapper:

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

repo_root=$(git rev-parse --show-toplevel)
exec "$repo_root/scripts/git-hooks/check-staged-secrets.sh"

Why thin wrapper?

  • hook file stays small;
  • logic testable directly;
  • CI can run same script;
  • easier review;
  • shared library possible.

21. Example: Complete Lightweight Hook Set

.githooks/pre-commit

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

root=$(git rev-parse --show-toplevel)
cd "$root"

./scripts/git-hooks/check-whitespace.sh
./scripts/git-hooks/check-staged-secrets.sh
./scripts/git-hooks/check-large-files.sh
./scripts/git-hooks/check-generated-hints.sh

.githooks/commit-msg

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

root=$(git rev-parse --show-toplevel)
"$root/scripts/git-hooks/check-commit-message.sh" "$1"

.githooks/pre-push

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

root=$(git rev-parse --show-toplevel)
"$root/scripts/git-hooks/check-pre-push-policy.sh" "$@"

22. Testing Hooks

Hooks are code. Test them.

Minimum:

./scripts/git-hooks/check-commit-message.sh fixtures/commit-messages/good.txt
! ./scripts/git-hooks/check-commit-message.sh fixtures/commit-messages/bad.txt

For integration test, create temporary repo:

tmp=$(mktemp -d)
cd "$tmp"
git init
git config user.name Test
git config user.email test@example.com
mkdir .githooks
cp /path/to/pre-commit .githooks/pre-commit
git config core.hooksPath .githooks
chmod +x .githooks/pre-commit

echo "secret=AKIA0000000000000000" > config.txt
git add config.txt
! git commit -m "test: should fail"

Do this in CI so hook logic does not rot.


23. Decision Framework

Use client-side hooks when:

  • feedback can be local;
  • check is cheap;
  • failure message can be actionable;
  • bypass is acceptable;
  • CI/server also covers critical cases;
  • hook helps developer, not just management.

Do not use client-side hooks when:

  • check needs authoritative remote state;
  • check must be unbypassable;
  • check is slow and blocks small commits;
  • check requires secrets or privileged access;
  • check has high false positive rate;
  • check mutates source in surprising ways.

24. Operational Checklist

Before rolling out hooks to a team:

  • All hooks are versioned.
  • Bootstrap is idempotent.
  • git doctor verifies installation.
  • Hook scripts are executable.
  • Hook output explains fix steps.
  • Heavy checks run in CI instead.
  • Critical policies are duplicated in CI/server.
  • Bypass policy is documented.
  • Hook works in CLI and common GUI/IDE environment.
  • Hook handles pathnames safely.
  • Hook handles sparse checkout or explicitly says unsupported.
  • Hook has tests.
  • Hook update process is reviewed like code.

25. Mental Model Akhir

Client-side hooks bukan polisi repository. Mereka adalah instrument panel di kokpit developer.

Instrument panel yang baik:

  • memberi sinyal cepat;
  • tidak mengambil alih kendali secara membabi buta;
  • tidak memblokir pekerjaan kecil dengan check berat;
  • menjelaskan masalah dan tindakan;
  • tetap punya backup control di sistem yang lebih authoritative.

Jika tim mendesain hook seperti enforcement tunggal, workflow akan rapuh.

Jika tim mendesain hook sebagai fast feedback layer, developer akan lebih cepat membuat commit bersih, PR lebih reviewable, CI lebih sedikit menangkap hal trivial, dan insiden akibat kesalahan lokal akan turun.


References

Lesson Recap

You just completed lesson 105 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.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.