Git Hooks: Automate Your Workflow So You Never Commit Broken Code Again

Git tutorial - IT technology blog
Git tutorial - IT technology blog

What Happens Without Git Hooks

I once worked on a team of 8, and every few days someone would push code that turned the CI pipeline red — either because they forgot to run the linter, or because their commit messages were things like fix, test, aaaaa. About 3–4 times per sprint. Having to leave “please reformat this” comments during code review wasted time and created unnecessary friction.

Git hooks solve exactly that — automatically blocking bad code before it enters the repo, no reminders needed.

What Are Git Hooks and Where Do They Live?

Git hooks are just ordinary scripts — bash, Python, Node.js, whatever — that Git calls automatically at specific moments: before a commit, before a push, after a merge… each event has its corresponding hook.

Every Git repo comes with a .git/hooks/ directory containing .sample template files. To activate a hook, create a file with the correct name (drop the .sample extension) and make it executable:

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

# Create a new hook
touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

Client-Side vs Server-Side Hooks

These two groups operate at completely different layers — mix them up and your setup won’t do anything.

Client-Side Hooks — Run on the Developer’s Machine

Run on each developer’s machine, before or after local operations:

  • pre-commit: runs before a commit is created — use it for linting, formatting, or running quick unit tests
  • commit-msg: runs after the commit message is entered — use it to validate format (Conventional Commits, Jira ticket IDs, etc.)
  • pre-push: runs before pushing — use it to run a more complete test suite
  • post-commit: runs after a successful commit — typically used for notifications or logging

One drawback to know upfront: .git/hooks/ is not tracked by Git, so every developer must install the hooks manually after cloning. Without a setup script, new team members will skip this step without realizing it.

Server-Side Hooks — Run on the Remote Repo

Run on the server (GitHub Actions isn’t a hook, but GitLab/Gitea self-hosted supports them):

  • pre-receive: runs before the server accepts a push — can reject the entire push
  • post-receive: runs after a successful push — commonly used for deployment or notifications
  • update: similar to pre-receive but runs per-branch

Server-side hooks can’t be bypassed by anyone — developers can’t use --no-verify to get around them. The trade-off is that you need server access. GitHub/GitLab.com don’t allow custom server hooks, so you’d have to rely on CI/CD instead.

Manual Hooks vs Husky — Which Should You Use?

This is the question I get most from junior devs setting things up for the first time: “which one should I use?” The answer really depends on your tech stack more than personal preference.

Manual Hooks: Simple, No Dependencies

Write scripts directly into .git/hooks/, or create a hooks/ directory in the repo with a setup.sh script that symlinks them:

# hooks/setup.sh
#!/bin/bash
cp hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "Git hooks installed!"

Pros: no Node.js required, no extra package dependencies, works with any language (Python, Bash, Ruby…).
Cons: you have to remember to run setup.sh after cloning, which is easy to forget.

Husky: Auto-Installs on npm install

Husky is an npm package that integrates hooks into the package.json lifecycle. When a dev runs npm install, hooks are installed automatically — no extra steps needed.

npm install --save-dev husky
npx husky init

Pros: zero-config for the team, hooks are committed to the repo (.husky/), nobody misses them.
Cons: requires Node.js and npm — adding it to a non-JS project feels like overkill.

My rule of thumb: use Husky for JavaScript/TypeScript projects, and manual hooks with a setup script in a Makefile or README for Python/Go/polyglot projects.

Practical Implementation: The 3 Most Important Hooks

1. pre-commit: Block Bad Code Before It’s Committed

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

echo "Running pre-commit checks..."

# Get list of staged Python files
STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')

if [ -n "$STAGED_PY" ]; then
  # Run flake8 linter
  echo "Linting Python files..."
  flake8 $STAGED_PY
  if [ $? -ne 0 ]; then
    echo "Linting failed. Fix errors before committing."
    exit 1
  fi

  # Run black format check
  black --check $STAGED_PY
  if [ $? -ne 0 ]; then
    echo "Format check failed. Run 'black .' to fix."
    exit 1
  fi
fi

echo "All checks passed!"
exit 0

This hook only checks staged files, not the entire repo — so it typically finishes in 1–2 seconds and doesn’t noticeably slow down your commit flow.

2. commit-msg: Enforce Commit Message Format

Conventional Commits (feat:, fix:, docs:…) enable auto-generated changelogs and make history easier to read. This hook validates the pattern:

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

MSG_FILE=$1
MSG=$(cat $MSG_FILE)

# Pattern: type(scope): description
# Example: feat(auth): add OAuth2 login
PATTERN='^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}$'

if ! echo "$MSG" | grep -qE "$PATTERN"; then
  echo "ERROR: Commit message does not match the required format!"
  echo "Expected: feat|fix|docs|style|refactor|test|chore(scope): description"
  echo "Example:  feat(auth): add OAuth2 login"
  echo ""
  echo "Your message: $MSG"
  exit 1
fi

exit 0

3. pre-push: Run Tests Before Pushing

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

BRANCH=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')

# Only run the full test suite when pushing to main or develop
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "develop" ]; then
  echo "Running test suite before push to $BRANCH..."
  python -m pytest tests/ -q --tb=short
  if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
  fi
fi

exit 0

Setup with Husky + lint-staged (for JS Projects)

npm install --save-dev husky lint-staged
npx husky init

Add to package.json:

{
  "lint-staged": {
    "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,md}": "prettier --write"
  },
  "scripts": {
    "prepare": "husky"
  }
}

File .husky/pre-commit:

#!/bin/sh
npx lint-staged

Tips to Keep Hooks From Becoming a Burden

  • Only check staged files, not the whole repo — use git diff --cached --name-only. If a hook takes more than 5 seconds, developers will find ways to bypass it.
  • Leave an escape hatch for emergencies: git commit --no-verify -m "hotfix: emergency". Sometimes you need to push fast and fix later — locking things down 100% tends to backfire more than it helps.
  • Show clear error messages on failure: don’t just exit 1, print a specific error and the command to fix it.
  • Commit the hooks/ directory to the repo along with an install-hooks.sh script — so onboarding new team members doesn’t require any extra questions.

After applying pre-commit linting and commit-msg validation to that team, the number of “fix format/lint” comments in PRs dropped from around 4–5 per PR to nearly zero. Reviews shifted almost entirely to logic and architecture. Merge conflicts also became less frequent because code was consistently formatted before merging.

Git hooks don’t replace CI/CD — integration tests and end-to-end tests still belong in the pipeline. But for simple checks that finish in a few seconds, putting them in a client-side hook saves far more time than waiting 5–10 minutes for a pipeline to tell you something’s broken.

Share: