LEARNING OBJECTIVES โต
- Understand the Git hook architecture (
.git/hooks/pre-commit) and why native hooks are not shared via version control. - Configure Husky to manage project-level Git hooks committed into the repository.
- Implement
lint-stagedto execute linters only on staged files for sub-second developer feedback. - Build a multi-step pre-commit pipeline executing Prettier formatting, HTMLHint analysis, and Markuplint validation.
- Handle Git staging states, stash management, and edge cases during pre-commit failures.
๐ The Mental Model & Story (Intuitive Foundation)
Imagine an international airport with an automated security checkpoint. If security officers only inspected passengers after they had already boarded the aircraft and the plane was halfway across the Atlantic Ocean, removing an unauthorized passenger would require turning the entire airliner around at tremendous expense.
The same principle applies to software development. If code quality is only checked on the remote Continuous Integration (CI) server after a Pull Request is opened:
- The developer has already switched contexts to another task.
- Broken branches pollute the remote Git history.
- CI compute minutes and cloud budgets are wasted on trivial formatting or unclosed tag errors.
Developer Workstation Remote Server
+-------------------------+ +-------------------------+
| git add index.html | | |
| | | | |
| git commit -m "..." | | |
| v | | |
| [ Husky Pre-Commit ] | | |
| | | | |
| [ lint-staged ] | | |
| -> Prettier (Format) | | |
| -> HTMLHint (Lint) | | |
| -> Markuplint (Spec) | | |
| | | | |
| (ALL PASS?) | | |
| / \ | | |
| [YES] [NO] | | |
| | | | | |
| Commit Saved Commit | | GitHub Actions CI |
| | Aborted! | === git push ===> | (100% Clean PRs) |
+-------------------------+ +-------------------------+
By installing a Pre-Commit Quality Gate using Husky and lint-staged, invalid markup is intercepted and fixed locally in milliseconds before it ever leaves the developer's laptop.
Technical Deep Dive & Specifications
Why Native .git/hooks Fall Short
Git includes a native hook system inside .git/hooks/. However, Git specifically ignores the .git folder during commits. This means raw Git hooks cannot be shared with team members via git clone.
Husky solves this by:
- Creating a version-controlled
.husky/directory in the repository root. - Configuring Git's
core.hooksPathto point to.husky/via annpm preparescript. - Automatically installing hooks for all developers upon running
npm install.
The Performance Problem & lint-staged
If a repository contains 5,000 HTML and JSX templates, running a full linter across the entire project on every single commit would take 20โ40 seconds. Developers would quickly bypass the hook using git commit --no-verify.
lint-staged solves this by executing linters only on files currently in the Git staging area (git diff --cached).
Working Tree (100 files changed)
|
[git add index.html] --> Only 1 file staged!
|
[lint-staged] --> Runs Prettier & HTMLHint ONLY on index.html (50ms execution!)
|
[Success] --> Auto-adds formatted file back to index and completes commit.
Step-by-Step Production Setup
Step 1: Install Dependencies
npm install --save-dev husky lint-staged prettier htmlhint markuplint
Step 2: Initialize Husky
# Initializes .husky/ directory and adds "prepare": "husky" to package.json
npx husky init
Step 3: Configure the Pre-Commit Hook (.husky/pre-commit)
Edit the generated .husky/pre-commit file:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
Step 4: Configure lint-staged (.lintstagedrc.json)
Create .lintstagedrc.json in your project root:
{
"*.html": [
"prettier --write",
"htmlhint --config .htmlhintrc"
],
"*.{jsx,tsx,vue,svelte}": [
"prettier --write",
"markuplint"
]
}
Execution Flow Sequence
- Developer runs
git commit -m "Add new feature". - Git triggers
.husky/pre-commit. - Husky invokes
npx lint-staged. lint-stagedcollects staged files matching globs (e.g.src/index.html).- Step 1: Prettier rewrites
index.htmlwith deterministic formatting. - Step 2:
lint-stagedautomatically re-stages the formatted file. - Step 3: HTMLHint scans the staged file.
- If HTMLHint finds an unclosed tag, it exits with code
1. The commit is aborted immediately, preserving the staging area. - If all checks pass with code
0, the Git commit completes normally.
๐ป Interactive Code Playground
Simulating the Pre-Commit Workflow in Node.js
Below is a complete, runnable demonstration script (test-git-hook.mjs) that illustrates how lint-staged intercepts broken HTML and blocks the commit:
Starter Code
Line-by-Line Code Breakdown
- Lines 8โ19: Creates a temporary HTML file containing unclosed tags and missing
altattributes to simulate a developer's local changes. - Lines 26โ27: Step 1 executes Prettier to ensure formatting is normalized.
- Lines 30โ31: Step 2 executes HTMLHint with mandatory
tag-pairandalt-requirerules. - Lines 34โ38: Catch block intercepts non-zero exit codes, mirroring Git's pre-commit abort behavior.
Expected Terminal Output
// test-git-hook.mjs
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const testFile = path.resolve('./temp-staged.html');
console.log('--- Phase 1: Writing Broken HTML with Missing Alt & Unclosed Tag ---');
fs.writeFileSync(
testFile,
`<!DOCTYPE html>
<html lang="en">
<head><title>Staged Demo</title></head>
<body>
<div>
<!-- Error: Missing alt on image and unclosed p tag -->
<img src="avatar.png">
<p>Developer Profile
</div>
</body>
</html>`
);
console.log('--- Phase 2: Simulating lint-staged Execution ---');
try {
// 1. Run Prettier
console.log('-> Running Prettier format check...');
execSync(`npx prettier --write "${testFile}"`, { stdio: 'inherit' });
// 2. Run HTMLHint with strict rules
console.log('-> Running HTMLHint validation...');
execSync(`npx htmlhint "${testFile}" --rules "tag-pair=true,alt-require=true"`, { stdio: 'inherit' });
console.log('\nโ
Staged check passed! Git commit approved.');
} catch (error) {
console.error('\nโ PRE-COMMIT QUALITY GATE FAILED!');
console.error('Commit aborted. Fix the lint violations above before committing.\n');
process.exit(1);
} finally {
// Cleanup demo file
if (fs.existsSync(testFile)) fs.unlinkSync(testFile);
}--- Phase 1: Writing Broken HTML with Missing Alt & Unclosed Tag ---
--- Phase 2: Simulating lint-staged Execution ---
-> Running Prettier format check...
temp-staged.html 24ms (unchanged)
-> Running HTMLHint validation...
temp-staged.html:
line 7, col 5: An alt attribute must be present on <img> elements. [alt-require]
line 8, col 5: Tag must be paired, no start tag: [ </div> ] on line 9. [tag-pair]
Scanned 1 file, found 2 errors in 1 file.
โ PRE-COMMIT QUALITY GATE FAILED!
Commit aborted. Fix the lint violations above before committing.๐๏ธ Hands-On Exercise
๐ฏ The Challenge: Build a Resilient .lintstagedrc Multi-Stage Pipeline
Instructions:
- Author a production-grade
.lintstagedrc.jsonconfiguration file that:- For all
*.htmlfiles: Runs Prettier (prettier --write), HTMLHint (htmlhint --config .htmlhintrc), and Markuplint (markuplint). - For all React/Vue components (
*.{jsx,tsx,vue}): Runs Prettier and Markuplint. - For all SVG vector graphics (
*.svg): Runs SVGO or an SVG linter check.
- For all
- Ensure the commands run sequentially so files are formatted before being analyzed by the linters.
๐ Starter Code Sandbox (.lintstagedrc.json)
โ ๏ธ Common Pitfalls
- Overusing
git commit --no-verify: Developers sometimes use--no-verify(or-n) to bypass pre-commit hooks during crunch periods. Doing so pushes broken markup that fails the CI build anyway, wasting team time. - Running Slow Full-Repo Commands in Pre-Commit: Never run
npm run test:e2eor full-project builds in a pre-commit hook. Pre-commit hooks must complete in under 2 seconds or developers will disable them. - Modifying Non-Staged Lines in Working Tree:
lint-stagedautomatically manages partially staged files via Git stashing. Ensure your version oflint-stagedis updated to avoid stash conflict edge cases.
๐ก Pro Tips
- Add
post-mergeandpost-checkoutHooks: Use Husky to automatically runnpm installwhen switching branches or pulling upstream commits with new dependencies. - Pair with Commitlint: Use Husky's
commit-msghook with@commitlint/clito enforce Conventional Commits (e.g.feat(html): add accessible modal markup) alongside markup linting.
๐ Key Takeaways
- Native Git hooks in
.git/hooks/are local-only and not tracked by version control. - Husky configures Git's
core.hooksPathto.husky/, allowing Git hooks to be shared seamlessly across development teams. lint-stageddramatically boosts developer productivity by executing formatters and linters exclusively on staged files.- Prettier should always execute before linters in
lint-stagedarrays so that formatted code is what gets analyzed. - Pre-commit hooks keep remote Continuous Integration (CI) pipelines green and eliminate formatting noise during code reviews.
- --