Client-Side Hooks (pre-commit, commit-msg)
Every Git repository has a hidden set of trigger points where Git will run a script for you automatically, right before or after key actions like committing. These are called hooks, and the two you will reach for most often are pre-commit, which runs before a commit is created, and commit-msg, which runs after you write a commit message but before the commit is finalized. Used well, they turn easy-to-forget rules (“don’t commit debug statements”, “write your commit messages like this”) into things Git enforces for you automatically, without needing a separate CI run.
Overview: how Git hooks actually work
Hooks are not a special Git feature stored in the object database — they are just executable scripts sitting in a directory. When you run git init (or clone a repo), Git creates a .git/hooks/ directory and fills it with sample files like pre-commit.sample and commit-msg.sample. Git ignores these .sample files. To activate a hook, you create a file in that directory with the exact hook name (no extension, e.g. .git/hooks/pre-commit) and make it executable. That’s the entire mechanism: Git looks in .git/hooks/ for a file matching the event name, and if it exists and is executable, Git runs it and inspects its exit code.
This matters because .git/hooks/ lives inside the .git directory, and the .git directory is never tracked or pushed as part of your repository’s history. A hook you add on your machine stays on your machine — it is not committed, not cloned, and not shared automatically with teammates. This is the single most important thing to understand about client-side hooks: they are local, personal automation, not a substitute for server-side enforcement or CI. (Later in this lesson you’ll see core.hooksPath, which is how teams work around this.)
pre-commit: gatekeeping the snapshot
pre-commit runs after you type git commit but before Git builds the commit object. At this point, Git has already computed what would go into the tree from your index (the staging area) — the hook runs with no arguments and can inspect the staged changes via commands like git diff --cached. If the hook script exits with a non-zero status, Git aborts the commit entirely: no commit object is created, nothing is written to the branch, and your staged changes remain staged exactly as they were. If it exits 0, Git proceeds to build the tree and commit objects as normal.
commit-msg: validating the message
commit-msg runs slightly later, after you have written (or been given) a commit message, but still before the commit object is created. Git passes this hook exactly one argument: the path to a temporary file containing the commit message you just wrote. The hook can read that file, validate its contents, and even rewrite the file to modify the message. As with pre-commit, a non-zero exit status aborts the commit.
Syntax
There is no git subcommand for writing a hook — you write an ordinary script and place it correctly. The general shape is:
.git/hooks/<hook-name> # e.g. pre-commit, commit-msg, pre-push
- File name — must match the hook exactly (
pre-commit,commit-msg, etc.), with no file extension. - Shebang line — the first line (e.g.
#!/bin/shor#!/usr/bin/env bash) tells the OS which interpreter to run the script with. Hooks can be written in any language as long as the file is executable and the OS can invoke it. - Executable bit — on Linux/macOS the file needs
chmod +x; without it, Git silently skips the hook. - Exit code —
exit 0allows the commit to proceed; any non-zero exit code aborts it. - Arguments —
pre-commitreceives none;commit-msgreceives one argument, the path to the commit message file. core.hooksPath— a Git config setting that points Git at a different, trackable directory to look for hooks in, instead of.git/hooks/.
Examples
Example 1: blocking a leftover console.log
A very common beginner mistake is committing debug statements. This pre-commit hook scans staged JavaScript/TypeScript files for console.log and refuses the commit if it finds one.
#!/bin/sh
# pre-commit: block staged console.log statements in JS/TS files
staged_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.js' '*.jsx' '*.ts' '*.tsx')
if [ -z "$staged_files" ]; then
exit 0
fi
if echo "$staged_files" | xargs grep -n "console.log" -- 2>/dev/null; then
echo "Error: found console.log in staged files. Remove debug statements before committing."
exit 1
fi
exit 0
Save this as .git/hooks/pre-commit, then make it executable and try committing a file that contains a stray console.log:
chmod +x .git/hooks/pre-commit
git add app.js
git commit -m "feat(auth): add login button"
Output:
app.js:42: console.log(user);
Error: found console.log in staged files. Remove debug statements before committing.
Git never created the commit — app.js is still staged, so you just edit the file, remove the log line, git add app.js again, and re-run git commit.
Example 2: running your test suite before every commit
A stricter pre-commit hook can refuse to let you commit at all if your automated tests are failing:
#!/bin/sh
# pre-commit: run the test suite before allowing a commit
npm test --silent
if [ $? -ne 0 ]; then
echo "Tests failed. Commit aborted."
exit 1
fi
exit 0
This is powerful, but be aware it runs on every single commit, including tiny work-in-progress ones, which can get slow and frustrating on a large test suite — more on this trade-off in Common Mistakes.
Example 3: enforcing Conventional Commits with commit-msg
If your team writes commit messages in the type(scope): subject style (the same style used throughout this course, e.g. fix(auth): correct token refresh bug), you can enforce the format automatically:
#!/bin/sh
# commit-msg: enforce Conventional Commits style messages
commit_msg_file="$1"
commit_msg=$(head -n1 "$commit_msg_file")
pattern="^(feat|fix|docs|style|refactor|test|chore)(\([a-z0-9./-]+\))?: .{1,72}$"
if ! echo "$commit_msg" | grep -Eq "$pattern"; then
echo "Error: commit message does not follow Conventional Commits format."
echo "Expected: <type>(<scope>): <description>"
echo "Example: feat(auth): add password reset flow"
exit 1
fi
exit 0
Save this as .git/hooks/commit-msg and make it executable. Now a message like "fixed stuff" gets rejected, while "fix(auth): correct token refresh bug" is accepted, keeping your project’s history consistently readable and machine-parseable (useful later for generating changelogs).
How it works step by step
- You run
git commit -m "...". Git first checks for an executable.git/hooks/pre-commitfile. - If found, Git runs it with no arguments and the current working directory set to the repository root (or the invoking directory in some setups). The hook can read the index directly (e.g. via
git diff --cached) since staging has already happened. - If
pre-commitexits non-zero, Git stops here — no tree or commit object is written, and your terminal shows the hook’s own output as the reason. - If it exits zero, Git writes your commit message to a temporary file and checks for an executable
.git/hooks/commit-msg, passing that file’s path as$1. - The hook can read, and even edit, that file. If it exits non-zero, Git aborts again, leaving your staged changes untouched and letting you retry with
git commit(Git will often reuse your last message via the editor). - Only after both hooks succeed does Git build the tree object from the index, the commit object pointing at that tree and at the current commit as its parent, and move the current branch pointer to the new commit.
Common Mistakes
Mistake: forgetting the executable bit. You write a perfect pre-commit script, but commits sail through as if it doesn’t exist. Git silently ignores non-executable hook files — there’s no warning. Fix: always run chmod +x .git/hooks/pre-commit after creating or editing a hook.
Mistake: assuming hooks are shared with the team. A developer adds a great commit-msg hook, commits their work, and is confused when a teammate’s identical clone doesn’t enforce it. .git/hooks/ is never tracked by Git, so it never travels with git clone or git pull. Fix: use core.hooksPath (below) to point Git at a tracked directory, or a dedicated hook manager, so every clone gets the same hooks.
Mistake: an overly slow pre-commit hook. Running a full test suite or a heavyweight linter on every commit makes small, frequent commits painful, and developers start reaching for --no-verify just to get on with their day — which defeats the purpose. Fix: keep pre-commit fast (lint only staged files, run a quick formatter) and push slower checks to pre-push or your CI pipeline instead.
Mistake: routinely bypassing hooks with --no-verify. Both pre-commit and commit-msg can be skipped entirely:
git commit -m "fix(urgent): hotfix bypassing pre-commit" --no-verify
This flag exists for genuine emergencies, but if it becomes habitual, your hooks are enforcing nothing. Treat frequent use of --no-verify as a signal that the hook itself is too strict or too slow, and fix the hook rather than normalizing the bypass.
Best Practices
- Keep
pre-commitchecks fast and scoped to staged files only (e.g.git diff --cached --name-only), not the whole codebase. - Use
core.hooksPathor a hook manager so hooks are version-controlled and identical for every contributor, instead of relying on each person to copy scripts into.git/hooks/manually. - Make hook failures explain themselves clearly — print exactly what failed and how to fix it, as shown in the examples above.
- Reserve heavier checks (full test suites, integration tests) for
pre-pushor CI, where a few extra seconds or minutes matter less. - Never rely on a client-side hook alone for rules that matter for security or compliance — anyone can delete or bypass their local hooks with
--no-verifyor by editing the file. Mirror important rules as server-side checks (e.g. GitHub branch protection rules or required status checks) too. - Version your hook scripts in a normal tracked folder (e.g.
.githooks/) with clear comments, just like any other source file.
Sharing hooks across a team with core.hooksPath
Since .git/hooks/ can’t be committed, the standard fix is to keep hook scripts in a regular, tracked folder and tell Git to look there instead:
mkdir -p .githooks
mv .git/hooks/commit-msg .githooks/commit-msg
chmod +x .githooks/commit-msg
git config core.hooksPath .githooks
git add .githooks
git commit -m "chore(hooks): share commit-msg hook via core.hooksPath"
Anyone who clones the repository still has to run git config core.hooksPath .githooks once themselves (this setting lives in the local, untracked .git/config, not in the tracked history), but at least the hook’s actual source code is now version-controlled, reviewable in pull requests, and identical for everyone who opts in. Many teams automate that one-time git config step with a setup script or an npm postinstall hook.
Practice Exercises
- In a scratch repository, write a
pre-commithook that rejects any staged file larger than 1 MB, printing the offending file’s name and size. Test it by staging a large binary file and confirming the commit is blocked, then confirm a normal small text file still commits fine. - Write a
commit-msghook that rejects any commit message under 10 characters or that starts with a lowercase letter after the type/scope prefix (e.g. reject"fix: broken thing."only if it doesn’t match your team’s exact style — decide on a rule and enforce it). Confirm both a message that should pass and one that should fail behave as expected. - Move a working
pre-commithook out of.git/hooks/into a tracked.githooks/directory, setcore.hooksPath, and verify (withgit config --get core.hooksPath) that Git is now reading hooks from the new location instead.
Summary
- Git hooks are ordinary executable scripts placed in
.git/hooks/with a name matching the event (pre-commit,commit-msg, etc.); a non-zero exit code aborts the action. pre-commitruns before the commit is built and can inspect staged changes;commit-msgruns afterward and receives the path to the commit message as its one argument.- Hooks live in
.git/, so they are never cloned, pulled, or pushed automatically — usecore.hooksPathpointing at a tracked folder to share them across a team. git commit --no-verifyskips bothpre-commitandcommit-msg; use it sparingly and treat frequent use as a sign a hook needs fixing, not bypassing.- Keep client-side hooks fast and helpful; back up anything security- or compliance-critical with server-side enforcement, since local hooks can always be disabled by the person running them.
