Git hooks — scripts that gate your workflow
Git hooks are scripts in .git/hooks that fire on events: pre-commit (lint), commit-msg (message format), pre-push (tests), plus server-side pre-receive. A non-zero exit aborts the operation. Hooks are not committed, so teams share them with husky or lefthook.
Every team has a rule that gets broken: “run the linter before committing,” “tests must pass before you push,” “commit messages follow the convention.” Reminding people in code review is too late — the bad commit already exists. Git has a built-in way to enforce these rules at the exact moment they matter: hooks, scripts git runs automatically around commits, pushes, and merges. A hook that exits non-zero stops the operation cold.
By the end of this lesson you will be able to name the key client-side hooks, write a pre-commit hook, and explain why teams reach for husky or lefthook to share hooks that git itself does not commit.
After this lesson you can explain what a git hook is, place a script in .git/hooks, name the roles of pre-commit, commit-msg, and pre-push, explain why hooks are not shared by default, and describe how husky/lefthook and core.hooksPath solve that.
A hook is a script git runs automatically when a specific event happens. Hooks live in .git/hooks. Git ships sample files there with a .sample suffix (disabled); remove the suffix and make the file executable to activate one:
ls .git/hooks # pre-commit.sample, commit-msg.sample, pre-push.sample, ...
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit # must be executable to runThe script can be any executable — shell, Node, Python — as long as it has the right name and a correct shebang.
A non-zero exit code aborts the operation — that is the entire enforcement mechanism. Git runs the hook and checks its exit status. Exit 0 means “proceed”; any non-zero exit means “stop, do not perform this action.” A pre-commit that finds a lint error and exits 1 cancels the commit before it is created:
#!/bin/sh
# .git/hooks/pre-commit — block the commit if linting fails
npm run lint || {
echo "Lint failed — commit aborted. Fix the errors and try again." >&2
exit 1
}This is why hooks are a gate, not just a notification: the bad commit never comes into existence.
The three client-side hooks you will use most. They fire at different points of the commit/push flow:
pre-commit— runs before the commit is created. Use it to lint, format, or run fast checks. Aborting here means nothing is committed.commit-msg— receives the path to the message file; use it to enforce a format such as Conventional Commits (feat:,fix:). Reject malformed messages.pre-push— runs before commits are sent to a remote. Use it for slower gates like the test suite, so broken code never reaches the shared branch.
#!/bin/sh
# .git/hooks/commit-msg — require a Conventional Commits prefix
grep -qE '^(feat|fix|docs|refactor|test|chore)(\(.+\))?: ' "$1" || {
echo "Commit message must start with a type, e.g. 'feat: ...'." >&2
exit 1
}▸Why this works
Why both pre-commit and pre-push? They trade speed for thoroughness. pre-commit runs on every commit, so it must be fast — linting a few staged files takes a second. pre-push runs far less often (only when you actually share work), so it can afford a slow but decisive gate like the full test suite. Putting the test suite in pre-commit would make every commit painful and tempt everyone to bypass it; splitting fast/slow checks across the two events keeps each one tolerable.
Hooks live in .git, so they are NOT committed or shared by default. The .git/hooks directory is part of your local repository metadata, not your tracked files — cloning a repo does not bring its hooks. That means a hook you set up protects only your machine; a teammate cloning the project gets none of it. For a team rule, a local-only hook is useless.
Teams share hooks with a manager like husky or lefthook, via core.hooksPath. The fix is to keep hook definitions in a tracked directory and point git at it. Git’s core.hooksPath setting redirects hooks away from .git/hooks to a versioned folder:
git config core.hooksPath .githooks # use a committed directory insteadTools like husky and lefthook automate exactly this: they store hooks in the repo, and an install step (often run on npm install) wires core.hooksPath so every clone gets the same gates. Now the rule travels with the project instead of living on one laptop.
Adding a lint gate that the whole team gets.
You want every commit linted, on every teammate’s machine. A raw .git/hooks/pre-commit would protect only you, so you use a tracked directory.
mkdir .githooks
cat > .githooks/pre-commit <<'EOF'
#!/bin/sh
npm run lint:staged || {
echo "Lint failed — fix issues before committing." >&2
exit 1
}
EOF
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
git add .githooks/pre-commit && git commit -m "chore: add shared pre-commit lint hook"Now the hook is a tracked file. But there is a catch: core.hooksPath is local config, not committed, so a fresh clone still needs that one git config line to run. This is precisely the gap husky and lefthook close — their install step sets core.hooksPath automatically (e.g. via a prepare script on npm install), so a new contributor runs npm install and is instantly gated, with no manual setup. Test the gate by staging a file with a lint error and committing: git runs your script, it exits 1, and the commit is refused with your message.
▸Common mistake
Hooks are a convenience, not a security boundary. Anyone can skip a client-side hook with git commit --no-verify (or git push --no-verify), and a contributor without the hook installed never runs it at all. So never rely on a client hook as your only guarantee — back critical rules with server-side enforcement (a pre-receive hook or CI checks on the remote) that a contributor cannot bypass. Client hooks catch mistakes early; server-side gates make them mandatory.
You write a pre-commit hook in .git/hooks/pre-commit that lints your code. Why does a teammate who clones the repo NOT get this check?
A git hook is a script in .git/hooks that git runs automatically on an event; a non-zero exit aborts the operation, making hooks a real gate. The client-side workhorses are pre-commit (fast checks like linting), commit-msg (enforce message format), and pre-push (slower gates like tests), with server-side pre-receive/update as the mandatory backstop. Because hooks live in .git, they are not committed or shared by default, so teams use husky or lefthook plus core.hooksPath to version hooks and install them on every clone — while remembering --no-verify means client hooks are advisory, not enforced. Next you will see a built-in helper that makes repeated conflict resolution automatic: rerere.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.