Git hooks for code quality: catching issues before they escape

Published on by

There's a particular kind of frustration that comes from pushing code, feeling good about your progress, then watching CI fail on a missing semicolon. It's the development equivalent of tripping over your own shoelaces – embarrassing and entirely preventable.

I've been thinking about this problem more as AI coding assistants become part of my daily workflow. My IDE does a decent job catching obvious mistakes when I'm coding alone, but AI-generated code introduces a new category of confidence problem. Claude might write syntactically perfect code that completely ignores my project's conventions, or suggests changes that break subtle invariants I've forgotten to document.

Git hooks have become my solution for this. They create a consistent quality gate that applies whether the code comes from me, an AI assistant, or any other team member. This was as much about learning to work effectively with AI tools as it was about maintaining code quality, but it turned out to be a useful addition to my development process.

What makes quality gates actually work?

Git hooks function as automated quality gates, running checks at specific points in your workflow. The most valuable trigger points I've found are pre-commit and pre-push – before changes get committed and before they get shared with others.

The critical constraint is speed. Hooks that take more than 10 seconds become roadblocks, and developers start reaching for --no-verify like it's an emergency exit. You have about 10 seconds to catch the most important problems, which forces some interesting prioritization decisions.

Setting up JavaScript projects with Husky

For JavaScript projects, I use Husky because it handles git hooks with minimal configuration overhead. Here's the setup I'm using on graph-gizmo, configured through package.json:

{
  "scripts": {
    "test": "jest",
    "lint": "eslint src/ --ext .js,.ts",
    "format": "prettier --write src/",
    "type-check": "tsc --noEmit"
  },
  "husky": {
    "hooks": {
      "pre-commit": "npm run lint && npm run type-check",
      "pre-push": "npm test"
    }
  }
}

The logic is straightforward: catch syntax errors before they get committed, catch logic errors before they get shared. I've found this pattern works particularly well for trunk-based development, where you want to commit frequently with confidence that basic quality checks pass, while ensuring shared code meets consistent standards.

Go projects with pre-commit

For Go projects, I prefer pre-commit over language-specific tools. Here's the setup I'm using on markdown-reader-mcp, configured through .pre-commit-config.yaml:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.4.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
      - id: check-merge-conflict

  - repo: https://github.com/dnephin/pre-commit-golang
    rev: v0.5.1
    hooks:
      - id: go-fmt
      - id: go-vet-mod
      - id: go-mod-tidy

  - repo: local
    hooks:
      - id: go-test-short
        name: go test (short)
        entry: go test -short ./...
        language: system
        pass_filenames: false

This covers Go formatting, vetting, and dependency cleanup, plus basic file hygiene. The go test -short flag runs unit tests but skips the slower integration tests that I leave for the CI pipeline.

The ten-second prioritization problem

That ten-second constraint forces some interesting decisions about what to include. You can't run everything, so you focus on catching the most embarrassing mistakes first. Syntax and formatting checks run almost instantly and catch obvious errors. Type checking prevents many runtime errors in typed languages. Fast unit tests validate core logic without external dependencies.

The expensive stuff – integration tests, security scans, performance benchmarks – stays in the CI pipeline where it doesn't interrupt the development flow. It's a deliberate trade-off between speed and coverage.

Why this works well for trunk-based development

I've found git hooks align particularly well with trunk-based development patterns. When everyone commits directly to main, you need confidence that each commit maintains basic quality standards without slowing down the development rhythm.

Pre-commit hooks provide that confidence by catching issues immediately, while pre-push hooks ensure that only tested code reaches the shared repository. This creates a natural development rhythm: commit frequently with basic quality assurance, push when ready to share tested changes with the team.

The AI code validation challenge

AI assistants generate increasingly sophisticated code, but they can't infer your project's specific conventions without explicit guidance. Claude might write syntactically perfect code that completely ignores your style guide, or suggest changes that introduce subtle type errors in complex codebases.

I've started treating AI output like code from any new team member: potentially useful, but requiring validation. Git hooks provide that validation automatically, catching formatting inconsistencies, missing test coverage, and violations of project-specific practices.

The key insight is applying the same standards consistently – no special treatment for AI-generated code. If the hooks fail, the work isn't done, regardless of whether the code came from me, Claude, or anyone else on the team.

The secondary benefit: better CI feedback

The most obvious benefit is fewer embarrassing CI failures from preventable issues. When local hooks catch formatting problems, type errors, and failing tests, your CI pipeline can focus on the complex stuff – cross-platform compatibility, security scanning, deployment verification.

But there's a secondary benefit I didn't expect: better signal-to-noise ratio in CI feedback. When the CI pipeline fails, it's usually for something that actually matters, not because someone forgot to run the linter. This makes the whole team more likely to pay attention to CI failures when they do occur.

How I actually implement this

I start simple and build up gradually. For new projects, I begin with formatting and linting – these catch the most common issues with minimal setup overhead. Then I add type checking to prevent runtime errors, followed by fast tests that complete in under 5 seconds.

The key is tuning based on what actually gets used. If people regularly bypass your hooks with --no-verify, your configuration is the problem, not the people. I've learned to adjust based on team feedback rather than doubling down on overly strict rules.

The documentation side effect

One unexpected benefit: well-configured git hooks serve as living documentation of your project's quality standards. New team members can look at the pre-commit configuration and immediately understand what matters for this codebase. In polyglot environments where different projects use different tools and standards, the hooks make those expectations explicit and automatically enforced.

Git hooks aren't magic – they won't solve every quality problem you have. But they'll prevent the obvious mistakes from escaping into shared code and keep your CI pipeline focused on issues that actually require human attention. Sometimes preventing the obvious problems is enough to make everything else manageable.

This was as much about learning to work more effectively with AI-generated code as it was about team development practices. Just don't expect it to transform your entire development workflow – sometimes the most useful tools are the ones that quietly remove friction from daily work.