Automate Git Workflows with Hooks
Learn how to automate workflows with git hooks in this hands-on Git Tutorial lesson. Covers the core concept, step-by-step walkthrough, and troubleshooting.
Focus: automate workflows with git hooks
You're mid-refactor, about to commit a breaking change, and you hit Enter. The commit lands, CI fails minutes later, and you've already pushed to a shared branch. The fix costs you twenty minutes and a rebase. Now imagine a safety net that runs your tests, checks your code style, or even rejects the commit before it ever lands. That's the power of git hooks — small scripts Git runs at key moments in your workflow. In this lesson, you'll learn how to automate workflows with git hooks, turning manual checks into automatic guardrails that save time and prevent costly mistakes.
The problem this lesson solves
Without hooks, you rely on discipline. You tell yourself you'll run the linter before committing, or remember to write a clear message. But discipline fades under deadline pressure. The result: sloppy commits, broken builds, and debug statements that sneak into production. The bigger the team, the worse the problem — one bad merge can stall everyone.
Git hooks solve this by moving checks into Git itself. Instead of hoping developers remember, you make Git run the checks automatically. No more chasing down "who committed the missing semicolon." The hook catches it before anyone sees it.
Pro tip: Hooks are local by default — they stay in your
.git/hooksdirectory and aren't shared. That's perfect for personal workflows, but for team-wide rules you'll want to version them (more on that later).
Core concept / mental model
Think of GitHub Actions or a CI pipeline — but running before the event, on your machine. Hooks are scripts that execute at specific points in Git's lifecycle. They're like guardrails on a highway: you don't notice them until you're about to veer off course.
A mental model: Git is the librarian and hooks are the bookends. When you ask to add a book (commit), the bookend checks that it's the right size, has a cover, and isn't already on the shelf. If it fails, the book is rejected. You can't skip the check — you have to fix the book first.
Formally, a hook is an executable file in .git/hooks/ with a name like pre-commit, pre-push, or commit-msg. When Git reaches that event, it runs the script. The script's exit code decides the outcome:
- Exit 0 → success, Git continues
- Non-zero exit → failure, Git aborts the operation
That's the entire magic. You write a script, make it executable, and Git obeys.
How it works step by step
Let's trace a typical git commit through the hook lifecycle:
- You run
git commit. - Git executes
pre-commit(if it exists). pre-commitruns your checks (linting, formatting, secret scanning).- If the script exits 0, Git proceeds to create the commit.
- If it exits non-zero, the commit is blocked, and you see the script's output.
- After the commit,
post-commitruns (for notifications, etc.).
Here's a visual in words:
pre-commit → commit-msg → post-commit
pre-push → post-update (on remote)
prepare-commit-msg → pre-merge-commit
Each hook is a placeholder for your script. The most useful ones:
pre-commit— lint, format, run unit tests, check for secrets.prepare-commit-msg— auto-fill subject or add issue numbers.commit-msg— enforce commit message conventions.pre-push— run slow tests, build, or verify credentials.
Pro tip: You can have multiple checks inside one hook by chaining commands with
&&or using a hook manager likehusky/pre-commit(the Python tool) to manage them declaratively.
Hands-on walkthrough
Let's build a practical pre-commit hook that rejects commits if there's a trailing whitespace or a marker like FIXME in the staged changes.
Step 1: Create a test repository
mkdir git-hooks-demo && cd git-hooks-demo
git init
echo "console.log('hello')" > app.py
git add app.py
Step 2: Write the hook
Create a file .git/hooks/pre-commit (no extension) with the following content:
#!/bin/bash
echo "Running pre-commit checks..."
# Check for trailing whitespace in staged files
if git diff --cached --check; then
echo "Whitespace check passed"
else
echo "ERROR: Trailing whitespace detected. Fix before committing."
exit 1
fi
# Check for FIXME markers in staged lines
if git diff --cached | grep -i 'FIXME'; then
echo "ERROR: FIXME marker found in staged changes."
exit 1
fi
echo "All checks passed"
Make it executable:
chmod +x .git/hooks/pre-commit
Step 3: Test it
Add a line with trailing whitespace:
echo "bad line " >> app.py
git add app.py
git commit -m "Introduce a bad line"
You'll see the hook block the commit:
Running pre-commit checks...
<line with trailing whitespace>
ERROR: Trailing whitespace detected. Fix before committing.
Now fix it:
sed -i 's/[[:space:]]*$//' app.py
git add app.py
git commit -m "Clean up whitespace"
This time the commit succeeds, and the hook reports success.
Step 4: Watch the exit code
Run echo $? after a failed commit — it returns 1, confirming the hook's blocking power.
Compare options / when to choose what
Hooks can be split into two worlds: client-side (your machine) and server-side (remote repository). Most hooks you'll write are client-side. Here's a comparison:
| Hook type | Where it runs | Use cases | Sharing |
|---|---|---|---|
pre-commit |
Local | Lint, format, unit tests | Requires versioning setup |
commit-msg |
Local | Enforce message conventions (e.g., conventional commits) | Requires versioning setup |
pre-push |
Local | Slow integration tests, build | Requires versioning setup |
pre-receive |
Server | Reject pushes with secrets, enforce branch protection | Automatically applied to all clones |
When deciding where to place a check:
- Fast checks (lint, whitespace) →
pre-commit - Message validation →
commit-msg - Slow tests (minutes) →
pre-pushto avoid blocking every commit - Security critical (secrets) → server-side
pre-receivefor ultimate enforcements
Pro tip: Don't overload
pre-commitwith slow tests — it'll frustrate your team. Save those forpre-push.
Troubleshooting & edge cases
- Hook not running? Make sure the file is executable (
chmod +x). Git silently skips non-executable hooks. - Exit code ignored? Some hooks (like
post-commit) don't block anything — they don't affect success. Only the pre-* hooks block. - Bypass detection: You can skip hooks with
git commit --no-verify. That's useful for emergencies, but teaches bad habits. Use it sparingly. - Too many hooks? Use a manager like husky (Node) or pre-commit (Python) to share hooks across a team. They version your scripts in the repo and install them automatically.
# Skip hooks for a one-off commit
git commit --no-verify -m "hotfix"
What you learned & what's next
You now know how to automate workflows with git hooks: the event model, exit-code protocol, and practical script writing. You can enforce standards before code ever reaches the remote. This is a small but powerful step toward mature Git workflows.
Next, you'll explore branch protection rules and code review — how to back up your hooks with server-side policies that even --no-verify can't bypass. That's where teams truly enforce quality.
To solidify your skills, try writing a commit-msg hook that enforces a specific prefix (like feat: or fix:) — a common real-world pattern.
Practice recap
Write a pre-commit hook that runs python -m py_compile on all staged .py files and blocks the commit if any fail. Then, intentionally add a syntax error, try to commit, and watch the hook catch it. After fixing, verify the commit succeeds. This gives you a taste of real-world guardrails before moving on to branch protection.
Common mistakes
- Forgetting to
chmod +xthe hook file — Git silently ignores non-executable hooks, so nothing happens and checks are skipped. - Making the hook too slow (e.g., running full test suite in
pre-commit) — it blocks every commit and leads to users using--no-verifyout of frustration. - Writing output to stdout instead of stderr — Git may show it confusingly, and some hook managers capture only stderr.
- Not handling staged files correctly — you must check
git diff --cached(staged) content, not the working directory, otherwise uncommitted changes can be missed or false positives introduced. - Assuming hooks are shared by default — they're local, so your team needs a separate mechanism (like a hook manager or versioned scripts) to install them consistently.
Variations
- Use a hook manager like 'husky' (JavaScript) or 'pre-commit' (Python) to define and share hooks declaratively — they handle installation, versioning, and language-specific runners.
- Set up server-side hooks like
pre-receiveorupdateto enforce policies on the central repository — this prevents bypasses even with--no-verify. - Combine hooks with CI/CD pipelines: use
pre-pushfor fast local checks, then let CI run deeper tests after push; this balances speed and thoroughness.
Real-world use cases
- Automatically lint and format staged files before every commit to keep the codebase clean.
- Enforce conventional commit message formats (e.g., feat:, fix:) with a commit-msg hook to improve changelog generation.
- Block pushes with accidental secrets or large files using a pre-push hook, preventing leakage to the remote.
Key takeaways
- Hooks are local, executable scripts in .git/hooks/ that Git runs at specific events; the exit code (0 success, non-zero failure) determines if the operation continues.
- The most impactful hooks are pre-commit, commit-msg, and pre-push — use them for fast checks and policy enforcement.
- Check the staged content (git diff --cached) in hooks, not the working tree, to ensure you're validating what will actually be committed.
- Make hooks executable and test them carefully — a missing chmod is the #1 reason hooks silently don't run.
- For team-wide automation, use a hook manager or version the hook scripts to ensure every developer gets the same guards.
- Use --no-verify only as a last-resort emergency bypass — overusing it defeats the entire purpose of hooks.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.