Ship Cleaner Code Faster: A Practical Guide to Using Git Hooks Without CI
Use Git hooks to run linters, tests, and formatting locally so bad commits never reach your repos — practical setup and real-world tips.
Written by: Arjun Malhotra
Ever committed tiny style fixes, pushed, and watched the CI fail for something your editor could have caught? That slow loop — commit, push, CI fail, fix, push again — costs time and focus. What if your machine caught predictable issues before they ever hit the remote? That’s where Git hooks quietly earn their keep.
Git hooks are small scripts that run at specific points in the git lifecycle: before a commit, after a merge, before a push, and so on. They sit on your machine and guard the door. When set up thoughtfully, git hooks reduce noisy PRs, prevent regressions, and keep teams aligned on formatting and basic checks — all without touching your CI pipeline.
Why Git hooks matter (and when they’re a bad fit)
Hooks are a simple, low-friction way to stop common problems at the source. Imagine running a formatter automatically before every commit so you never submit code with mixed indentation or trailing whitespace again. Or imagine running a quick subset of tests that catch obvious failures, saving CI minutes and your teammates’ time.
They’re especially useful when:
- You want consistent formatting across a team without relying on each developer to remember to run a tool.
- You need a fast local check that’s quicker than CI (linting, basic static analysis).
- You want to block sensitive files or credentials from being committed.
That said, hooks aren’t a replacement for CI. They run locally and can be bypassed. If a developer deliberately disables them or clones a repo without installing them, the protection vanishes. Heavy or long-running checks (full test suites, integration tests) still belong in CI. Treat hooks as the first line of defense — fast, helpful, and convenient.
Common hooks and practical uses
Here are the hooks people actually use and why they work:
- pre-commit: Runs right before a commit is finalized. Ideal for running formatters (Prettier, Black), linters, and small static checks. Because it runs locally, keep it fast (under a few seconds).
- commit-msg: Validates the commit message. Great for enforcing conventional commits, JIRA keys, or a ticket reference pattern.
- pre-push: Runs before a push is sent to the remote. Use it for slightly longer checks than pre-commit, like running unit tests that are quick or verifying branch naming rules.
- post-merge / post-checkout: Useful for tasks that should run when code arrives locally — like installing dependencies, regenerating code, or updating submodules.
Use the keyword “Git hooks” to automate formatting and linting on every commit, and you’ll notice fewer trivial PR comments and quicker reviews. But run the heavier checks in pre-push rather than pre-commit to avoid slowing down the edit-commit loop.
How to Actually Start: a pragmatic setup that won’t drive anyone nuts
If you’ve never used hooks, the default approach (editing .git/hooks manually and committing them) quickly becomes unmanageable across a team. Here’s a practical, modern workflow using tools that make installation consistent and frictionless.
-
Use a tool to manage hooks
- Start with a lightweight manager like pre-commit (Python) or Husky (Node). They allow you to declare hooks in a config file inside the repo, and they provide an easy install step for contributors.
- Why use a manager? Because it standardizes installation and lets you store hook configs in version control. You avoid “it worked on my machine” problems.
-
Pick fast, deterministic tasks for pre-commit
- Formatters: Black (Python), Prettier (JS/TS), gofmt (Go). These are deterministic — run them automatically and stash the formatted files into the commit.
- Linters: Quick static checks like eslint with only core rules enabled, or flake8 with a short timeout.
- Small codegen or license checks: things that are quick to run and frequently missed.
-
Keep heavy tasks in pre-push
- Run a fast subset of tests (unit tests only, or tests tagged as “fast”).
- Run type-checkers with incremental mode (mypy —incremental, or tsc —noEmit with isolated modules).
- Avoid full integration suites here; push should still be reasonably fast.
-
Enforce commit-message style with commit-msg
- Use commitlint or a custom regex to require ticket IDs or conventional commit types.
- This reduces noisy PR titles and helps changelogs.
-
Make install painless
- Add a simple setup script (setup.sh or a Makefile target) that installs hook manager and runs its install step.
- Document a single command to run after cloning: something like ./dev-setup.sh or make dev-setup.
- Consider adding an automated prompt in a project’s scaffold or CI that fails early if hooks are not installed.
-
Handle bypasses gracefully
- Educate the team: explain why hooks exist and show how to run them manually.
- Log helpful messages when hooks fail, including commands to reproduce locally.
- Resist making hooks too strict at first — iterate based on friction and team feedback.
Example pre-commit config snippet (conceptual):
- pre-commit runs: black, isort, flake8 (Python)
- commit-msg: commitlint with conventional commits
- pre-push: pytest -k “not slow” —maxfail=1
Real-world tips that save time
- Toggle fast vs. strict: Start with non-blocking hooks that warn instead of fail, then move to blocking when the team is on board. Warnings build trust.
- Version your tooling: Commit a .tool-versions or similar so everyone uses the same formatter/linter versions. Otherwise “it passed for me” becomes a frequent excuse.
- Cache where possible: Use local caches for linters/tests. Many tools support caches that dramatically reduce runtime.
- Keep logs useful: When a hook fails, show the minimal command to reproduce and a short explanation. Developers hate opaque errors.
- Avoid per-developer configuration in hooks: They should be deterministic and not depend on personal dotfiles or global tools.
- Consider editor integrations: If your editor runs the same formatters automatically on save, pre-commit becomes mostly a safety net rather than the main mechanism. That’s fine — redundancy is okay as long as it’s fast.
What to watch out for
- Performance creep: Over time, teams add more checks and pre-commit becomes slow. Monitor hook execution times and refactor: move slow checks to pre-push or CI.
- False sense of security: Hooks reduce but don’t eliminate mistakes. Keep CI and code review as the authoritative safety nets.
- Platform differences: Be mindful of Windows vs macOS/Linux differences in scripts. Use cross-platform tools or containerized checkers when necessary.
- Bypass options: People can use git commit —no-verify. If you must forbid bypasses, combine hooks with server-side checks or branch protections, but do so slowly — heavy-handed enforcement can create resentment.
Wrapping up
Git hooks are deceptively simple and surprisingly powerful. They keep trivial mistakes out of your repo, standardize formatting, and catch obvious failures locally — saving CI minutes and reviewer time. Start small: pick a hook manager, run fast formatters on pre-commit, push heavier checks to pre-push, and make installation a single documented step. With a little care you’ll get quieter PRs, faster feedback, and fewer “oops” moments.
Try adding one formatter and one linter to pre-commit this week. Watch the noise drop and your diffs get cleaner. Once that becomes habit, you can expand responsibly — the payoff is consistently better code and fewer interruptions to flow.