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.
1
pre-commit
2
commit-msg
3
pre-push
4
CI
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.
Server-side hooks: the ones that can't be bypassed locally
Everything above runs on the developer's own machine, which is exactly why it can be skipped with --no-verify. Git also supports server-side hooks (pre-receive, update, post-receive) that run on the remote when a push is received — these genuinely cannot be bypassed by the person pushing, since they execute on infrastructure the pusher doesn't control:
#!/bin/sh
# pre-receive, on the git server
while read oldrev newrev refname; do
if git log "$oldrev..$newrev" --format=%s | grep -qi "wip"; then
echo "Rejected: commit messages containing 'wip' aren't allowed on this branch."
exit 1
fi
doneSelf-hosted Git servers (a bare repo on a company server, GitLab self-managed) can use these directly. Hosted platforms like GitHub don't expose raw server-side hooks to repo owners — the equivalent enforcement there is branch protection rules and required CI status checks, which serve the same "can't be bypassed by the pusher" purpose through a different mechanism.
A minimal, real lint-staged config
Pairing Husky with lint-staged needs a small config specifying which command runs against which staged file types — worth seeing concretely rather than just described:
// package.json
{
"lint-staged": {
"*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
}eslint --fix and prettier --write both mutate the staged files in place, then lint-staged re-stages the fixed versions automatically — so a commit that would have failed on a formatting issue instead just gets auto-fixed and committed cleanly, with no back-and-forth needed from the developer for anything auto-fixable.
Skipping a hook deliberately, when it's actually appropriate
--no-verify isn't purely an anti-pattern — there are legitimate cases for it, like committing a work-in-progress snapshot to a personal branch before switching tasks, where the usual lint/test bar isn't the point of that particular commit:
git commit --no-verify -m "wip: partial refactor, will clean up"The distinction that matters: using it deliberately and occasionally, for commits that are explicitly not meant to meet the normal bar, is fine. Reaching for it habitually because hooks feel slow or annoying defeats their entire purpose — if that's happening regularly, the actual fix is speeding up the hook (see the lint-staged pattern above), not routing around it by default.
Hooks are one layer of a bigger local-workflow picture — clean commit history often comes from combining hooks with disciplined use of git rebase before opening a PR, and the same "fail fast, cheaply" instinct behind pre-commit checks is exactly what a good GitHub Actions pipeline does at the CI stage.
Common mistakes
- Putting a slow full test suite in
pre-commitinstead ofpre-push. A commit that takes 30+ seconds to complete trains people to commit less often (in bigger, riskier chunks) or to reach for--no-verifyout of habit — reserve the slowest checks forpre-push. - Relying on raw
.git/hooks/scripts for anything the whole team needs. They live outside version control by design, so a hook only one person set up locally silently doesn't apply to anyone else's clone. - Writing a hook that fails silently instead of with a clear message and non-zero exit code. If a hook doesn't explicitly
exit 1on failure, Git treats it as passing regardless of what actually happened. - Forgetting hooks need to be executable. A hook script that's correct but not
chmod +x'd simply doesn't run, with no error to indicate why.
Related reading
- Git Rebase vs. Merge: When to Actually Use Each — shares tags: git, devops (same category).
- CI/CD Pipelines Explained: From Commit to Production — shares tags: devops, git.
- Understanding Cloud Cost Optimization Basics — shares tags: devops, productivity.
- Big O Notation Without the Math Panic — shares tags: productivity.
- GitHub Actions Reusable Workflows: Build CI/CD Templates Once, Use Everywhere — shares tags: git, devops.