CI catches broken tests, but only after your push. By then, the bad commit is on the remote, the team's CI minutes are spent, and your build is the broken one in the dashboard. Git hooks are scripts Git runs automatically at specific points in your workflow — and the pre-push hook is exactly the gate you want before your laptop ships code to GitHub. This lesson covers raw Git hooks, the team-friendly Husky toolkit that makes them shareable, and the pragmatic question of "what's worth running locally vs leaving to CI."
What hooks are
A Git hook is a shell script that lives in .git/hooks/ and runs automatically when a specific Git event happens. The events have fixed names:
pre-commit— runs beforegit commitfinalises. If the script exits non-zero, the commit is aborted.commit-msg— runs after the message is written, before the commit is recorded. Used to enforce message format.pre-push— runs beforegit pushsends commits to the remote. Non-zero exit blocks the push.post-merge,post-checkout, etc. — fire after the matching events.
Look in your repo:
ls .git/hooks/applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
pre-push.sample
pre-rebase.sample
prepare-commit-msg.sample
push-to-checkout.sample
update.sample
Every file ends in .sample — Git ships templates but doesn't activate them. To activate, drop the .sample extension and make the file executable. From here, "the hook runs" means: Git invokes the script with arguments and reads its exit code.
A first pre-push hook
Want to run a quick smoke test before every push? Edit .git/hooks/pre-push (no extension):
#!/bin/sh
echo "Running smoke tests before push..."
npx cypress run --spec "cypress/e2e/smoke/**/*"
if [ $? -ne 0 ]; then
echo "❌ Smoke tests failed. Push aborted."
exit 1
fi
echo "✅ Smoke tests passed. Pushing..."
exit 0Make it executable:
chmod +x .git/hooks/pre-pushTry a push:
git pushRunning smoke tests before push...
[Cypress runs your smoke suite...]
❌ Smoke tests failed. Push aborted.
error: failed to push some refs
The push is blocked. Fix the test, push again. Smoke tests now run automatically — no more "I forgot to test before pushing."
The catch: hooks aren't versioned
The .git/ folder is not part of your repository. It's a local artefact created by Git on your machine. Your hand-rolled pre-push hook lives only on your laptop — your teammates' clones don't have it, and there's no git add that would change that.
You could write a README saying "everyone please copy this script into .git/hooks/pre-push" — and people forget on day one. The robust answer is Husky.
Husky — version-controlled hooks for the team
Husky is an npm package that solves the "hooks aren't shared" problem. It stores hooks in a tracked folder (.husky/) and configures Git to run them.
Install in your test repo:
npm install --save-dev husky
npx husky inithusky - install command is deprecated
husky - created .husky/
husky - created .husky/pre-commit
Two things just happened:
- A
.husky/folder appeared at the repo root. It IS tracked by Git. - A
preparescript was added topackage.jsonso futurenpm installs configure Husky for new clones automatically.
Edit .husky/pre-commit (or create .husky/pre-push):
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run smokeCommit and push the .husky/ folder. Anyone who clones the repo and runs npm install automatically gets the same hook. No README, no manual setup.
package.json should have a corresponding script:
{
"scripts": {
"smoke": "cypress run --spec 'cypress/e2e/smoke/**/*'",
"prepare": "husky"
}
}Useful hooks for test projects
Three patterns most QA teams settle on:
pre-commit — fast static checks
Run things that take seconds and catch silly mistakes:
# .husky/pre-commit
npm run lint
npm run typecheckESLint catches unused variables and obvious typos; TypeScript's tsc --noEmit catches type drift. Both run in 2-5 seconds on a touched-files-only setup.
pre-push — smoke tests
Run your fastest meaningful end-to-end tests:
# .husky/pre-push
npm run smoke10-30 seconds is the sweet spot. Faster, and it's not catching anything; slower, and people will start --no-verify-ing past it.
commit-msg — enforce conventions
If your team uses Conventional Commits (feat:, fix:, chore:), a commit-msg hook with commitlint rejects malformed messages:
# .husky/commit-msg
npx --no -- commitlint --edit "$1"lint-staged — only check what you changed
The friction with linters is "running on the whole repo every commit is slow." lint-staged runs them only on files you've staged:
npm install --save-dev lint-stagedIn package.json:
{
"lint-staged": {
"*.{js,ts}": "eslint --fix",
"*.{json,md}": "prettier --write"
}
}In .husky/pre-commit:
npx lint-stagedNow pre-commit only lints the two files you actually edited, in 200ms instead of 12 seconds.
What NOT to put in a local hook
The temptation is to run "everything" before push. Resist:
- The full e2e suite — 10 minutes is too long. People will work around the hook (
git push --no-verify) and the safety vanishes. - CI-only checks — anything that needs cloud secrets, a specific OS, or a clean container belongs in CI, not on each developer's laptop.
- Network-dependent tests — flaky in cafés, slow on hotel WiFi.
Local hooks should be fast and deterministic. CI is for the heavy stuff. The split looks like:
| Layer | What runs | Time budget |
|---|---|---|
pre-commit | Linter, type-check, formatter on staged files | < 5 seconds |
pre-push | Smoke tests (10-30 of your most critical tests) | < 30 seconds |
| CI on PR | Full unit + integration suite, lint over whole repo | 5-10 minutes |
| CI on merge | Full e2e suite, performance checks, builds | 15-30 minutes |
The --no-verify escape hatch
Every Git hook has the same bypass:
git commit --no-verify
git push --no-verifyBoth flags skip hooks entirely. Sometimes legitimately useful — your machine can't run the hook (Cypress isn't installed yet), or you're pushing a docs-only change. Don't make it a habit. A hook that gets --no-verify'd every time isn't doing its job; either the hook is too slow, or the team doesn't agree with it. Address the cause, not the symptom.
A pre-push flow at a glance
Step 1 of 5
git push
Developer runs git push origin feature/checkout-tests.
A real QA scenario — adding a smoke gate to a Cypress repo
Your team's Cypress repo just merged its 200th flaky push. You volunteer to add a smoke gate.
git switch -c chore/add-husky-presmoke
npm install --save-dev husky
npx husky init
echo 'npx cypress run --spec "cypress/e2e/smoke/**/*"' > .husky/pre-push
git add .husky package.json package-lock.json
git commit -m "Add pre-push hook to run smoke suite locally"
git push -u origin chore/add-husky-presmokeOpen the PR. Add a description that explains what runs, how long it takes, and how to bypass it for emergencies (--no-verify). Once merged, every teammate's next npm install activates the hook automatically. The 200th push that would have broken main is now caught on someone's laptop instead.
⚠️ Common mistakes
- Putting hooks in
.git/hooks/and expecting teammates to have them. That folder is local-only. Always use Husky (or another hook manager) for anything you want shared with the team. - Running the whole test suite on
pre-push. A 10-minute hook is a--no-verifyfactory. Pick a smoke subset; let CI run the rest. Smaller, faster gates with high signal beat slow gates that get bypassed. - Forgetting the executable bit. Manual hooks at
.git/hooks/pre-pushneedchmod +x. If the file isn't executable, Git silently skips it — you'll think the hook works and it isn't running at all. Husky handles this for you, which is another reason to prefer it.
🎯 Practice task
Set up a real pre-push hook on a project. 25-30 minutes.
- In a Node-based repo (your
qa-sandboxinitialised withnpm init -y, or a real test repo), install Husky:npm install --save-dev husky && npx husky init. - Confirm
.husky/was created and thatpackage.jsonhas apreparescript. - Create
.husky/pre-push(overwrite the default) with one line:echo "pre-push hook running" && exit 0. Stage and commit. - Run
git push. Confirm you see "pre-push hook running" in the output. - Replace the script with a deliberately failing command:
exit 1. Try to push. Confirm Git blocks the push. - Replace with something useful —
npm run lint, or a fake test command likenode -e "console.log('smoke ok')". Confirm it runs. - lint-staged stretch: install
lint-stagedand configure it inpackage.jsonto format*.jsonfiles with Prettier onpre-commit. Edit a JSON file with bad formatting, commit, and confirm the formatter ran. - Real stretch: add a
pre-pushhook to a real test project that runs your smoke suite. Time it. If it's > 30 seconds, prune the smoke set until it isn't.
That closes Chapter 5. You now know how to keep the repo clean, manage test data thoughtfully, and gate code locally before it ships. The capstone in Chapter 6 stitches it all together into a single end-to-end project.