Step-by-step
-
1
Understand the raw hook approach
Every Git repository has a
.git/hooks/directory with sample scripts. Create an executable file namedpre-committhere and Git will run it before each commit. The critical limitation:.git/is never committed, so the hook only lives on your machine and is not shared with the team.bashls .git/hooks/ # pre-commit.sample commit-msg.sample post-merge.sample ... # Create a raw hook (not the recommended long-term approach) cp .git/hooks/pre-commit.sample .git/hooks/pre-commit chmod +x .git/hooks/pre-commit -
2
Write a raw bash hook that blocks on errors
A pre-commit hook is a plain shell script. Exit
0to allow the commit; exit anything else to abort it. This example runs ESLint on every staged.jsfile and blocks if any errors are found.bash#!/bin/bash # .git/hooks/pre-commit STAGED_JS=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.js$') if [ -n "$STAGED_JS" ]; then echo "Running ESLint on staged files..." npx eslint $STAGED_JS if [ $? -ne 0 ]; then echo "ESLint failed. Commit aborted." exit 1 fi fi exit 0 -
3
Install Husky to commit hooks with the repo
Husky stores hooks in a
.husky/directory that is committed, so every developer gets them automatically when they clone or pull. It is the industry standard for Node.js projects.bashnpm install --save-dev husky # Initialise Husky (creates .husky/ and adds prepare script to package.json) npx husky-init # After init, install dependencies to trigger the prepare script: npm install -
4
Configure the Husky pre-commit hook
After
husky-init, a file.husky/pre-commitis created with a placeholder. Replace the placeholder with your actual command. This file is committed and shared with the entire team.bash# .husky/pre-commit #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npx lint-staged -
5
Add lint-staged to lint only changed files
Running your linter against the entire codebase on every commit is slow and produces noise from files you didn't touch.
lint-stagedruns linters only on the files that are actually staged — keeping the hook fast and the feedback relevant.bashnpm install --save-dev lint-staged -
6
Configure lint-staged in package.json
Add a
lint-stagedkey topackage.json. Each key is a glob pattern; the value is an array of commands to run on matched staged files. Prettier runs first (format), then ESLint (check for errors).bash// package.json { "lint-staged": { "*.{js,ts,tsx,jsx}": [ "prettier --write", "eslint --fix" ], "*.{css,scss}": [ "prettier --write" ], "*.php": [ "./vendor/bin/phpcs --standard=PSR12" ] } } -
7
Test the hook and bypass when necessary
Stage a file with a lint error and run
git commit— the hook should block it. If you ever need to skip the hook legitimately (e.g. committing a temporary debug file during an incident), use--no-verify. Use this sparingly; making a habit of it defeats the purpose.bash# Normal commit — hook runs automatically git add src/broken.js git commit -m "test: trigger the hook" # ✘ ESLint: 3 errors found. Commit aborted. # Skip the hook — only when you genuinely mean it git commit --no-verify -m "chore: temp debug commit" -
8
Keep the hook under three seconds
Developers will bypass a hook that takes more than a few seconds.
lint-stagedalready helps by limiting scope to staged files. Additional tips: run linters in parallel where possible, disable type-checking in the pre-commit hook (runtsc --noEmitin CI instead), and avoid installing packages inside the hook script.bash# Time your hook to confirm it's fast time git commit --allow-empty -m "timing test" # If still slow, run tsc type-checking only in CI, not in the hook: # In CI (.github/workflows/ci.yml): # - run: npx tsc --noEmit
Tips & gotchas
- Hooks in <code>.git/hooks/</code> are never cloned or pushed — always use Husky (or a similar tool like <code>lefthook</code>) to share hooks with your team.
- Add <code>node_modules/.bin</code> to the PATH inside your hook script so you can call <code>eslint</code> instead of <code>./node_modules/.bin/eslint</code>.
- For PHP projects without Node, use <a href="https://github.com/captainhookphp/captainhook" target="_blank" rel="noopener noreferrer">CaptainHook</a> — it does the same thing with a JSON config file.
- Run <code>git stash --include-untracked</code> at the start of your hook and <code>git stash pop</code> at the end if you need to lint a clean working tree (not just staged files).
Wrapping up
The raw .git/hooks/pre-commit approach is fine for solo projects; use Husky + lint-staged the moment you have a team. The three-second rule is non-negotiable: a hook that developers bypass because it's slow is worse than no hook at all. Keep the hook focused on fast, local checks (format + lint) and push the slow checks (type-checking, full test suite) to CI where they belong.