What Are Git Hooks?
A Git hook is a script that Git runs automatically at a specific point in your workflow — right before you finish a commit, right after one lands, before a push leaves your machine, and more. Hooks let you automate the checks and chores that are easy to forget by hand: running a linter on staged files, rejecting a badly formatted commit message, or kicking off a test suite before code reaches a shared branch. They turn Git from a passive history tracker into an active participant in your workflow.
Overview: How Git Hooks Work
Every Git repository has a hidden .git directory, and inside it a hooks subdirectory. When you run git init, Git populates .git/hooks with a set of example scripts, each named after the hook it demonstrates but suffixed with .sample — for example pre-commit.sample or commit-msg.sample. Git ignores any file ending in .sample; a hook only becomes active once a file exists with the exact hook name (no extension) and that file is executable.
A hook can be written in any language — POSIX shell, Bash, Python, Node.js, Ruby, anything the operating system can execute. Git doesn’t parse or understand the contents of a hook at all; it simply invokes the file (relying on the file’s executable permission and, on Unix-like systems, its #! shebang line to pick the right interpreter) and looks only at the exit code the file returns.
Client-side vs. server-side hooks
Hooks fall into two groups. Client-side hooks run on your own machine as you commit, merge, check out branches, and push — pre-commit, commit-msg, pre-push, and similar. Server-side hooks (pre-receive, update, post-receive) run on the machine that receives a push, and only exist if you run your own Git server. GitHub.com does not let you install arbitrary server-side hook scripts on a hosted repository — instead, GitHub gives you branch protection rules and GitHub Actions workflows to get the same “reject or react to an incoming push” behavior in the cloud. This lesson focuses on client-side hooks; other lessons in this section cover Actions.
What fires, and when
During an ordinary git commit, hooks fire in this order: pre-commit (before you’re asked for a message), prepare-commit-msg (right after Git drafts a default message), commit-msg (after you’ve written the final message, validating its content), then post-commit (after the commit object already exists). Other operations have their own hooks: pre-rebase fires before a rebase starts, post-checkout after git switch/git checkout, post-merge after a merge completes, and pre-push right before objects are transmitted to a remote.
Exit codes matter. If a hook whose name starts with pre-, or a validating hook like commit-msg, exits with a non-zero status, Git aborts the operation before it does anything destructive — no commit is created, no push is sent. Hooks whose name starts with post- run after the operation has already completed successfully; their exit code is purely informational and can’t undo what already happened.
One detail that surprises almost everyone the first time: .git/hooks is not tracked by Git. It lives inside the .git metadata directory, which is explicitly excluded from the snapshots Git commits. That means hooks you write never travel with git clone, git push, or git pull — each collaborator has to set them up on their own machine, unless you deliberately share them, which is what core.hooksPath is for (see the example below).
Syntax
There’s no dedicated Git subcommand for creating a hook — a hook is just an executable file with the right name in the right place. The two commands you’ll use constantly are:
chmod +x ".git/hooks/<hook-name>"
git config core.hooksPath "<directory>"
chmod +x marks a hook file as executable — Git silently skips a hook file that exists but isn’t executable. git config core.hooksPath tells Git to look for hooks somewhere other than .git/hooks, which is how teams share version-controlled hooks (see Example 4 below).
The most commonly used hook names:
| Hook | Fires | Typical use | Can abort? |
|---|---|---|---|
pre-commit |
Before the commit message is composed | Lint/format staged files, block debug statements | Yes |
prepare-commit-msg |
After Git drafts a default message, before the editor opens | Pre-fill a message template | Rarely used to abort |
commit-msg |
After you finish writing the message | Enforce a message format (e.g. Conventional Commits) | Yes |
post-commit |
After the commit object is created | Notifications, local logging | No |
pre-rebase |
Before a rebase starts | Block rebasing a branch that’s already been published | Yes |
post-checkout |
After git switch/git checkout |
Warn about large files, refresh environment | No |
post-merge |
After a merge completes | Reinstall dependencies if a lockfile changed | No |
pre-push |
Before objects are sent to the remote | Run the test suite before code leaves your machine | Yes |
Examples
Example 1: Enabling a sample hook
Every fresh repository already has example hooks sitting in .git/hooks, disabled by their .sample suffix. Enabling one is just a rename plus a permission change:
cd .git/hooks
ls
mv pre-commit.sample pre-commit
chmod +x pre-commit
ls -l pre-commit
Output:
applypatch-msg.sample pre-commit.sample pre-push.sample
commit-msg.sample pre-merge-commit.sample pre-rebase.sample
fsmonitor-watchman.sample ...
-rwxr-xr-x 1 alex staff 478 Aug 3 10:02 pre-commit
The first ls shows the full set of disabled samples Git ships with. After the rename and chmod +x, pre-commit (no .sample) is now a real, active hook — Git will run it automatically before every future commit in this repository.
Example 2: A pre-commit hook that blocks debug statements
The sample hooks are mostly commented-out boilerplate. In practice you replace them with your own script. Here’s a pre-commit hook that scans staged .js files for stray console.log or debugger statements:
#!/bin/sh
# Block commits that add console.log or debugger statements in staged JS files
staged_js=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.js$')
if [ -n "$staged_js" ] && echo "$staged_js" | xargs grep -nE 'console\.log|debugger'
then
echo "pre-commit: found console.log/debugger in staged JS files above. Remove them or use 'git commit --no-verify' to bypass."
exit 1
fi
exit 0
Save that as .git/hooks/pre-commit (and make sure it’s executable), then try committing a file that still has a stray console.log:
git add app.js
git commit -m "feat: add login button"
Output:
app.js:14: console.log(user);
pre-commit: found console.log/debugger in staged JS files above. Remove them or use 'git commit --no-verify' to bypass.
Git never even opened a commit-message editor — the hook exited with status 1, so Git aborted before creating anything. The staged changes are untouched; once the developer removes the console.log and re-stages the file, git commit will succeed normally.
Example 3: A commit-msg hook that enforces Conventional Commits
The commit-msg hook receives one argument: the path to a temporary file holding the message the author just wrote. It runs after the message exists but before the commit object is created, so it’s the right place to validate format:
mv .git/hooks/commit-msg.sample .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg
#!/bin/sh
# Enforce Conventional Commits style messages: type(scope): subject
commit_msg_file="$1"
first_line=$(head -n1 "$commit_msg_file")
if ! echo "$first_line" | grep -qE '^(feat|fix|docs|style|refactor|test|chore)(\([a-z0-9_-]+\))?: .+'; then
echo "commit-msg: '$first_line' doesn't follow Conventional Commits style (e.g. 'feat: add login page')."
exit 1
fi
exit 0
Replace the sample content with the script above, then test both a bad and a good message:
git commit -m "added new button"
git commit -m "feat: add submit button to signup form"
Output:
commit-msg: 'added new button' doesn't follow Conventional Commits style (e.g. 'feat: add login page').
[main 3f9a1c2] feat: add submit button to signup form
1 file changed, 4 insertions(+)
The first message is rejected outright — no commit is created, and the index is left exactly as it was. The second message matches the required pattern, so the hook exits 0 and Git proceeds to create the commit.
Example 4: Sharing hooks with your team via core.hooksPath
Because .git/hooks isn’t tracked, the hooks from the previous two examples only exist on one machine. To share them, move them into a directory inside your tracked working tree and tell Git to look there instead:
mkdir .githooks
mv .git/hooks/pre-commit .githooks/pre-commit
mv .git/hooks/commit-msg .githooks/commit-msg
git config core.hooksPath .githooks
git add .githooks
git commit -m "chore: version-control shared git hooks"
Output:
[main 7c2e4aa] chore: version-control shared git hooks
2 files changed, 20 insertions(+)
create mode 100755 .githooks/commit-msg
create mode 100755 .githooks/pre-commit
git config core.hooksPath .githooks only changes a setting in your own local, untracked .git/config — running that command isn’t itself something Git pushes to teammates. Everyone who clones the repo does get the hook scripts automatically, since .githooks is now a normal tracked directory, but each person still has to run git config core.hooksPath .githooks once themselves before Git actually starts invoking those scripts.
How It Works Step by Step
To see why hook order and exit codes matter, walk through what actually happens inside Git when you run git commit -m "..." in a repository with both hooks from Examples 2 and 3 installed:
- Git runs
pre-commitwith no arguments, from the repository root. The hook can inspectgit diff --cached(the staged snapshot) to decide whether to allow the commit. A non-zero exit stops everything here — nothing is read from or written to the object database yet. - Git composes a default message (from
-m, a merge template, etc.) into a temporary file, then runsprepare-commit-msg, passing that file’s path plus a “source” (message, template, merge, squash) so the hook can programmatically edit the draft. - If you didn’t pass
-m, your editor opens on that temp file; once you save and close it (or immediately, if you did pass-m), Git runscommit-msgwith the path to the finalized message file as its only argument. A non-zero exit here aborts the commit too — the staged changes remain staged, ready to be fixed and re-committed. - Only now does Git actually build objects: it writes a blob for each changed file’s content, content-addressed by the SHA-1 hash of
"blob <size>\0<content>"; it writes tree objects describing the directory structure, each entry pointing at a blob or another tree by hash; and it writes a commit object that points at the root tree, at the parent commit(s), and carries the author, committer, timestamp, and the finalized message. - Git then moves the current branch pointer — really just the 40-character SHA-1 stored in a file like
.git/refs/heads/main— to the new commit’s hash. SinceHEADnormally points at the branch rather than directly at a commit,HEADnow resolves to the new commit automatically. - Finally, Git runs
post-commitwith no arguments. The commit already exists at this point; the hook’s exit code is informational only and can’t roll anything back.
The same “pre-* can veto, post-* can only react” pattern applies to every other hook pair — pre-push can stop a push before any objects leave your machine, but nothing can stop a post-merge hook from running after a merge that already completed.
Common Mistakes
Forgetting to make the hook executable. You write a perfect pre-commit script, save it, and commits go through as if it doesn’t exist. Git silently ignores a hook file that lacks the executable bit — it doesn’t warn you. The fix is simply:
chmod +x .git/hooks/pre-commit
Assuming hooks travel with the repo. A teammate clones your repository and is baffled that the commit-message check doesn’t run for them. .git/hooks lives outside the tracked working tree, so git clone never copies your hooks. The fix is to publish hooks through core.hooksPath as shown in Example 4, and document the one-time setup command in your README.
A hook that swallows its own failure. A common bug is ending a script with something like npm test || true — the || true forces the overall exit code to 0 no matter what happened, so the hook always “passes” and silently stops protecting anything. Let the real exit code propagate, and only add fallback logic when you genuinely mean “don’t block on this specific step.”
Treating a client-side hook as your only safety net. Anyone can skip hooks entirely for one commit:
git commit --no-verify -m "fix: emergency hotfix, skipping hooks intentionally"
And anyone who hasn’t installed the hook (or is on a machine where core.hooksPath was never configured) skips it by default. Client-side hooks are a fast-feedback convenience, not a security boundary — mirror the same checks in CI (for example, a GitHub Actions workflow) so a bypassed or missing hook can’t let broken code merge.
Best Practices
- Keep
pre-commithooks fast and scoped to staged files only — a hook that runs your entire test suite on every commit will get skipped or disabled out of frustration. - Share hooks with your team by committing them to a tracked directory (e.g.
.githooks/) and pointing Git at it withgit config core.hooksPath .githooks, or by adopting a hook-management tool that installs hooks automatically for every contributor. - Give every hook failure a clear, actionable message that tells the author exactly what to fix and how to bypass it in a genuine emergency.
- Reserve heavier checks (full test suites, integration tests) for
pre-pushrather thanpre-commit, since pushes happen far less often than commits. - Treat hooks as a local convenience layer, not enforcement — keep the real gate in CI and in GitHub branch protection rules with required status checks.
- Use
--no-verifysparingly and deliberately, never as a habit for working around a hook you find annoying.
Practice Exercises
- Enable the built-in
pre-commit.samplehook, read through its default logic, then modify it so it rejects any staged file larger than 5 MB. Hint: combinegit diff --cached --name-onlywith a check of each file’s size on disk. - Write a
commit-msghook that rejects any commit message not starting with one offeat,fix,docs, orchorefollowed by a colon. Test it with one message that should fail and one that should pass. - Move your hooks into a tracked
.githooksdirectory and rungit config core.hooksPath .githooks. Expected end state: a fresh clone of the repo has the hook scripts on disk inside the tracked working tree, but they only take effect for a collaborator after that person also runs the samegit config core.hooksPath .githookscommand locally.
Summary
- A Git hook is an executable script in
.git/hooks(or wherevercore.hooksPathpoints) that Git runs automatically at points like committing, merging, checking out, and pushing. - Enable a sample hook by removing its
.samplesuffix and runningchmod +x— Git matches hooks by exact filename, not extension. - Hooks named
pre-*and validating hooks likecommit-msgcan abort the operation with a non-zero exit code; hooks namedpost-*run after the fact and are informational only. .git/hooksis not tracked by Git, so hooks don’t travel withgit clone— usecore.hooksPathpointed at a tracked directory to share them with your team.- Hooks can always be bypassed locally with
--no-verify, so treat them as a fast local convenience and keep your real enforcement in CI and GitHub branch protection.
