Skip to main content

What are Git hooks?

Git hooks are scripts that Git runs automatically at fixed points in its workflow, like before a commit or before a push, to enforce rules or trigger automation.

Theory

TL;DR

  • Think of hooks as checkpoints at an airport: Git pauses at specific moments, runs your script, and a non-zero exit code blocks the operation.
  • Hooks live in .git/hooks/ and only run on your machine. Teammates don't get them when they clone the project.
  • For local checks under 10 seconds (lint, tests) use hooks. For team-wide enforcement, use CI/CD.
  • git commit --no-verify bypasses pre-commit hooks, so they are not a security layer.
  • Husky lets you version hooks alongside your code and share them across a team.

Quick example

bash
# .git/hooks/pre-commit #!/bin/sh # Runs before every commit on this machine if ! npm run lint; then echo "Lint failed - fix before committing" exit 1 # Non-zero exit aborts the commit fi

Make it executable first: chmod +x .git/hooks/pre-commit. Without that step, Git skips the file with no warning and the commit goes through as if the hook doesn't exist.

How Git runs hooks

Git checks .git/hooks/ for an executable file named after the event, for example pre-commit. When you run git commit, Git spawns a shell subprocess to execute that script. Exit code 0 means continue. Anything else aborts the operation. Hooks inherit Git's environment variables like $GIT_DIR.

The four main phases:

  • pre: runs before the action (pre-commit, pre-push) and can block it
  • during: modifies the operation in progress (prepare-commit-msg, commit-msg)
  • post: runs after the action is complete (post-commit, post-merge) and cannot block anything
  • server-side: runs on the remote (pre-receive, post-receive) and requires server access

Client vs server hooks

Client hooks sit in .git/hooks/ on your local machine. Server hooks live on the remote server, for example in a self-hosted GitLab or Gitea instance. GitHub-managed repos don't give you access to server hooks.

The practical implication: anyone can bypass client hooks by deleting the file or running git commit --no-verify. For actual enforcement, configure checks in GitHub Actions or a CI pipeline instead.

When to use

  • Pre-commit linting: catch style issues before they reach the repo
  • Pre-push tests: run the test suite before the push goes out
  • Commit message validation: enforce conventional commits format via commit-msg
  • Post-merge dependency sync: run npm install automatically after pulling changes that modify package.json

Skip hooks for anything that takes more than 10 seconds locally or that the team must not be able to bypass.

Sharing hooks with Husky

The .git/ directory is not committed to the repo. That means your hooks don't transfer when a colleague clones the project. Husky fixes this by pointing Git to a versioned .husky/ directory.

bash
# Install and initialize Husky npm install husky --save-dev npx husky install # Creates .husky/ directory # Add a pre-commit hook npx husky add .husky/pre-commit "npx lint-staged"
json
// .lintstagedrc.json { "*.{js,ts,jsx,tsx}": ["eslint --fix", "git add"] }

Husky v9 simplified this further by using git config core.hooksPath .husky/ directly, dropping the npm lifecycle magic that v4 relied on.

Common mistakes

Forgetting chmod +x:

bash
# File exists but is not executable .git/hooks/pre-commit

Git skips non-executable files with no error message. The commit goes through, nothing runs, and you spend time wondering why the hook isn't working. Fix: chmod +x .git/hooks/pre-commit. This gets almost every developer at least once.

Hardcoded paths:

bash
#!/bin/sh node_modules/.bin/eslint . # Breaks on a fresh clone

Use npx eslint . or npm run lint instead. Hardcoded node_modules paths fail right after git clone before dependencies are installed.

Trying to abort from a post-hook:

bash
# post-commit npm run notify-slack || exit 1 # exit 1 has no effect here

Post-hooks run after the action is already done. A non-zero exit code there does nothing. To block an operation, use the corresponding pre-hook.

Expecting hooks to run in CI: GitHub Actions clones the repo fresh and gets no local hooks. Hooks are a local developer tool. Configure the same checks separately in your workflow file if you need them in CI.

Real-world usage

  • Husky + lint-staged (60k+ GitHub stars): runs ESLint only on staged files, standard in React and Next.js repos
  • Lefthook: YAML-configured hooks, common in Ruby/Rails projects at GitLab
  • pre-commit.com framework: multi-language hook management, used at Airbnb and in the dask project
  • commit-msg hook: validates conventional commit format before the message is saved

Follow-up questions

Q: How do you bypass a hook in an emergency?


A: Run git commit --no-verify. This skips pre-commit and commit-msg hooks. Useful for WIP commits or when a broken hook is blocking the whole team.

Q: What is the difference between pre-commit and commit-msg hooks?


A: pre-commit runs before Git opens the editor for the commit message. commit-msg runs after you write the message but before the commit is saved. Use pre-commit to validate code, commit-msg to validate message format.

Q: Why don't hooks run in CI pipelines?


A: CI runners clone the repo fresh. The .git/hooks/ directory is local-only and not in version control. Hooks only exist on machines where someone explicitly set them up.

Q: How does Husky v9 differ from v4?


A: Husky v4 stored config in package.json and installed hooks via npm lifecycle scripts. v9 uses git config core.hooksPath .husky/ directly and requires an explicit husky install call. The setup is more transparent and easier to debug.

Q: If both pre-commit and prepare-commit-msg hooks exist, which runs first?


A: Git runs them in a fixed order: pre-commit, then prepare-commit-msg, then commit-msg, then post-commit. You cannot change this sequence. If both hooks modify the same state, you need to account for the order manually or use a tool like Lefthook to coordinate them.

Examples

Basic: lint only staged files

bash
#!/bin/sh # .git/hooks/pre-commit (chmod +x required) # Checks only staged JS files, not the entire project changed_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$') for file in $changed_files; do npx eslint "$file" || exit 1 done

This is faster than eslint . on large repos because it only checks files staged for this commit. If any file fails, the loop exits with code 1 and Git aborts. On a clean project with no staged JS files, the loop runs zero times and exits 0.

Intermediate: Husky + lint-staged in a Next.js project

bash
# .husky/pre-commit #!/bin/sh . "$(dirname "$0")/_/husky.sh" npx lint-staged
json
// .lintstagedrc.json { "*.{js,ts,jsx,tsx}": ["eslint --fix", "git add"], "*.{css,scss}": ["stylelint --fix", "git add"] }

lint-staged auto-fixes issues and re-stages the corrected files. If a file can't be fixed automatically, the commit aborts. The .husky/pre-commit file is tracked by Git, so every developer who clones the repo and runs npm install gets the same checks automatically, because the prepare script triggers husky install.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?