Practical Exercise: Write a Pre-Commit Hook That Runs ESLint and Blocks Bad Commits
This capstone exercise builds a genuinely working, testable artifact: a real pre-commit hook that lints staged JavaScript files with ESLint and blocks any commit containing lint errors — first written raw, by hand, to reinforce this module's mechanics lessons, then reimplemented via Husky to reinforce the sharing-and-team-consistency lesson, directly proving that a broken commit really does get blocked.
Learning Objectives
- Set up a minimal Node.js project with ESLint configured.
- Write a raw pre-commit hook by hand that lints staged files and blocks non-compliant commits.
- Verify the hook actually blocks a commit containing a real lint error, then allows a fixed commit through.
- Reimplement the same hook using Husky and lint-staged, confirming it would now be properly shareable with a team.
Key Terms to Know Before This Pre-Commit Hook Practical Exercise
- Working artifact: A genuinely functional piece of tooling, verified to actually behave as intended, rather than a purely theoretical or simulated exercise.
- Lint error: A code quality or style issue flagged by a linter like ESLint, which this exercise's hook uses as the specific pass/fail signal for blocking a commit.
- End-to-end verification: Confirming a hook works correctly by actually attempting both a failing and a succeeding commit, not just inspecting the script's code.
How to Build a Working ESLint Pre-Commit Hook, Step by Step
**Step 1: Set up a minimal project with ESLint.**
```
mkdir hook-exercise && cd hook-exercise
git init
npm init -y
npm install --save-dev eslint
npx eslint --init # choose basic options: JavaScript, CommonJS, no framework, Node
```
**Step 2: Create a file with a deliberate lint error.**
```
echo "var unusedVariable = 5;" > app.js
```
(Most default ESLint configurations flag `var` usage and/or unused variables — adjust based on your specific `eslint.config.js` if needed, to ensure at least one real, genuine error exists.)
**Step 3: Write a raw pre-commit hook by hand** (this module's earlier practical hooks lesson), scoping it to staged files:
```
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.js$')
if [ -z "$FILES" ]; then
exit 0
fi
echo "$FILES" | xargs npx eslint
if [ $? -ne 0 ]; then
echo "ESLint failed. Commit blocked until errors are fixed."
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
```
**Step 4: Verify the hook actually blocks a bad commit.**
```
git add app.js
git commit -m "feat: add app.js"
# ESLint should report errors, and the commit should be BLOCKED
```
Confirm with `git log --oneline` that no new commit was actually created — this is the critical end-to-end verification step, proving the hook genuinely works rather than just looking correct on paper.
**Step 5: Fix the lint error and verify the commit now succeeds.**
```
echo "const message = 'hello';" > app.js
git add app.js
git commit -m "feat: add app.js"
# Should succeed this time — ESLint passes, hook allows the commit through
```
**Step 6: Reimplement the same hook using Husky** (this module's dedicated Husky lesson), making it genuinely shareable:
```
npm install --save-dev husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit
```
Add to `package.json`:
```
"lint-staged": { "*.js": "eslint" }
```
**Step 7: Verify the Husky-based version behaves identically.** Repeat steps 2, 4, and 5 (introduce a lint error, confirm the commit is blocked, fix it, confirm the commit succeeds) — the *behavior* should be identical to the raw hook, but now the `.husky/` folder and updated `package.json` can be committed and shared, meaning any teammate who clones this project and runs `npm install` automatically gets this exact same enforced check, unlike the raw hook from Step 3, which would remain purely local.
This complete exercise deliberately walks through both approaches from this module — hand-written mechanics first, then the properly shareable, production-appropriate Husky version — reinforcing that Husky doesn't replace understanding the underlying hook mechanism, it just solves the sharing problem on top of it.
Pre-Commit ESLint Hook Workflow: Visual Walkthrough
Draw a seven-step vertical flow: 1) Set up Node.js project + ESLint → 2) Create app.js with a deliberate lint error → 3) Write raw .git/hooks/pre-commit by hand (scoped to staged .js files) → 4) Attempt commit — VERIFY it's BLOCKED (git log shows no new commit) → 5) Fix the error, attempt commit again — VERIFY it SUCCEEDS → 6) Reimplement via Husky + lint-staged (npx husky init, .husky/pre-commit) → 7) Re-verify identical block/allow behavior, now with .husky/ committed and shareable. Add a final caption: 'Same enforced behavior — but now automatically shared with any teammate who clones and runs npm install.'
Hook-Building Exercise Steps: Quick Reference Table
| Step | Command / Action | Purpose |
|---|---|---|
| 1-2. Setup | npm init, install ESLint, create app.js with an error | Establishes a real, testable project |
| 3. Raw hook | Write .git/hooks/pre-commit by hand, chmod +x | Reinforces this module's raw hook mechanics |
| 4. Verify blocking | git commit — confirm it FAILS, check git log shows no new commit | Proves the hook genuinely works, not just looks correct |
| 5. Verify success | Fix the error, commit again — confirm it SUCCEEDS | Confirms the hook only blocks genuinely bad commits |
| 6. Husky reimplementation | npx husky init, .husky/pre-commit, lint-staged config | Makes the hook genuinely shareable with a team |
| 7. Re-verify | Repeat block/allow test with the Husky version | Confirms identical behavior, now via a committed, shared setup |
Building the ESLint Hook: Command Syntax
# Steps 1-2: Setup
mkdir hook-exercise && cd hook-exercise
git init
npm init -y
npm install --save-dev eslint
npx eslint --init
echo "var unusedVariable = 5;" > app.js
# Step 3: Raw pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.js$')
[ -z "$FILES" ] && exit 0
echo "$FILES" | xargs npx eslint
[ $? -ne 0 ] && echo "ESLint failed. Commit blocked." && exit 1
exit 0
EOF
chmod +x .git/hooks/pre-commit
# Step 4: Verify BLOCKED
git add app.js
git commit -m "feat: add app.js" # should FAIL
git log --oneline # should show NO new commit
# Step 5: Fix and verify SUCCESS
echo "const message = 'hello';" > app.js
git add app.js
git commit -m "feat: add app.js" # should SUCCEED
# Steps 6-7: Reimplement with Husky
npm install --save-dev husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit
# Add "lint-staged": { "*.js": "eslint" } to package.json
git add .husky package.json
git commit -m "chore: migrate to husky-managed pre-commit hook"
Breaking Down the Hook-Building Exercise Example
This complete sequence produces a genuinely functioning, end-to-end verified pre-commit enforcement system, deliberately built twice: first by hand, directly exercising the raw hook mechanics from this module's client-side hooks and practical hooks lessons, and second via Husky, exercising the sharing solution from this module's dedicated Husky lesson. The critical verification steps (4 and 5) don't just write the hook and assume it works — they actually attempt a failing commit and confirm via `git log` that nothing was created, then fix the issue and confirm the commit succeeds, proving real, working behavior rather than a purely theoretical exercise. The final Husky migration commits `.husky/` and the updated `package.json`, making this exact enforcement genuinely shareable — something the raw hook from Step 3 never was.
How This Exact Hook Setup Mirrors Real Team Configurations
- This exact ESLint pre-commit setup — whether raw or via Husky — is one of the most commonly deployed developer tooling configurations across the entire JavaScript and TypeScript ecosystem.
- Many company project templates and boilerplates ship with precisely this Husky + lint-staged + ESLint configuration pre-configured, so every new project a team starts inherits this exact enforcement automatically.
- The verify-it-actually-blocks-then-verify-it-actually-allows testing pattern demonstrated in this exercise mirrors how experienced engineers responsibly test any new piece of tooling before relying on it, rather than assuming a script works just because it looks syntactically correct.
- Technical interviews for roles emphasizing developer tooling or DevOps sometimes ask candidates to build exactly this kind of working pre-commit hook live, testing genuine hands-on Git and tooling fluency rather than just conceptual knowledge.
Pre-Commit Hook Practical Exercise Interview Questions and Answers
Q1. Walk through how you would build and verify a pre-commit hook that blocks commits with ESLint errors.
Write a pre-commit hook script (in .git/hooks/pre-commit, made executable) that identifies currently staged JavaScript files using git diff --cached --name-only --diff-filter=ACM, runs ESLint against just those files, and exits with a non-zero status if ESLint reports errors, which aborts the commit. Critically, verification requires actually attempting a commit with a deliberate lint error to confirm it's genuinely blocked (checking git log shows no new commit was created), then fixing the error and confirming a subsequent commit succeeds.
Q2. Why would you migrate a working raw pre-commit hook to Husky, if it already functions correctly?
A raw hook in .git/hooks/ is never tracked by Git and exists only on the machine that created it, so it wouldn't be shared with any teammate who clones the project. Migrating to Husky moves the hook into a regular, trackable .husky/ folder and wires up automatic setup via the npm install lifecycle, making the exact same enforcement automatically available to the whole team.
Q3. What is the significance of actually testing both a failing and a succeeding commit, rather than just reviewing the hook script's code?
Reviewing a script's code confirms it looks logically correct, but doesn't guarantee it actually behaves as intended in practice — issues like incorrect file permissions, wrong exit code logic, or an unexpected shell quoting issue could cause a hook to silently fail to block anything. Actually attempting both a failing and succeeding commit, and confirming the results via git log, provides genuine, end-to-end proof that the hook works exactly as intended.
Pre-Commit Hook Practical Exercise Quiz: Test Your Understanding
1. In this exercise, how is it confirmed that a commit was genuinely blocked, rather than just assumed from the script's logic?
- By reading the hook script's code carefully
- By actually attempting the commit and checking git log --oneline shows no new commit was created
- By checking the file's modification timestamp
- By asking a colleague to review the script
Answer: B. By actually attempting the commit and checking git log --oneline shows no new commit was created
Explanation: Genuine end-to-end verification requires actually running the commit and confirming via git log that no new commit was actually created, providing real proof the hook worked as intended, not just a code review.
2. Why is the pre-commit hook in this exercise scoped to only staged .js files rather than the entire project?
- ESLint cannot check more than one file at a time
- It keeps the check fast and directly relevant to what's actually being committed, following this module's practical hooks lesson
- Git technically forbids linting unstaged files
- JavaScript files cannot be linted individually
Answer: B. It keeps the check fast and directly relevant to what's actually being committed, following this module's practical hooks lesson
Explanation: Scoping the lint check specifically to currently staged JavaScript files (via git diff --cached --name-only) keeps the hook fast and focused, exactly the pattern established in this module's practical hooks lesson.
3. What is the key benefit of reimplementing the working raw hook using Husky in this exercise's final steps?
- Husky makes ESLint run faster
- It makes the exact same enforcement automatically shareable with any teammate who clones the project and runs npm install
- Husky replaces the need for ESLint entirely
- Raw hooks stop working once Husky is installed
Answer: B. It makes the exact same enforcement automatically shareable with any teammate who clones the project and runs npm install
Explanation: The core benefit of migrating to Husky is solving the sharing problem — the raw hook only ever existed locally, while the Husky-managed version lives in a tracked, committed folder, automatically configured for every team member.
Common Mistakes When Building This Pre-Commit Hook
- Writing the hook script but forgetting to make it executable (chmod +x), causing Git to silently skip it entirely without any error.
- Assuming the hook works correctly just because the script looks syntactically right, without actually attempting a real failing commit to verify it in practice.
- Forgetting to scope the ESLint check to only staged files, causing the hook to unnecessarily lint the entire project on every commit.
- Migrating to Husky but forgetting to actually commit the .husky/ folder and updated package.json, leaving the setup just as unshared as the original raw hook.
Pre-Commit Hook Exercise: Exam-Ready Quick Notes
- Raw hook: .git/hooks/pre-commit, scoped to staged .js files via git diff --cached --name-only --diff-filter=ACM, must be chmod +x.
- Verification requires actually attempting both a failing commit (confirm blocked via git log) and a succeeding one after fixing the error.
- Husky migration: npx husky init, .husky/pre-commit + lint-staged config — makes the hook genuinely shareable.
- Commit .husky/ and package.json to make the Husky-managed hook available to the whole team.
Pre-Commit Hook Exercise: Key Takeaways
- This exercise produces a genuinely working, end-to-end verified artifact, not just a theoretical script — proving both blocking and allowing behavior actually occurs.
- Building the hook raw first, then via Husky, reinforces that Husky solves the sharing problem without changing the fundamental underlying hook mechanism.
- Real verification requires actually attempting both a failing and succeeding commit, not just reviewing a script's logic on paper.
Frequently Asked Questions About This Pre-Commit Hook Exercise
Q1. How do I write a pre-commit hook that lints staged JavaScript files and blocks the commit on errors?
Create an executable script at .git/hooks/pre-commit that uses git diff --cached --name-only --diff-filter=ACM to find staged .js files, runs ESLint against them, and exits with a non-zero status if ESLint reports errors — this aborts the commit, exactly the pattern this exercise builds and tests.
Q2. How do I actually verify my pre-commit hook is working, rather than just assuming it is?
Introduce a deliberate lint error in a file, stage it, and attempt to commit — then check git log --oneline to confirm no new commit was actually created. Then fix the error, stage it again, and confirm a subsequent commit succeeds, proving both the blocking and allowing behavior work as intended.
Q3. Why does this exercise rebuild the same hook a second time using Husky?
The raw hook written directly in .git/hooks/ only exists locally on the machine that created it, since that folder isn't tracked by Git. Rebuilding it with Husky moves the hook into a regular, committed .husky/ folder, making the exact same enforcement automatically available to any teammate who clones the project and runs npm install.
Q4. What needs to be committed to make the Husky-based hook shareable with a team?
The .husky/ folder itself, along with the updated package.json (which now includes both the lint-staged configuration and the 'prepare' script Husky adds), need to be committed — these are the actual tracked files that make the setup shareable.
Q5. What does completing this exercise demonstrate about my Git and tooling skills?
It demonstrates genuine, hands-on ability to build real, working developer tooling — not just theoretical knowledge of hooks, but the ability to write, correctly enable, and rigorously verify a functioning pre-commit check, and then properly solve the team-sharing problem using an industry-standard tool like Husky.
Summary
This capstone exercise builds a genuinely functional, end-to-end verified pre-commit ESLint enforcement system, deliberately in two stages. First, a raw hook is hand-written directly in `.git/hooks/pre-commit`, scoped to only currently staged JavaScript files (using `git diff --cached --name-only --diff-filter=ACM`), running ESLint against them and exiting non-zero to block the commit if errors are found — directly exercising this module's earlier client-side hooks and practical hooks lessons. This is genuinely verified, not just assumed: a commit containing a deliberate lint error is actually attempted and confirmed blocked (via `git log` showing no new commit), and then, after fixing the error, a subsequent commit is confirmed to succeed. Second, the exact same enforcement is reimplemented using Husky and lint-staged (`npx husky init`, a `.husky/pre-commit` file calling `lint-staged`), with the resulting `.husky/` folder and updated `package.json` actually committed — making this identical enforcement genuinely shareable with any teammate who clones the project and runs `npm install`, unlike the original raw hook, which remained purely local. Re-verifying the block/allow behavior confirms the Husky-managed version behaves identically, completing a genuine, working demonstration of everything covered across this module.