Git Hooks & Automation
Reviewed & published by Brayan K
Let Git run your checks for you. By the end of this lesson you'll write a pre-commit hook that blocks bad commits, a commit-msg hook that enforces a message format, and you'll know how Husky shares hooks with your whole team.
Part of the free Git course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- What hooks are and where they live (.git/hooks)
- Write a pre-commit hook that blocks a commit via a non-zero exit
- Validate the commit message with a commit-msg hook
- Make hook scripts executable so Git will run them
- Understand why .git/hooks isn't shared with teammates
- Share hooks across a team with Husky or the pre-commit framework
1️⃣ Where Hooks Live
Every repo has a .git/hooks directory full of .sample scripts Git ignores. To activate one, create a file with the real hook name (drop .sample) and make it executable. The client-side hooks you'll use most are pre-commit, commit-msg, and pre-push.
# A "hook" is a script Git runs automatically at a certain moment — like
# right before a commit. They live in the .git/hooks directory.
ls .git/hooks
# Git ships SAMPLE hooks ending in .sample (which it ignores). To activate
# one, create a file with the real hook name (no .sample) and make it
# executable. Client-side hooks you'll meet most:
# pre-commit runs BEFORE the commit is created (lint, format, quick tests)
# commit-msg validates/edits the commit MESSAGE
# pre-push runs BEFORE a push (good place for the full test suite)2️⃣ A pre-commit Hook
The pre-commit hook runs before the commit is recorded — perfect for linting, formatting, or quick checks. The rule is simple: exit non-zero to abort the commit, exit 0 to allow it. Here's one that refuses any commit whose staged changes contain TODO.
# A pre-commit hook that BLOCKS any commit containing the word TODO.
# Save this as .git/hooks/pre-commit (no extension) and make it executable.
#!/bin/sh
# Check the staged changes for "TODO"
if git diff --cached | grep -q "TODO"; then
echo "Error: remove TODO before committing"
exit 1 # non-zero exit ABORTS the commit
fi
exit 0 # zero exit lets the commit proceed
# Make it runnable, then test it:
# chmod +x .git/hooks/pre-commit
# git commit -m "test" # blocked if any staged line contains TODO3️⃣ A commit-msg Hook
The commit-msg hook receives the path to the message file as its first argument, so it can validate or reject the wording. This one insists every message references a ticket like JIRA-123.
# A commit-msg hook that REQUIRES a ticket reference like "JIRA-123".
# Git passes the path to the message file as the first argument ($1).
# Save as .git/hooks/commit-msg and chmod +x it.
#!/bin/sh
if ! grep -qE "[A-Z]+-[0-9]+" "$1"; then
echo "Error: commit message must reference a ticket, e.g. JIRA-123"
exit 1
fi
# Now "git commit -m 'fix bug'" is rejected, but
# "git commit -m 'JIRA-42 fix bug'" is accepted.Your turn. Write a pre-commit hook that blocks the word DEBUG. Fill in the three blanks.
# 🎯 YOUR TURN — replace each ___ then test it in a practice repo.
# Goal: a pre-commit hook that blocks the word DEBUG.
# Save this as .git/hooks/pre-commit:
#!/bin/sh
if git diff --cached | grep -q "DEBUG"; then
echo "Error: remove DEBUG before committing"
___ 1 # 👉 the keyword that aborts the commit (non-zero)
fi
___ 0 # 👉 the same keyword with 0 to allow the commit
# Then make it runnable:
chmod ___ .git/hooks/pre-commit # 👉 the flag that adds execute permission
# ✅ Expected: committing a staged line containing DEBUG is blocked;
# a clean commit goes through.4️⃣ Sharing Hooks with Your Team
Here's the catch: .git/hooks isn't committed, so your hooks don't travel with clones — teammates won't have them. Tools solve this by keeping hooks in the tracked part of the repo. Husky stores them under .husky/ for JavaScript projects; the language-agnostic pre-commit framework uses a committed .pre-commit-config.yaml.
Common Errors (and the fix)
- Hook never runs: it isn't executable. Run chmod +x .git/hooks/pre-commit, and make sure the filename has no .sample extension.
- "cannot execute: required file not found": a missing or wrong shebang. Start the script with #!/bin/sh (or your interpreter).
- Teammates don't have your hooks: expected — .git/hooks isn't cloned. Use Husky or the pre-commit framework to share them.
- A hook blocks a commit you must make: bypass once with git commit --no-verify — but treat that as an escape hatch, not a habit.
- Relying on hooks for security: they're skippable locally. Back them with CI / protected-branch rules for real enforcement.
Pro Tips
- 💡 Keep pre-commit fast: lint and format the staged files only; save the full test suite for pre-push or CI.
- 💡 Point hooks at a tracked folder: git config core.hooksPath .githooks lets you commit hooks directly.
- 💡 Combine with CI: hooks catch issues early; GitHub Actions enforces them where they can't be skipped.
- 💡 Use ready-made hooks: the pre-commit framework has a big library for many languages.
📋 Quick Reference
| Hook / Command | Purpose |
|---|---|
| .git/hooks/pre-commit | Run before a commit (lint/format) |
| .git/hooks/commit-msg | Validate the commit message ($1) |
| .git/hooks/pre-push | Run before a push (tests) |
| chmod +x <hook> | Make a hook runnable |
| git commit --no-verify | Skip hooks for one commit |
| npx husky init | Set up shared hooks (JS) |
Mini-Challenge: Enforce Quality Before Every Commit
No full scripts this time — just the plan. Build hooks that catch bad code and bad messages, then share them.
# 🎯 MINI-CHALLENGE: enforce quality before every commit.
# No full scripts given — just the plan. Use a practice repo.
#
# 1. Create .git/hooks/pre-commit that runs your linter or test command
# 2. chmod +x it, then commit something that fails the check — confirm it's blocked
# 3. Fix the issue and confirm a clean commit succeeds
# 4. Add a commit-msg hook that rejects empty or one-word messages
# 5. Bonus: move your hook into Husky (.husky/) so the team shares it
#
# ✅ Expected: bad code and bad messages are blocked locally before they
# ever reach the repo, and the hook can be shared via Husky.
# your commands here🎉 Lesson Complete!
- ✅ Hooks are scripts Git runs automatically at workflow events
- ✅ They live in .git/hooks and must be executable
- ✅ pre-commit blocks a commit by exiting non-zero
- ✅ commit-msg validates the message file passed as $1
- ✅ .git/hooks isn't shared — use Husky or the pre-commit framework
- ✅ Hooks are convenience, not security — back them with CI
- 🎓 You've completed the Git course — congratulations!
Practice quiz
What is a Git hook?
- A remote branch
- A merge conflict marker
- A script Git runs automatically at certain points, like before a commit
- A type of tag
Answer: A script Git runs automatically at certain points, like before a commit. Hooks are scripts Git runs on events such as committing or pushing, to automate or enforce steps.
Where do a repository's hooks live by default?
- .git/hooks
- .github/hooks
- .hooks
- the repo root
Answer: .git/hooks. Each repo's hooks live in the .git/hooks directory as executable scripts.
Which hook runs BEFORE a commit is created, often to lint or test?
- post-commit
- pre-push
- commit-msg
- pre-commit
Answer: pre-commit. pre-commit runs before the commit is recorded; a non-zero exit aborts the commit.
How does a pre-commit hook block a commit?
- By printing 'STOP'
- By exiting with a non-zero status code
- By deleting the file
- By returning true
Answer: By exiting with a non-zero status code. If the hook exits non-zero, Git aborts the commit; exit 0 lets it proceed.
Which hook can validate or reformat the commit MESSAGE?
- commit-msg
- pre-commit
- post-checkout
- pre-rebase
Answer: commit-msg. commit-msg receives the path to the message file and can check or reject it.
Why are hooks in .git/hooks NOT shared with teammates by default?
- They are encrypted
- They are too large
- The .git directory isn't committed or cloned
- Git deletes them on push
Answer: The .git directory isn't committed or cloned. The .git directory isn't part of the tracked tree, so its hooks don't travel with clones.
What must a hook script be in order to run?
- Named .hook
- Executable (and have a valid shebang)
- Written in Python
- Committed to main
Answer: Executable (and have a valid shebang). Git only runs hook files that are executable; a shebang like #!/bin/sh tells the OS how to run it.
What problem does a tool like Husky solve?
- It speeds up cloning
- It encrypts commits
- It hosts repos
- It version-controls and installs hooks so the whole team shares them
Answer: It version-controls and installs hooks so the whole team shares them. Husky keeps hooks in the repo and wires them up on install, so everyone gets the same checks.
The 'pre-commit' framework is configured with which file?
- hooks.json
- .pre-commit-config.yaml
- .gitconfig
- Makefile
Answer: .pre-commit-config.yaml. The pre-commit framework reads .pre-commit-config.yaml to manage multi-language hooks.
Which hook runs on the developer's machine just before code is sent to the remote?
- post-receive
- update
- pre-push
- post-merge
Answer: pre-push. pre-push runs locally before a push completes — handy for running the test suite first.
Continue this course
- Previous: Submodules & Monorepos
- Next: Multiple Working Trees with git worktree — Check out several branches at once in separate directories that share one .git
- Quick reference: Git cheat sheet
Frequently asked questions
What exactly is a Git hook?
A hook is a script Git runs automatically at a specific point in its workflow — for example just before a commit (pre-commit), when checking the commit message (commit-msg), or before a push (pre-push). They let you automate checks and chores: run a linter, format code, run quick tests, or enforce a message convention. If a hook exits with a non-zero status, Git aborts the action.
Where do hooks live, and why aren't they shared?
Each repo's hooks live in its .git/hooks directory as executable scripts. The .git directory is local plumbing — it isn't part of the tracked file tree, so it's never committed, pushed, or included in a clone. That means hooks you set up by hand exist only on your machine; teammates won't get them unless you use a tool that stores hooks in the tracked part of the repo.
How does a hook block an action like a commit?
By its exit code. Git runs the hook and inspects the result: exit 0 means 'all good, proceed', and any non-zero exit means 'stop'. So a pre-commit script that finds a problem prints an error and runs exit 1, which aborts the commit. This simple convention is how every client-side enforcement hook works.
Why do teams use Husky or the pre-commit framework?
Because raw .git/hooks scripts aren't shared. Husky (popular in JavaScript projects) keeps hooks under a tracked .husky/ folder and installs them automatically, so everyone who clones gets the same checks. The language-agnostic 'pre-commit' framework does the same via a committed .pre-commit-config.yaml and a large library of ready-made hooks. Both solve the 'works on my machine only' problem.
Are hooks a security guarantee?
No. Client-side hooks run on the developer's own machine and can be bypassed (git commit --no-verify) or simply not installed. They're great for catching mistakes early and keeping a team consistent, but they're conveniences, not enforcement. For guarantees, pair them with server-side checks — required status checks in CI (GitHub Actions) or protected-branch rules that can't be skipped locally.