Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXV · HooksHooks

Client-side hooks — where they live and how to enable them

Intermediate⏱ ~20 mingit

What you'll learn

  • Locate the client-side hooks directory and identify the .sample files Git ships by default
  • Enable a client-side hook by removing the .sample suffix and marking it executable
  • List the client-side hooks and the lifecycle point each fires at (pre-commit, commit-msg, pre-push, etc.)
  • Configure core.hooksPath to share a hook directory across multiple clones or teams

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

Client-side hooks are the hooks that run on the developer’s machine, in the local clone. They fire before and after every commit, push, merge, and checkout, and they are the developer’s first line of defence against malformed commits and unstaged secrets. This lesson covers where they live, how to enable them, the full list, and how to share a hook set across a team without checking it into the ignored .git/hooks/ directory.

Where client-side hooks live

Every Git repository has a .git/hooks/ directory. The directory is created by git init and populated with a default set of files, each named after a lifecycle event and each suffixed with .sample. The .sample suffix is the key detail: Git will only invoke a hook whose file is named exactly for the event and whose executable bit is set. A file named pre-commit.sample is not a hook; it is a sample of a hook.

ls .git/hooks/
# applypatch-msg.sample
# commit-msg.sample
# fsmonitor-watchman.sample
# post-update.sample
# pre-applypatch.sample
# pre-commit.sample
# pre-merge-commit.sample
# pre-push.sample
# pre-rebase.sample
# prepare-commit-msg.sample
# push-to-checkout.sample
# update.sample

The .sample files are real scripts with real comments. They document what each hook receives on stdin, what environment variables are set, and what exit codes mean. Reading them is the fastest way to learn the contract for each hook.

How to enable a hook

Two operations turn a .sample file into an active hook: remove the .sample suffix and mark the file executable. The executable bit is what Git checks, not the .sample suffix per se; the suffix is a convention that lets Git ship non-executing defaults.

# Enable the pre-commit hook by removing the .sample suffix
rm .git/hooks/pre-commit.sample

# Mark it executable (the file is now active)
chmod +x .git/hooks/pre-commit

# Verify the file is now an active hook
ls -l .git/hooks/pre-commit
# -rwxr-xr-x 1 engineer engineer 478 Aug 21 14:23 pre-commit

After these two operations, every subsequent git commit will invoke .git/hooks/pre-commit and respect its exit code. The same pattern applies to every other hook: remove the .sample suffix, chmod +x the file.

The two operations can be combined in one command:

mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

The mv removes the suffix (the file is now named exactly pre-commit); the chmod +x makes it executable. Either order works, but chmod +x must run before Git will invoke the file.

The full client-side hook list

The hooks Git invokes, in the order they fire around a single git commit followed by git push:

flowchart TB
    C["git commit invoked"] --> PC["pre-commit"]
    PC --> PCM["prepare-commit-msg"]
    PCM --> CM["commit-msg"]
    CM --> CMT["commit object recorded"]
    CMT --> POC["post-commit"]
    POC --> P["git push invoked"]
    P --> PP["pre-push"]
    PP --> XMIT["pack transmitted to remote"]

Each hook has a defined input and a defined effect on exit:

  • pre-commit — fires before the commit object is recorded; reads no stdin; receives no arguments. Exit non-zero aborts the commit. Reads the index (git diff --cached) to inspect the staged content.
  • prepare-commit-msg — fires after the editor is opened with the commit message; receives the path to the message file as $1, the commit type as $2, and the commit OID as $3. Exit non-zero aborts the commit. Used to populate the commit message template.
  • commit-msg — fires after the message is finalised but before the commit object is recorded; receives the path to the message file as $1. Exit non-zero aborts the commit. Used to enforce commit-message conventions (Conventional Commits, signed-off-by trailers).
  • post-commit — fires after the commit object is recorded; receives no arguments. Cannot abort the commit (it has already happened). Used for notifications and logging.
  • pre-push — fires before the push leaves the machine; receives the remote name as $1 and the remote URL as $2; reads the list of refs to be updated on stdin. Exit non-zero aborts the push.
  • pre-rebase — fires before a rebase begins; receives the upstream as $1 and the branch being rebased as $2 (when not the current branch). Exit non-zero aborts the rebase.
  • pre-merge-commit — fires before a merge commit is created (when a merge cannot fast-forward); receives the same arguments as pre-commit. Exit non-zero aborts the merge.
  • post-merge — fires after a merge (or pull) completes; receives a flag $1 indicating whether the merge was a squash. Used to update working-tree state (e.g. regenerate vendor directories).
  • post-checkout — fires after a checkout (or branch switch); receives the previous HEAD as $1, the new HEAD as $2, and a flag $3 indicating whether the checkout was a branch switch (1) or a file checkout (0). Used to update working-tree state.

Sharing hooks with core.hooksPath

The contents of .git/hooks/ are not committed - the directory is inside .git/, which is ignored by Git. A hook installed by hand is therefore a hook that cannot be reproduced by a fresh clone. Teams solve this by storing hook scripts in a tracked directory and pointing Git at it via core.hooksPath:

# Store hooks in a tracked directory
mkdir -p scripts/hooks
cp .git/hooks/pre-commit.sample scripts/hooks/pre-commit
chmod +x scripts/hooks/pre-commit

# Point Git at the tracked directory
git config core.hooksPath scripts/hooks

After this configuration, every hook invocation looks for the script under scripts/hooks/ instead of .git/hooks/. A fresh clone that runs the bootstrap script gets the same hook set as every other developer. The scripts/hooks/ directory is committed and reviewed like any other source file.

# Apply the same hook set globally for every repository on this machine
git config --global core.hooksPath /usr/local/share/git-hooks/team

The global setting applies to every repository on the developer’s machine; the per-repository setting overrides it. The discipline is to commit the per-repository core.hooksPath setting to .git/config via a bootstrap script, and to keep the global setting for shared team hooks that every repository should run.

Production discipline

  1. Never install a hook by hand and expect it to persist. A fresh clone has only the .sample files. Store the active scripts in scripts/hooks/ and bootstrap them.
  2. Make every hook script idempotent and side-effect aware. A hook that re-runs successfully on a no-op commit is safer than one that fails on a re-run.
  3. Test the hook on the same shell the engineer uses. Hooks run in the engineer’s shell, with the engineer’s $PATH. A hook that works in CI but fails on a developer laptop is a hook that will be bypassed.
  4. Prefer --global core.hooksPath for team-wide hooks. Per-repository core.hooksPath is correct for repository- specific hooks (e.g. a Terraform-only linter that should not run in an Ansible repository); team-wide hooks (e.g. a secret scanner) belong in the global path.
  5. Audit hook changes in pull requests. A modified hook is a modified policy. Treat scripts/hooks/* as code.

Cross-course references

  • Git, CI/CD & GitOps - Part II (GitArch) lesson 04 covers the .git/ directory layout and explains why .git/hooks/ is ignored.
  • Git, CI/CD & GitOps - Part II (GitArch) lesson 05 covers the config cascade (--local, --global, --system) that determines where core.hooksPath is read from.
  • Ansible for Production Sysadmins - Part XXXVIII (Review) covers the same core.hooksPath pattern for Ansible repositories, where linting the playbook is also a pre-commit concern.

Quiz

Knowledge check · 4 questions

  1. Q1. A team has a tracked `scripts/hooks/pre-commit` script and wants every clone of the repository to use it instead of `.git/hooks/pre-commit`. Which configuration achieves that?

  2. Q2. A file named `.git/hooks/pre-commit.sample` is invoked by Git as a pre-commit hook whenever `git commit` runs, regardless of its executable bit, because the `.sample` suffix is treated as a tag rather than as part of the filename.

  3. Q3. Name the four client-side hooks that fire around a `git commit` in the order they fire, and describe what each can read and what its exit code does.

  4. Q4. Diagnose why a team that committed a hook to `.git/hooks/pre-commit` finds the hook missing on a fresh clone, and recommend the correct way to ship hooks with a repository.

    A team committed a hook script to `.git/hooks/pre-commit` (marking it executable in the process) and assumes the next clone will pick it up. After a fresh clone, the new developer reports that `git commit` does not invoke the hook. The team cannot reproduce the hook behaviour on the new clone.

Passing score: 75%. Answers are checked in this browser.