Lesson 103 of 12120 min read

Practical Git Hooks: Linting Before Commit, Running Tests, Enforcing Message Format

Write real, working Git hooks that solve genuine problems: linting staged code, running tests before commit, and enforcing a commit message convention.

Author: CodersNexus

Practical Git Hooks: Linting Before Commit, Running Tests, Enforcing Message Format

With the mechanics of client-side hooks established, this lesson gets fully hands-on: three genuinely useful, realistic hook scripts you could actually use on a real project today — linting only the files you're about to commit, running a project's test suite before allowing a commit, and enforcing a structured commit message convention.

Learning Objectives

  • Write a pre-commit hook that lints only staged files, not the entire codebase.
  • Write a pre-commit hook that runs a test suite and blocks the commit on failure.
  • Write a commit-msg hook enforcing Conventional Commits formatting.
  • Understand git commit --no-verify as an intentional escape hatch, and its trade-offs.

Key Terms to Know Before Writing Practical Git Hooks

  • Staged-file linting: Running a linter only against the specific files currently staged for commit, rather than the entire codebase, keeping the check fast and relevant.
  • git diff --cached --name-only: A command that lists just the filenames currently staged, commonly used inside hooks to scope checks appropriately.
  • --no-verify: A flag that explicitly skips pre-commit and commit-msg hooks for one specific commit, an intentional, visible escape hatch rather than a hidden bypass.

How to Write Practical, Real-World Git Hooks

**Practical hook #1: Linting only staged files.** A naive pre-commit hook might run a linter against the entire codebase every time, which is slow and wasteful — you should only care about the files actually being committed right now. The key technique is scoping the lint check to exactly the staged files, using `git diff --cached --name-only`:

```
#!/bin/sh
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx)$')
if [ -z "$FILES" ]; then
exit 0 # no relevant staged files, nothing to lint, allow the commit
fi

echo "$FILES" | xargs npx eslint
if [ $? -ne 0 ]; then
echo "ESLint failed. Fix the errors above before committing."
exit 1
fi
```

This finds only the added/copied/modified (`--diff-filter=ACM`, excluding deleted files, which obviously can't be linted) staged files matching a relevant extension, exits cleanly if there's nothing relevant to check, and otherwise runs ESLint specifically against those files — fast, and precisely scoped to what's actually about to be committed.

**Practical hook #2: Running tests before allowing a commit.** A stricter pre-commit hook can require the full (or a relevant subset of the) test suite to pass:

```
#!/bin/sh
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Commit blocked until tests pass."
exit 1
fi
```

This is a meaningfully stronger (and slower) check than linting alone, and many teams deliberately choose to run only a fast subset of tests at commit-time (reserving the full suite for CI), balancing thoroughness against the friction of a slow local commit process.

**Practical hook #3: Enforcing a commit message convention.** Building on the previous lesson's `commit-msg` introduction, a complete, working Conventional Commits enforcement hook:

```
#!/bin/sh
COMMIT_MSG_FILE=$1
PATTERN='^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,100}$'

if ! head -1 "$COMMIT_MSG_FILE" | grep -qE "$PATTERN"; then
echo "ERROR: Commit message must follow Conventional Commits format."
echo "Example: feat: add user authentication"
exit 1
fi
```

This checks the first line of the message file (passed as `$1`) against a regular expression requiring one of the standard type prefixes, an optional scope in parentheses, a colon, and a non-empty description — directly enforcing the convention introduced back in Module 1.

Finally, it's worth explicitly understanding **`git commit --no-verify`**: this flag intentionally skips both `pre-commit` and `commit-msg` hooks for one specific commit. This exists as a deliberate, visible **escape hatch** — sometimes a hook's check is genuinely inappropriate for one specific, unusual commit (an emergency fix under time pressure, or a deliberately non-standard commit for a specific reason), and `--no-verify` provides a clear, explicit way to bypass it for that one case, rather than needing to temporarily disable or delete the hook entirely. This is precisely why the previous lesson noted that client-side hooks, while genuinely useful, are not a strict, unbypassable enforcement mechanism — that stricter guarantee comes only from server-side hooks or platform features like GitHub's required status checks.

Practical Hook Workflow: Visual Walkthrough

Draw a single pre-commit hook script visualized as three sequential checks: 1) 'Get staged files (git diff --cached --name-only)' → 2) 'Run ESLint on ONLY those files' → (pass/fail branch) → 3) 'Run npm test' → (pass/fail branch) → 'Commit proceeds ✓' or 'exit 1 — Commit BLOCKED ✗'. Add a side annotation showing 'git commit --no-verify' as a dashed bypass arrow going AROUND both checks directly to 'Commit proceeds', labeled 'Intentional escape hatch — visible, not a hidden loophole.'

Practical Hook Use Cases: Quick Reference Table

Practical HookWhat It ChecksKey Technique
Staged-file lintingOnly files currently staged, matching relevant extensionsgit diff --cached --name-only --diff-filter=ACM
Test suite before commitThe project's test suite (or a fast subset)Run npm test (or equivalent); check exit code
Commit message formatThe written commit message against a required patterngrep -E against a Conventional Commits regex, in commit-msg
--no-verify escape hatchN/A — explicitly skips pre-commit and commit-msggit commit --no-verify -m "..."

Practical Hooks: Full Working Script Examples

# Full pre-commit hook: lint only staged JS/JSX files
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx)$')
if [ -z "$FILES" ]; then
  exit 0
fi
echo "$FILES" | xargs npx eslint
if [ $? -ne 0 ]; then
  echo "ESLint failed. Fix the errors above before committing."
  exit 1
fi
EOF
chmod +x .git/hooks/pre-commit

# Full commit-msg hook: enforce Conventional Commits
cat > .git/hooks/commit-msg << 'EOF'
#!/bin/sh
PATTERN='^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,100}$'
if ! head -1 "$1" | grep -qE "$PATTERN"; then
  echo "ERROR: Commit message must follow Conventional Commits format, e.g., 'feat: add login'"
  exit 1
fi
EOF
chmod +x .git/hooks/commit-msg

# Intentionally bypass both hooks for one specific commit
git commit --no-verify -m "emergency: hotfix without full checks"

Breaking Down the Practical Hook Examples

Both hook scripts are shown in their complete, working form, ready to actually be placed in `.git/hooks/` and made executable. The `pre-commit` script specifically scopes ESLint to only the relevant staged files, keeping the check fast rather than re-linting the entire project on every commit. The `commit-msg` script validates the first line of the written message against a Conventional Commits pattern, rejecting non-compliant messages with a clear, actionable error. The final `--no-verify` example demonstrates the intentional, visible escape hatch — explicitly opted into for one specific commit, rather than silently or permanently disabling the hooks.

How Practical Hooks Are Used on Real Engineering Teams

  • Scoping linting to only staged files (rather than the entire codebase) is standard practice on virtually every professional project using pre-commit linting, since re-checking unrelated, unchanged files on every commit would be needlessly slow.
  • Many teams deliberately keep pre-commit checks fast (linting, maybe a quick subset of tests) while reserving the full, slower test suite for CI, specifically to avoid frustrating developers with painfully slow local commits.
  • Conventional Commits enforcement via a commit-msg hook is extremely common specifically because it enables downstream automated tooling — changelog generation, semantic version bumping — that depends on consistently structured, parseable commit messages.
  • --no-verify is a well-known, intentionally documented escape hatch that experienced engineers use sparingly and deliberately, typically reserved for genuine emergencies or clearly justified exceptions, rather than routine use.

Practical Git Hooks Interview Questions and Answers

Q1. How would you write a pre-commit hook that lints only the files being committed, rather than the entire codebase?

Use git diff --cached --name-only --diff-filter=ACM to get the list of currently staged, added/copied/modified files, filter that list to relevant file extensions, and run the linter specifically against just those files, exiting cleanly if there's nothing relevant to check. This keeps the check fast and precisely scoped to what's actually about to be committed.

Q2. How would you write a commit-msg hook enforcing a Conventional Commits message format?

Read the commit message file (passed as the hook's first argument) and check its first line against a regular expression requiring a valid type prefix (like feat, fix, or docs), an optional scope, a colon, and a description. If it doesn't match, print a clear error explaining the expected format and exit with a non-zero status to reject the commit.

Q3. What does git commit --no-verify do, and why does it exist as a legitimate feature rather than being considered a flaw?

It explicitly skips both the pre-commit and commit-msg hooks for that one specific commit. It exists as a deliberate, visible escape hatch for genuine exceptions — like an emergency fix under time pressure — where a hook's check is inappropriate for that one case, without needing to temporarily disable or delete the hook entirely.

Practical Git Hooks Quiz: Test Your Understanding

1. Why should a pre-commit linting hook scope its check to only staged files, rather than the entire codebase?

  1. Linting the entire codebase is technically impossible
  2. It keeps the check fast and precisely relevant to what's actually being committed
  3. Staged files cannot be linted at all
  4. Git requires this scoping by default

Answer: B. It keeps the check fast and precisely relevant to what's actually being committed

Explanation: Re-linting the entire codebase on every commit would be slow and largely irrelevant to the specific change being made; scoping to staged files keeps the check fast and directly useful.

2. What command is commonly used inside a hook to get the list of currently staged files?

  1. git log --oneline
  2. git diff --cached --name-only
  3. git branch -a
  4. git remote -v

Answer: B. git diff --cached --name-only

Explanation: This command lists just the filenames currently staged for the next commit, commonly filtered further (like with --diff-filter=ACM) to scope a hook's checks appropriately.

3. What does git commit --no-verify do?

  1. Permanently deletes all hooks from the repository
  2. Explicitly skips the pre-commit and commit-msg hooks for that one specific commit
  3. Forces a commit even with unresolved merge conflicts
  4. Disables all future hooks for the entire repository

Answer: B. Explicitly skips the pre-commit and commit-msg hooks for that one specific commit

Explanation: --no-verify is a deliberate, visible escape hatch for that single commit only, letting a developer intentionally bypass hook checks for a genuine, specific exception without permanently altering the hooks themselves.

Common Mistakes When Writing Practical Git Hooks

  • Writing a pre-commit hook that lints the entire codebase rather than scoping the check to only staged files, resulting in slow, frustrating commits.
  • Forgetting to filter deleted files out of the staged-files list before attempting to lint them, causing the hook to error on files that no longer exist.
  • Writing an overly strict or slow pre-commit hook (like running the entire test suite every time) without considering the trade-off between thoroughness and developer friction.
  • Treating --no-verify as a hidden bypass to be embarrassed about rather than understanding it as an intentional, legitimate escape hatch for genuine exceptions.

Practical Git Hooks: Exam-Ready Quick Notes

  • Scope pre-commit linting to staged files: git diff --cached --name-only --diff-filter=ACM, filtered by relevant extension.
  • pre-commit running tests: check the exit code of the test command; exit 1 to block on failure.
  • commit-msg enforcing format: check the message file's first line (passed as $1) against a regex pattern.
  • git commit --no-verify: intentional, visible escape hatch skipping pre-commit and commit-msg for one commit.

Practical Git Hooks: Key Takeaways

  • Practical, well-scoped hooks (linting only staged files, running tests, enforcing message format) solve genuine, everyday problems with minimal friction.
  • Balancing thoroughness against speed is a real design decision when writing pre-commit checks, since overly slow hooks frustrate and discourage developers.
  • --no-verify is a legitimate, intentional escape hatch, not a flaw — understanding when its use is appropriate is part of using hooks well.

Frequently Asked Questions About Practical Git Hooks

Q1. How do I write a pre-commit hook that only lints the files I'm actually committing?

Use git diff --cached --name-only --diff-filter=ACM to get the list of currently staged, added/modified files, filter that to relevant file extensions, and run your linter specifically against those files — this keeps the check fast rather than re-linting your entire project every time.

Q2. How can I make a commit fail if my project's tests don't pass?

Write a pre-commit hook that runs your test command (like npm test) and checks its exit code — if the tests failed (a non-zero exit code), have your hook script also exit with a non-zero status, which blocks the commit.

Q3. How do I enforce a specific commit message format like Conventional Commits?

Write a commit-msg hook that reads the commit message file (passed as the script's first argument) and checks it against a regular expression matching your required format, rejecting the commit with a clear error message if it doesn't comply.

Q4. What does the --no-verify flag do when committing?

It explicitly skips both the pre-commit and commit-msg hooks for that one specific commit, providing a deliberate, visible way to bypass hook checks for a genuine exception, like an emergency fix, without needing to disable the hooks themselves.

Q5. Should my pre-commit hooks be fast or thorough?

There's a real trade-off — very thorough checks (like a full test suite) take longer and can frustrate developers with slow commits, while very fast checks might miss issues. Many teams balance this by running fast checks (linting, a quick test subset) locally in pre-commit hooks, and reserving the full, thorough test suite for a CI system instead.

Summary

Practical, real-world Git hooks solve genuine problems with careful scoping and clear feedback. A well-written pre-commit linting hook uses `git diff --cached --name-only --diff-filter=ACM` to scope its check to only the currently staged, relevant files, keeping the process fast rather than re-checking the entire codebase on every commit. A pre-commit test-running hook simply checks the exit code of a test command, blocking the commit on failure — with many teams deliberately running only a fast subset locally, reserving the full suite for CI. A commit-msg hook enforcing Conventional Commits checks the message file's first line against a regular expression requiring a valid type prefix and description, rejecting non-compliant messages with a clear, actionable error. Finally, `git commit --no-verify` provides a deliberate, visible escape hatch, explicitly skipping pre-commit and commit-msg for one specific commit — a legitimate feature for genuine exceptions, not a hidden flaw, and precisely why truly strict, unbypassable enforcement requires server-side hooks or platform features rather than client-side hooks alone.

Frequently Asked Questions

Use git diff --cached --name-only --diff-filter=ACM to get the list of currently staged, added/modified files, filter that to relevant file extensions, and run your linter specifically against those files — this keeps the check fast rather than re-linting your entire project every time.

Write a pre-commit hook that runs your test command (like npm test) and checks its exit code — if the tests failed (a non-zero exit code), have your hook script also exit with a non-zero status, which blocks the commit.

Write a commit-msg hook that reads the commit message file (passed as the script's first argument) and checks it against a regular expression matching your required format, rejecting the commit with a clear error message if it doesn't comply.

It explicitly skips both the pre-commit and commit-msg hooks for that one specific commit, providing a deliberate, visible way to bypass hook checks for a genuine exception, like an emergency fix, without needing to disable the hooks themselves.

There's a real trade-off — very thorough checks (like a full test suite) take longer and can frustrate developers with slow commits, while very fast checks might miss issues. Many teams balance this by running fast checks (linting, a quick test subset) locally in pre-commit hooks, and reserving the full, thorough test suite for a CI system instead.