Git, CI/CD & GitOpsXXV · HooksHooks
Client-side hooks — where they live and how to enable them
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
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
$1and 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
$1and 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
$1indicating 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$3indicating 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
- Never install a hook by hand and expect it to persist. A
fresh clone has only the
.samplefiles. Store the active scripts inscripts/hooks/and bootstrap them. - 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.
- 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. - Prefer
--global core.hooksPathfor team-wide hooks. Per-repositorycore.hooksPathis 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. - 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 wherecore.hooksPathis read from. - Ansible for Production Sysadmins - Part XXXVIII (Review)
covers the same
core.hooksPathpattern for Ansible repositories, where linting the playbook is also a pre-commit concern.
Quiz
Knowledge check · 4 questions
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?
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.
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.
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.