Skip to main content
Git & GitHub · lesson 12 of 12
Git & GitHub advanced Lesson 12 of 12

Blocking Bad Commits with a pre-commit Hook

Write a real executable pre-commit hook that inspects staged files, watch it reject a commit with a nonzero exit code, then fix the file and commit successfully.

By SkillByExample · Published

git hooks automation

A hook is just an executable file with a name Git recognizes, sitting in .git/hooks. No config file, no registration — the presence and the exit code are the entire mechanism.

1) A pre-commit hook that inspects staged content

$ cat .git/hooks/pre-commit
#!/bin/sh
if git diff --cached --name-only | xargs grep -l "console.log" 2>/dev/null; then
  echo "pre-commit: found console.log in a staged file, remove it before committing" >&2
  exit 1
fi
exit 0

git diff --cached --name-only lists staged files — exactly what’s about to be committed, not the whole working tree. The hook greps them for a forbidden string and exits 1 if it finds one.

2) A commit that trips the hook

$ printf 'export function debugAdd(a, b) {\n  console.log("adding", a, b);\n  return a + b;\n}\n' > debug.mjs
$ git add debug.mjs
$ git commit -m "Add debugAdd helper"
debug.mjs
pre-commit: found console.log in a staged file, remove it before committing

Git ran the hook before doing anything else, saw exit code 1, and aborted — no commit object was created. git log --oneline -1 still shows the previous commit; debug.mjs is still staged, waiting to be fixed.

3) Fix the file, and the same commit succeeds

$ printf 'export function debugAdd(a, b) {\n  return a + b;\n}\n' > debug.mjs
$ git add debug.mjs
$ git commit -m "Add debugAdd helper"
[main b31842c] Add debugAdd helper
 1 file changed, 3 insertions(+)
 create mode 100644 debug.mjs

Same hook, same commit message, different staged content — this time grep finds nothing, the hook exits 0, and Git proceeds normally.

Practice

  1. Make the hook executable and confirm the difference: chmod -x .git/hooks/pre-commit, attempt a commit with console.log staged, and observe Git silently skips a non-executable hook instead of blocking the commit.

    Solution

    Git only runs hook files that are both present and executable. A non-executable pre-commit is treated as if it doesn’t exist — no warning, no error, the commit just goes through.

  2. Add a second check to the hook (for example, blocking a staged file over some size) using the same git diff --cached pattern, and confirm both checks can independently block a commit.

  3. Bypass the hook deliberately with git commit --no-verify on a commit that would normally be blocked, and confirm it succeeds — useful to know, and worth explaining to a team why it should be used sparingly.

  4. Write a commit-msg hook (receives the commit message file path as $1) that rejects a commit message under 10 characters, and test it with a real short message.

  5. Clone the repository into a fresh directory and confirm .git/hooks/pre-commit does not exist in the clone — hooks are local, not part of the repository’s tracked history.

Frequently Asked Questions

What makes a file in .git/hooks actually run?
Git looks for a file with an exact name (pre-commit, pre-push, commit-msg, and a fixed set of others) in .git/hooks, and runs it if it exists and is executable. No registration step is needed — the filename and the executable bit are the entire configuration.
How does Git decide whether the hook passed or failed?
By the process exit code, the same convention as git bisect run: 0 lets the commit through, any nonzero code aborts it before the commit object is created. The hook's stdout/stderr is shown to the user but doesn't affect the outcome.
Are hooks copied when someone clones the repo?
No. .git/hooks lives inside .git, which clone never copies — hooks are local-only by design. Teams distribute hooks by committing a scripts/ directory to the repo and documenting (or automating) a one-time copy into .git/hooks, or by using a shared tool like Husky or pre-commit.com that manages that step.
What's the difference between pre-commit and commit-msg hooks?
pre-commit runs before Git even asks for a commit message, and typically inspects staged file content (lint, forbidden strings, tests). commit-msg runs after the message is written, receives the message's file path as an argument, and typically enforces message format (e.g. a ticket-number prefix).