What you'll learn
Quick Answer
Git hooks are scripts that Git runs automatically at specific points in its workflow, before a commit, after a commit, before a push, and more, stored as executable files in .git/hooks/. A pre-commit hook that exits with a non-zero status blocks the commit entirely, before it's created, which makes hooks a common way to enforce rules like no debug statements or no failing tests reaching the repository.
What a Git Hook Actually Is
Every Git repository has a .git/hooks/ directory, pre-populated with sample scripts ending in .sample that do nothing until renamed. Rename pre-commit.sample to pre-commit, make it executable, and Git runs it automatically every time someone runs git commit, before the commit object is created.
If the script exits with status 0, the commit proceeds. If it exits non-zero, Git aborts the commit and nothing is written to history. That's the whole mechanism: an executable that Git calls at a fixed point, gated on its exit code.
Common hook names: pre-commit (before the commit message editor even opens), commit-msg (validate the message itself), pre-push (before anything leaves your machine), and post-checkout. They can be written in any language, shell, Python, Node, as long as the file is executable and starts with a shebang line the OS can run.
Writing a Pre-Commit Hook
Here's a real one that blocks any commit whose staged changes add a console.log line, a common rule for keeping debug statements out of a codebase:
#!/bin/sh
# .git/hooks/pre-commit
if git diff --cached -U0 | grep -E '^\+' | grep -vE '^\+\+\+' | grep -q 'console\.log'; then
echo "COMMIT REJECTED: found console.log in staged changes. Remove debug statements before committing." >&2
exit 1
fi
exit 0
git diff --cached -U0 shows only the changed lines of what's staged. The first grep keeps lines that were added (start with +); the second drops the +++ b/file diff header so it isn't mistaken for an added line; grep -q then checks whether any surviving line contains console.log. Make the file executable and it's live, no configuration or registration step:
$ chmod +x .git/hooks/pre-commit
Git finds it purely by filename inside .git/hooks/.
Watching It Block a Real Commit
Stage a file with a debug statement in it and try to commit:
$ cat app.js
function add(a, b) {
console.log('debugging add', a, b);
return a + b;
}
$ git add app.js
$ git commit -m "Add add() function"
COMMIT REJECTED: found console.log in staged changes. Remove debug statements before committing.
$ echo $?
1
The commit fails with exit status 1, and, this is the important part, no commit object is created. Checking the log right after confirms it:
$ git log --oneline
fatal: your current branch 'main' does not have any commits yet
git status shows app.js still sitting staged, exactly where it was before the failed attempt. Nothing to undo, because nothing happened; the working directory, the index, and the branch are all in precisely the state they were in the moment before git commit was typed, with the only trace of the attempt being the rejection message printed to the terminal.
A Clean Commit Goes Through
Remove the debug line and commit again:
$ cat app.js
function add(a, b) {
return a + b;
}
$ git add app.js
$ git commit -m "Add add() function"
[main (root-commit) 574ab32] Add add() function
1 file changed, 3 insertions(+)
create mode 100644 app.js
$ git log --oneline
574ab32 Add add() function
Same message, same author, same working directory; the only thing that changed is the file content the hook actually checks. The hook ran silently this time: no output, no error, exit code 0, and the commit went through exactly as a normal git commit would. That silence is intentional; a hook that only speaks up when it's rejecting something stays out of the way on every commit that doesn't need it.
A Real Bug Found While Building This
Building this exact hook turned up a genuine bug worth knowing about, because it's a classic shell scripting trap. An earlier version used grep -v '^\+\+\+' without the -E flag on that second grep, while the first grep in the pipe had -E. Testing it directly:
$ git diff --cached -U0 | grep -E '^\+'
+++ b/debugfile.js
+console.log('debug test')
$ git diff --cached -U0 | grep -E '^\+' | grep -v '^\+\+\+'
(no output, both lines vanished)
The added console.log line disappeared along with the diff header it was supposed to filter out. The cause: in basic (non--E) grep, \+ is a GNU extension meaning "one or more", the opposite of its meaning in extended regex, where \+ escapes a literal plus sign. grep -v '^\+\+\+' was silently matching "one or more + at the start of the line", which matches a single leading + just as well as three. Adding -E to the second grep so both use the same regex flavor fixed it. Mixing basic and extended regex escaping in one pipeline is an easy way to write a hook that looks correct and quietly does nothing.
The Catch: Hooks Don't Get Committed
The catch that surprises almost everyone the first time: .git/hooks/ is never committed. It lives inside the .git directory itself, which Git never tracks as part of the repository's content. Clone the repository on another machine, or have a teammate pull it, and the hook simply isn't there.
Two common ways around this. The simple one: keep the actual hook scripts in a tracked folder such as .githooks/, and point Git at it with git config core.hooksPath .githooks, a one-time setup step each teammate runs, or one that a setup script runs for them. The more common one in real projects: a tool like Husky or pre-commit installs the hooks as part of npm install or a documented setup command, so nobody has to remember the manual step.
Either way, a hook that isn't reproduced on every clone isn't actually enforcing anything for the team; it only protected the one machine it was written on.
