Keeping code clean, consistent, and error‑free is a daily battle for any development team. Manual code reviews and ad‑hoc linting can only go so far—by the time a problem surfaces, it may already be merged. This is where Git hooks shine. A Git hook is a tiny script that runs automatically at key points in the Git workflow, giving you the power to enforce standards before code even reaches the shared repository. In this guide we’ll walk you through setting up a pre‑commit hook that runs linting, formatting, and tests, turning code‑quality checks into a seamless, automated part of every commit.
What You’ll Need
- Git (version 2.20 or newer) installed on your machine
- Access to a local clone of the repository you want to protect
- A linting/formatting tool for your language (e.g., ESLint, Prettier, Flake8)
- Node.js or the appropriate runtime for your tooling
- Basic familiarity with shell scripting (bash, sh, or PowerShell)
Step 1: Choose the Right Hook
The most common hook for code‑quality enforcement is pre-commit. It runs after you type git commit but before the commit is actually created. If the script exits with a non‑zero status, Git aborts the commit, giving you a chance to fix the issues. Other hooks like pre-push or commit-msg can be added later, but start with pre-commit for immediate feedback.
Step 2: Create the Hook Script
Navigate to the hidden .git/hooks directory in your repository and create a file named pre-commit (no extension). On Unix‑like systems you can use:
cd /path/to/your/repo cd .git/hooks touch pre-commit chmod +x pre-commit
Open the file in your editor and paste a skeleton script. Below is a bash example that runs ESLint and Prettier on staged JavaScript files, then runs the test suite:
#!/bin/bash # Exit on any error set -e # Gather a list of staged *.js and *.jsx files STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '.(js|jsx)$' || true) if [ -z "$STAGED_FILES" ]; then echo "No JavaScript files staged – skipping linting." exit 0 fi # Run ESLint echo "Running ESLint..." npx eslint $STAGED_FILES # Run Prettier in check mode echo "Running Prettier..." npx prettier --check $STAGED_FILES # Run tests (adjust command to your test runner) echo "Running test suite..." npm test # If we reach this point, all checks passed exit 0
Save the file. The set -e directive ensures the script stops at the first failing command, causing the commit to abort.
Step 3: Make the Hook Executable and Test Locally
On Windows, you might need to set the executable flag differently (e.g., using Git Bash). On macOS/Linux, the chmod +x pre-commit command we ran earlier does the job. Test the hook by making a deliberate lint error:
echo "var x = 1" > bad.js git add bad.js git commit -m "Test bad commit" # You should see ESLint or Prettier complain and the commit abort.
If the commit succeeds despite the error, double‑check that the script is executable and that you’re editing the correct .git/hooks/pre-commit file (not a copy elsewhere).
Step 4: Wire Up Lint‑Staged for Speed
Running linters on every file in the repository can be slow. The lint-staged package limits checks to only the files that are staged. Install it as a dev dependency:
npm install --save-dev lint-staged # Add a lint‑staged config to package.json
Then modify the hook script to delegate to lint-staged:
#!/bin/bash set -e npx lint-staged npm test exit 0
In package.json add:
"lint-staged": {
"*.js": ["eslint --fix", "prettier --write"]
}
Now only the staged JavaScript files are linted and formatted, keeping the pre‑commit hook snappy.
Step 5: Enforce Test Coverage Before Pushing
While a pre‑commit hook catches syntax and style issues, it won’t stop a failing test suite that only runs later. You can add a pre-push hook that runs your full test suite (or a subset) before any push reaches the remote. Create .git/hooks/pre-push with:
#!/bin/bash set -e echo "Running full test suite before push..." npm test exit 0
Make it executable (chmod +x pre-push) and you’ll get a safety net that prevents broken code from ever leaving your local machine.
Step 6: Share Hooks Across the Team
Git hooks live inside the .git folder, which means they’re not version‑controlled by default. To avoid “works on my machine” problems, use one of these strategies:
- core.hooksPath: Store hooks in a tracked directory (e.g.,
git-hooks/) and tell Git to use it:git config core.hooksPath git-hooks
- Husky: A popular Node package that installs hooks automatically when developers run
npm install. Install with:npm install husky --save-dev npx husky install
Then add a hook:
npx husky add .husky/pre-commit "npx lint-staged && npm test"
Both methods keep the hook definitions in the repository, ensuring every clone gets the same quality gate.
Common Mistakes to Avoid
1. Forgetting the executable flag – On Unix systems a non‑executable hook is ignored silently. Run chmod +x after any edit.
2. Using Windows line endings (CRLF) – Git may treat the script as binary and fail to execute. Configure your editor to use LF or run git config core.autocrlf input.
3. Committing the .git/hooks directory – Since it’s ignored by default, pushing it does nothing. Use core.hooksPath or a tool like Husky instead.
4. Running heavy tools on every commit – Linting the whole codebase slows developers down and encourages them to disable the hook. Scope checks to staged files with lint-staged.
5. Hard‑coding absolute paths – Scripts should rely on relative paths or $PWD so they work on any machine.
Tips and Tricks
• Keep hooks fast: Aim for sub‑second execution. If a check takes longer, consider moving it to pre-push instead.
• Use caching: Tools like eslint --cache store results and speed up subsequent runs.
• Provide helpful output: Echo clear messages so developers know why a commit was rejected.
• Combine with CI: Even with local hooks, keep a CI pipeline that re‑runs linting and tests to catch any bypasses.
• Version your hook scripts: Store them in a hooks/ folder and tag releases, so you can roll back if a change breaks the workflow.
Frequently Asked Questions
Can I use Git hooks with other languages besides JavaScript?
Absolutely. The hook script is just a shell script, so you can invoke any command—Python’s flake8, Ruby’s rubocop, Go’s golint, etc. Replace the npx eslint lines with the appropriate command for your stack.
What if a teammate disables the hook on their machine?
Local hooks can always be bypassed, which is why sharing them via core.hooksPath or Husky is important. Additionally, enforce the same checks in your CI pipeline; a commit that slips through locally will still be rejected on merge.
Do Git hooks work on Windows?
Yes, but you need to write the script in a language the system can execute—PowerShell, Batch, or Bash via Git Bash. Pay attention to line endings (use LF) and make sure the script’s shebang (#!/bin/bash) points to a valid interpreter.
Conclusion
Git hooks turn the moment you hit git commit into an opportunity to enforce code quality, catch bugs early, and keep your codebase pristine. By following the steps above—choosing the right hook, writing an efficient script, leveraging lint-staged or Husky, and sharing the configuration across the team—you’ll embed automated checks directly into developers’ workflows. The result? Fewer broken builds, happier reviewers, and a healthier code culture. Give it a try in your next project and watch the quality of your commits improve dramatically.
Photo by Fer Troulik on Unsplash





