Git hooks are scripts that run automatically at specific points in Git's workflow — commit, push, merge — and they're one of the most underused tools for catching mistakes before they leave your machine.
Where hooks live
ls .git/hooks/Every Git repo has a .git/hooks/ directory with sample hooks (.sample files) that aren't active by default. Removing the .sample extension and making the file executable activates it.
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitThe most useful hook: pre-commit
Runs before a commit is finalized — the natural place to catch problems before they enter history.
#!/bin/sh
# .git/hooks/pre-commit
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed — commit aborted."
exit 1
fiExiting with a non-zero status blocks the commit. This is the mechanism behind "can't commit code that fails lint" workflows.
pre-push — a second checkpoint
#!/bin/sh
# .git/hooks/pre-push
npm testRuns before git push sends commits to a remote — a reasonable place for a slower check (like a full test suite) that would be annoying to run on every single commit but is worth enforcing before code reaches a shared branch.
commit-msg — enforcing message format
#!/bin/sh
# .git/hooks/commit-msg
if ! grep -qE "^(feat|fix|docs|chore|refactor)(\(.+\))?: .+" "$1"; then
echo "Commit message must follow Conventional Commits format."
exit 1
fiUseful for teams that rely on commit message conventions for automated changelog generation or semantic versioning.
The problem with raw hooks: they're not committed to the repo
.git/hooks/ lives inside the .git directory, which isn't tracked by Git itself — so a hook you set up locally doesn't automatically apply to anyone else who clones the repo. This is the main reason raw hooks don't scale to a team by themselves.
Husky — the standard fix
npm install -D husky
npx husky initHusky stores hook scripts inside the repo (typically in a .husky/ directory) and configures Git to use that directory instead of .git/hooks/ — so hooks get versioned, reviewed, and applied automatically for anyone who runs npm install after cloning.
# .husky/pre-commit
npx lint-stagedPairing Husky with lint-staged is the common combination — it runs lint/format only on the files actually staged for commit, rather than the entire codebase, keeping the pre-commit check fast even in large repos.
Where hooks fit vs. CI
Hooks catch problems early, on the developer's own machine, before code is even pushed — faster feedback than waiting for CI. But hooks can be bypassed (git commit --no-verify) and only run on whoever has them installed. CI is the backstop that can't be skipped. The two aren't redundant — hooks are for fast, optional-but-encouraged local feedback; CI is the actual enforcement.