News
🌿 Git Tutorials Automate Code Quality Checks with Git Hooks

Automate Code Quality Checks with Git Hooks

Run linters, tests, and formatters automatically before every commit or push — without relying on anyone remembering to do it manually.

Git hooks are scripts that run automatically at specific points in the Git workflow. A pre-commit hook that runs your linter means a commit with failing checks is physically impossible, not just discouraged. No CI pipeline failure, no "oh I forgot to lint" — the problem never leaves the developer's machine.


Where hooks live

Every Git repository has a .git/hooks/ directory with sample scripts:

ls .git/hooks/
applypatch-msg.sample  commit-msg.sample  pre-commit.sample  ...

To activate a hook, create a file with the exact hook name (no .sample extension) and make it executable. The script can be any executable — bash, Python, Node.


A pre-commit hook

The most useful hook. Runs before the commit is created. A non-zero exit code aborts the commit.

# .git/hooks/pre-commit
#!/bin/sh
set -e

echo "Running linter..."
npm run lint

echo "Running type check..."
npm run typecheck

Make it executable:

chmod +x .git/hooks/pre-commit

Now every git commit runs the linter and type checker first. If either fails, the commit is blocked and the output tells you exactly what is wrong.


A commit-msg hook

Validates the commit message format. Useful for enforcing Conventional Commits or JIRA ticket references:

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

commit_msg=$(cat "$1")
pattern="^(feat|fix|docs|chore|refactor|test|style)(\(.+\))?: .{1,72}"

if ! echo "$commit_msg" | grep -qE "$pattern"; then
  echo "Error: commit message does not follow Conventional Commits format."
  echo "Expected: type(scope): description"
  echo "Example:  feat(auth): add OAuth2 login support"
  exit 1
fi

$1 is the path to the file containing the commit message. The script reads it and validates the format. A rejected commit returns the developer to their editor to fix the message.


A pre-push hook

Runs before git push. Useful for running the full test suite, which may be too slow to run on every commit:

# .git/hooks/pre-push
#!/bin/sh
set -e

echo "Running tests before push..."
npm test

The developer can still commit freely, but cannot push broken code.


The problem: hooks do not sync with the repo

.git/hooks/ is not tracked by version control. If you add a hook, a teammate cloning the repo does not get it. Two solutions:

Option 1: Store hooks in the repo and configure Git to use them

Create a hooks/ directory in your project root (tracked by Git) and configure Git to look there:

mkdir hooks
git config core.hooksPath hooks

Or set it globally for all repos:

git config --global core.hooksPath ~/.git-hooks

Add your hooks to hooks/pre-commit, commit the directory, and everyone who runs git config core.hooksPath hooks gets them. Document this step in your README.

Option 2: Use a hook manager

Tools like husky (Node.js ecosystem) or pre-commit (Python, works with any language) manage hooks as part of the project's dependency setup:

npm install --save-dev husky
npx husky init

Hooks become part of package.json and run automatically when a developer installs dependencies. The trade-off: one more tool in the project.


Bypass a hook when necessary

Hooks can be bypassed with --no-verify:

git commit --no-verify -m "WIP: temporary hack"
git push --no-verify

This is an escape hatch for legitimate situations (committing broken work to a personal branch, committing generated files that fail the linter). It should be used deliberately, not as a habit. If teammates are using --no-verify regularly, the hooks are probably too strict or too slow.


Useful patterns

Run only on staged files (much faster than linting everything):

# .git/hooks/pre-commit
#!/bin/sh
staged=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')
if [ -n "$staged" ]; then
  echo "$staged" | xargs npx eslint
fi

Format staged files automatically and re-add them:

# .git/hooks/pre-commit
#!/bin/sh
staged=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
if [ -n "$staged" ]; then
  echo "$staged" | xargs black
  echo "$staged" | xargs git add
fi

Auto-formatting and re-adding means the developer never sees a "formatted files need to be staged" failure — the hook handles it silently.