Skip to content
ExponaBLOG
GIT & GITHUB FOR PROFESSIONAL DEVELOPERS · DAY 050 / 100

Git hooks: what every Git & GitHub developer should know

Automate checks locally.

Expona Admin6 min read

Most teams we work with catch bugs in code review that should have been caught before a pull request was even opened. Git hooks solve this by running automated checks on your machine before code ever leaves your local repository. Understanding how to set up and maintain Git hooks is essential if you want to scale development practices across a team without turning CI/CD into a bottleneck.

What are Git hooks and why they matter

Git hooks are scripts that run automatically at specific points in the Git workflow. When you commit, push, or receive updates, Git can trigger these hooks to enforce standards, run tests, or validate data. Unlike CI/CD pipelines that run after code is already in a shared repository, hooks run locally on each developer's machine, catching problems immediately and keeping your repository cleaner from the start.

The hooks Git provides live in .git/hooks/ on every repository. By default, they're disabled templates with names like pre-commit, commit-msg, and pre-push. When you enable and configure them, they become part of your development workflow, giving you the chance to reject bad commits before they ever reach your team.

Pre-commit hooks for linting and formatting

The pre-commit hook runs before your commit is finalized. This is where you catch style violations, import issues, and other fixable problems without waiting for a pull request review. We've found that running linters and formatters here saves enormous amounts of code review time because obvious issues never make it into the repository.

A basic pre-commit hook might look like this:

bash
#!/bin/bash
# .git/hooks/pre-commit

# Run eslint on staged files
npx eslint --fix --cached src/

# Run prettier for formatting
npx prettier --write --staged src/

# Exit with error if linting fails
if ! npx eslint src/; then
  echo "ESLint found issues. Commit aborted."
  exit 1
fi

exit 0

This script runs every time you commit. It automatically fixes formatting issues, runs your linter, and only lets the commit through if ESLint passes. The key is the exit codes: returning 0 allows the commit, any non-zero value rejects it.

In a real production scenario, you might want to be more selective and only lint files that were actually changed. Tools like lint-staged make this easier by running hooks only against staged files, which is faster and reduces false positives from unrelated code:

bash
#!/bin/bash
# Using lint-staged (installed via npm)

npx lint-staged

With a matching .lintstagedrc.json configuration:

bash
{
  "src/**/*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
  "src/**/*.css": ["stylelint --fix"]
}

This setup keeps hooks focused and fast because they only check what changed, not the entire codebase.

Commit-msg hooks for enforcing message standards

A commit message hook validates that your messages follow a consistent format. This matters more than many teams realize: proper commit messages make git log readable, enable automated changelog generation, and help with debugging when you need to trace why a line of code exists.

A commit-msg hook might enforce Conventional Commits format, which structures messages as type(scope): description:

bash
#!/bin/bash
# .git/hooks/commit-msg

COMMIT_MSG=$(cat $1)

# Pattern for conventional commits
PATTERN="^(feat|fix|docs|style|refactor|perf|test|chore)(\(.+\))?: .{1,}"

if ! [[ $COMMIT_MSG =~ $PATTERN ]]; then
  echo "Commit message does not follow Conventional Commits format."
  echo "Expected: type(scope): description"
  echo "Example: feat(auth): add password reset flow"
  exit 1
fi

exit 0

When someone tries to commit with a message like fix stuff, this hook rejects it and explains the required format. Over time, this standardization makes your git history a reliable source of context about what changed and why.

Sharing hooks with your team using Husky

The problem with shell scripts in .git/hooks/ is that they're not version-controlled. Each developer has to set them up manually, and updates don't propagate. This is where Husky comes in: it manages hooks as npm dependencies that live in your repository.

Setting up Husky takes just a few steps:

bash
npm install husky --save-dev
npx husky install

Then add hooks as npm scripts. For example, a pre-commit hook:

bash
npx husky add .husky/pre-commit "npm run lint:staged"

This creates a file at .husky/pre-commit that runs npm run lint:staged every time someone commits. Because it's in version control, any developer who clones the repo and runs npm install automatically gets the hook. When you update the hook, the change propagates to the whole team on their next pull.

A typical Husky setup in package.json looks like:

bash
{
  "scripts": {
    "lint": "eslint src/",
    "lint:staged": "lint-staged",
    "format": "prettier --write src/",
    "prepare": "husky install"
  },
  "devDependencies": {
    "husky": "^8.0.0",
    "lint-staged": "^13.0.0",
    "eslint": "^8.0.0",
    "prettier": "^3.0.0"
  }
}

The prepare script ensures hooks install automatically when someone clones and runs npm install. This removes friction from your development setup.

Alternative: the pre-commit framework

If your team uses Python or works in polyglot environments, the pre-commit framework offers a language-agnostic alternative. It's a Python-based tool that manages hooks defined in a YAML configuration file:

bash
# .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
  - repo: https://github.com/psf/black
    rev: 23.1.0
    hooks:
      - id: black
  - repo: https://github.com/PyCQA/flake8
    rev: 6.0.0
    hooks:
      - id: flake8

Install once with pip install pre-commit, then pre-commit install wires up the hooks. Updates to .pre-commit-config.yaml are reflected automatically. This approach works well in mixed-language teams where not everyone uses npm.

Keep hooks fast or developers will disable them

A hook that takes 30 seconds to run will become a friction point. Developers will learn to use git commit --no-verify to bypass it, defeating the purpose. Speed matters more than you might think.

Here are practical ways to keep hooks fast:

  • Only check staged files: Use lint-staged or similar tools to avoid linting your entire codebase on every commit.
  • Parallelize when possible: Run independent tools concurrently rather than sequentially.
  • Cache aggressively: Tools like ESLint and Prettier cache results. Don't clear caches unnecessarily.
  • Skip expensive checks locally: Keep type checking, comprehensive testing, and security scanning for CI. Pre-commit hooks should focus on fast, obvious problems.
  • Measure: Use time to profile your hooks. If they consistently exceed 5 seconds, you need to optimize.

A realistic pre-commit workflow on a 500-line change should take 2-3 seconds, not 30. If your hook is slow, developers will avoid it.

Common mistakes

Forgetting to make hooks executable: Shell scripts need execute permissions. If a hook isn't running, check that it has chmod +x applied. Husky handles this automatically, which is one reason it's popular.

Running the entire test suite in pre-commit: Tests belong in CI, not in hooks. Hooks should be for instant feedback on linting and formatting. Unit tests that take 20 seconds will break your workflow.

Not documenting why hooks exist: Add comments explaining what each hook does and why. When a developer hits a rejection, they should understand the rule, not just see a cryptic error.

Enabling hooks without consensus: If your team doesn't agree on standards, hooks become obstacles. Discuss hook rules during planning, not after they're already enforcing them.

Neglecting to update hooks as the project evolves: Standards change. Linter rules get stricter. Review and update your hook configuration quarterly to keep them aligned with your team's current practices.

Key takeaways

  • Git hooks automate checks before code reaches your repository, catching problems early and reducing code review burden.
  • Pre-commit hooks should run linters and formatters on staged files; commit-msg hooks validate message format.
  • Use Husky (Node.js teams) or the pre-commit framework (Python/polyglot teams) to version-control and share hooks across your team.
  • Keep hooks fast by running only on changed files and avoiding expensive operations like full test suites.
  • Document hook rules clearly and get team buy-in before enforcing them.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles