Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Git Hooks & Automation

Git hooks are scripts that run automatically every time a particular event occurs in a Git repository. They are the “event listeners” of the Git world — invisible quality gates that catch mistakes before they reach the codebase.

What are Git Hooks?

Hooks are stored in the .git/hooks directory. They are executable scripts (bash, Python, Node, anything your system can run) that Git invokes at specific points in its workflow. If a hook exits with a non-zero status, the Git action is aborted — this is what makes hooks powerful as quality gates. Think of hooks like the bouncer at a nightclub door: they check your commit before it gets in, and if something is wrong (failing tests, bad formatting, missing JIRA ticket), you are turned away until you fix it.

Common Hooks

HookWhen it runsUse Case
pre-commitBefore git commit executesLinting, formatting, running fast unit tests. Catch issues before they enter history.
commit-msgAfter you write the messageEnforcing commit message patterns (e.g., JIRA-123: ... or Conventional Commits format).
pre-pushBefore git push sends dataRunning integration tests, preventing accidental pushes to main/production branches.
prepare-commit-msgBefore editor opensAuto-populating commit messages with branch name or ticket number.
post-mergeAfter git merge completesAuto-running npm install when package.json changes after pulling.

Creating a Hook (Manual)

Create a file .git/hooks/pre-commit:
#!/bin/sh
# This script runs BEFORE every git commit.
# If it exits with non-zero, the commit is aborted.
echo "Running pre-commit checks..."

# Run linter -- catches style violations and common errors
npm run lint

# $? is the exit code of the last command.
# 0 = success, anything else = failure.
if [ $? -ne 0 ]; then
    echo "Linting failed! Commit aborted. Fix the issues above and try again."
    exit 1  # Non-zero exit = abort the commit
fi

# Optional: run fast unit tests (skip slow integration tests here)
npm run test:unit -- --bail
if [ $? -ne 0 ]; then
    echo "Unit tests failed! Commit aborted."
    exit 1
fi
Make it executable (required on macOS/Linux; Windows Git Bash handles this differently):
chmod +x .git/hooks/pre-commit
The sharing problem: Hooks live in .git/hooks/, which is NOT tracked by Git. This means your teammates do not get your hooks when they clone the repo. This is exactly why tools like Husky exist — they store hooks in committed files and configure Git to use them automatically.

Automating with Husky (JavaScript/Node)

Sharing hooks via .git/hooks is hard because that directory is not committed to version control. Husky solves this by storing hooks in a .husky/ directory (which IS committed) and configuring Git’s core.hooksPath to point there during npm install. Every developer who runs npm install gets the hooks automatically — no manual setup.

1. Install Husky

# --save-dev because hooks are a development concern, not a production dependency
npm install --save-dev husky

# Initialize Husky -- creates .husky/ directory and configures Git
npx husky init

2. Add a Hook

This creates a .husky/pre-commit file that Git will execute before every commit.
# Every commit must pass the test suite before it is accepted
echo "npm test" > .husky/pre-commit
Now, every time anyone on the team runs git commit, npm test runs first. If tests fail, the commit is rejected. The developer fixes the issue locally instead of breaking the shared branch.

Lint-Staged

Running tests on the entire project for every commit is slow — on a large codebase, it can take minutes, which defeats the purpose of fast feedback. lint-staged solves this by running scripts only on the files that are currently staged for commit. If you changed 3 files out of 500, only those 3 get linted. Add a lint-staged config to your package.json. Each key is a glob pattern matching staged files; the value is the command to run on those files:
{
  "lint-staged": {
    "*.{js,ts}": "eslint --fix",
    "*.{md,json}": "prettier --write"
  }
}
.husky/pre-commit:
# lint-staged reads the staged file list and applies the matching rules
npx lint-staged
Why this matters in practice: On a monorepo with 10,000 files, running ESLint on everything takes 45 seconds. Lint-staged runs it on only the 5 files you changed in 2 seconds. This is the difference between developers embracing hooks and developers adding --no-verify to every commit out of frustration.

Key Takeaways

  • Use Hooks to shift quality checks “left” (to the developer’s machine). Catching a linting error in 2 seconds locally is infinitely better than finding it 10 minutes later in CI.
  • Use Husky to share hooks across your team. Without it, hooks are “honor system” and nobody uses them.
  • Use Lint-Staged to keep commits fast. Slow hooks lead to --no-verify becoming team culture.
  • Never rely only on hooks. Developers can bypass them with git commit --no-verify, and hooks do not run in GitHub’s web editor or squash-merge UI. Always run the same checks in CI as a safety net. Hooks are a convenience; CI is the enforcement.
Common mistake in teams: Making pre-commit hooks too aggressive (running the full test suite, type-checking the entire project) causes developers to bypass them constantly. Keep pre-commit hooks under 10 seconds. Move heavier checks to pre-push hooks or CI.

Interview Deep-Dive

Strong Answer:
  • The problem is not the developers — it is the hook. If developers consistently bypass a quality gate, the gate is too expensive for the feedback loop it occupies. Pre-commit hooks should run in under 10 seconds. A full unit test suite that takes 45+ seconds at the pre-commit stage is a workflow killer.
  • My solution: restructure what runs where. Pre-commit hooks should only run lint-staged (ESLint and Prettier on staged files only, not the entire project). This takes 2-3 seconds and catches formatting and obvious errors. Move the full unit test suite to a pre-push hook or, better yet, to CI. Pre-push hooks are the right place for heavier checks because pushes happen less frequently than commits.
  • Implementation: install lint-staged and configure it in package.json to run ESLint --fix and Prettier --write only on staged *.{js,ts} files. The pre-commit hook calls npx lint-staged. This is the standard pattern used by most major open-source projects.
  • Additionally, I would keep the full test suite in CI as the enforcing gate. Hooks are a convenience that catches issues early; CI is the authoritative gate that blocks merges. This two-layer approach means fast local feedback (hooks) plus reliable enforcement (CI).
Follow-up: A developer argues that if CI catches everything, hooks are pointless overhead. How do you counter this argument?Hooks catch issues in 2 seconds on the developer’s machine. CI catches the same issue in 5-10 minutes after the push, after the developer has context-switched to another task. The cost of a context switch back to fix a lint error is far higher than the 2 seconds the hook took. Hooks are about shortening the feedback loop, not about enforcement. The analogy I use: CI is the seatbelt, hooks are the dashboard warning light. You need both, but the warning light saves you from needing the seatbelt most of the time.
Strong Answer:
  • The core problem: Git hooks live in .git/hooks/, which is not committed to version control. If a team member clones the repo, they get no hooks. Hooks are effectively opt-in and individual, which means they are never used consistently.
  • Husky solves this by storing hook scripts in a committed .husky/ directory (which IS tracked by Git) and configuring Git to use that directory as the hooks path. When Husky is initialized (npx husky init), it sets core.hooksPath=.husky in the local Git config via a prepare script in package.json.
  • Here is what happens when a new developer clones and runs npm install: The prepare lifecycle script in package.json runs automatically after npm install. This script executes husky (or husky install in older versions), which sets git config core.hooksPath .husky. From that point forward, every git commit, git push, etc. looks in .husky/ for hook scripts instead of .git/hooks/.
  • The key insight is that Husky uses a standard Git feature (core.hooksPath) to redirect hook resolution. No symlinks, no copying files, no post-install hacks. If a developer does not run npm install (e.g., they are not a Node.js developer touching docs), hooks are not configured — which is a reasonable failure mode.
  • For non-Node projects (Python, Go, etc.), there are analogous tools: pre-commit (Python-based, supports any language) and lefthook (Go-based, very fast). The principle is the same: committed hook definitions + automatic installation on project setup.
Follow-up: A CI/CD pipeline runs git commit internally (e.g., committing auto-generated files). The pre-commit hook fails because ESLint is not installed in the CI image. How do you handle this?The CI environment should use git commit --no-verify (or HUSKY=0 git commit) for automated commits because the CI pipeline has its own quality checks. Alternatively, set the HUSKY environment variable to 0 in CI to disable hooks entirely. The broader principle is that hooks are developer tools — CI has its own enforcement layer and should not depend on developer hooks. Some teams add a CI environment variable check in the hook script itself: [ -n "$CI" ] && exit 0.
Strong Answer:
  • Pre-commit (under 10 seconds): Use lint-staged to run language-specific linters only on staged files. For React: ESLint + Prettier on *.{ts,tsx}. For Go: gofmt and go vet on *.go. For Terraform: terraform fmt and terraform validate on *.tf. Each linter runs in parallel because lint-staged supports concurrent execution. The total time should be under 5 seconds because we are only processing changed files.
  • Commit-msg (under 1 second): Validate commit message format using commitlint. Enforce Conventional Commits (feat:, fix:, chore:, etc.) and require a JIRA ticket reference (e.g., PROJ-123). This costs almost nothing in time and pays enormous dividends in automated changelog generation and release management.
  • Pre-push (30-60 seconds): Run the affected test suites. For a monorepo, use a tool like Turborepo, Nx, or Bazel to determine which packages are affected by the changes and run only those tests. Do not run the full test suite (which could be 20+ minutes in a monorepo). If the affected-package detection is not available, run only the tests in the directories that were modified.
  • CI (the enforcing gate): Full test suite across all affected packages, integration tests, security scanning (Snyk, Trivy), and deployment previews. CI is the authoritative gate — nothing merges without passing CI, regardless of whether hooks passed locally.
  • What I explicitly would NOT do: run the full monorepo test suite in pre-commit (too slow), run Terraform plan in hooks (requires cloud credentials and takes too long), or enforce hooks in CI (CI has its own checks).
Follow-up: How do you handle the case where a developer modifies a shared utility library that affects both frontend and backend packages?This is where monorepo tooling earns its keep. Turborepo and Nx build a dependency graph of packages. When the shared library changes, they determine all downstream dependents and run their tests. In the pre-push hook, I would use npx turbo run test --filter=...[HEAD] which runs tests for everything affected by the current changes. In CI, the same command runs with broader coverage. The key is that the dependency graph is explicit (defined in package.json or workspace.json), not inferred — so the tool knows that changing @company/utils requires testing @company/frontend and @company/api.

Next: Linux Crash Course →