ESLint 20 🛡️ Pre-commit Hooks — Husky & lint-staged integration
A pre-commit hook is the last line of defense before code enters the repository. It runs automatically when a developer runs git commit, and if it fails, the commit is aborted. The combination of Husky and lint-staged is the standard mechanism for this in JavaScript projects. Husky installs the Git hooks and manages their execution; lint-staged runs the configured commands only against the files that are actually staged, which is what makes the hook fast enough to run on every commit without the developer noticing a delay .
The division of labor is precise. Husky does not lint anything. It only ensures that a script runs when Git triggers the pre-commit event. lint-staged does not install hooks. It receives the staged file list from Git, filters it against glob patterns, and runs the configured commands on the matching files. The two tools are independent, and either can be used without the other, but the combination is what makes the workflow practical: Husky guarantees the hook runs, and lint-staged guarantees it runs quickly .
Key point: Husky installs the hook; lint-staged runs the commands on staged files. The hook is a one-liner: npx lint-staged. The configuration lives in package.json or a .lintstagedrc file, and the commands are the same commands the developer would run manually, with --fix or --write so that the corrections are applied automatically and re-staged.
Why the combination exists
The speed problem. A pre-commit hook that runs ESLint on the entire codebase is slow enough that developers disable it. The hook runs on every commit, and a full lint pass on a large project can take ten or twenty seconds, which is enough friction to make --no-verify a habit. lint-staged solves this by passing only the staged files to the command. The lint pass is proportional to the size of the commit, not the size of the repository, and a typical commit touches a handful of files .
The re-staging problem. A linter with --fix modifies files, but the modifications are not automatically included in the commit. The file on disk has the fix, but the index (the staged version) still has the original. lint-staged handles this: after a command modifies a file, it re-stages the modified version so that the fix is included in the commit. Without this behavior, the developer would commit the unfixed code and the fix would appear as an unstaged change .
The partial staging problem. A developer may stage only part of a file’s changes, leaving the rest unstaged. lint-staged stashes the unstaged changes before running the commands, so the linter only sees the staged content. After the commands run, the unstaged changes are restored. This prevents the linter from reporting problems on lines that are not part of the commit .
The trade-off. The hook is a convenience, not a security control. It can be bypassed with git commit --no-verify, and it only runs on machines where the developer has installed the dependencies. Any check that must be enforced—security linting, license checks, secret scanning—belongs in CI, where it cannot be skipped. The pre-commit hook catches the common cases early; CI catches everything else .
a. Installing Husky and lint-staged
The installation is two packages, one initialization command, and one file edit. The husky init command creates the .husky/ directory and adds a prepare script to package.json.
# Install both packages as dev dependencies.
npm install --save-dev husky lint-staged
# Initialize Husky.
# This creates the .husky/ directory and adds "prepare": "husky"
# to the scripts section of package.json.
npx husky init
The prepare script is what makes the hook installation persist across npm install. When a new developer clones the repository and runs npm install, the prepare script runs Husky’s installation, which sets core.hooksPath to .husky in the local Git configuration. Without the prepare script, the developer would need to run npx husky init manually after every clone .
// package.json — the prepare script added by husky init
{
"scripts": {
"prepare": "husky"
}
}
The husky init command also creates a sample .husky/pre-commit file. The file is a POSIX shell script, and its content is the command that runs on commit. The initial content is usually npm test, which needs to be replaced with the lint-staged command.
# .husky/pre-commit — the hook file
# Replace the default content with the lint-staged command.
npx lint-staged
The hook file does not need a shebang line in recent Husky versions. The file is executed by Git, and Git uses the shell that is configured for the repository. The command npx lint-staged runs the locally installed lint-staged binary from node_modules .
b. Configuring lint-staged
The lint-staged configuration maps glob patterns to commands. The commands run sequentially for each matching file set, and the file paths are appended to the command automatically.
// package.json — the lint-staged configuration
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css,scss}": [
"prettier --write"
]
}
}
The order of the commands matters. eslint --fix runs first, applying the ESLint corrections. prettier --write runs second, formatting the result. If Prettier ran first, ESLint would reformat the code according to its own rules, and the formatting would be undone. The ESLint-first order ensures that the final output satisfies both tools, provided the ESLint configuration does not conflict with Prettier’s formatting (which is what eslint-config-prettier ensures) .
The --fix and --write flags are what make the hook useful. Without them, the command reports problems and the commit is blocked. With them, the command corrects what it can and the commit proceeds. The developer only sees a failure when there is a problem that cannot be fixed automatically, which is the case where manual intervention is actually required .
// lint-staged.config.js — an alternative configuration file
// The function form receives the file list and returns the command.
export default {
'*.{js,jsx,ts,tsx}': (files) => {
// The files are passed explicitly in the function form.
// This is useful when the command needs to handle the list
// differently, such as batching or filtering.
return [`eslint --fix ${files.join(' ')}`, `prettier --write ${files.join(' ')}`];
},
};
The configuration file can be a .lintstagedrc.json, .lintstagedrc.yaml, .lintstagedrc.js, or a lint-staged key in package.json. The package.json form is the simplest for a small configuration; the dedicated file is better when the configuration grows or when it needs comments .
Complete Example Session
// ============================================
// PART 1: THE INSTALLATION
// ============================================
// npm install --save-dev husky lint-staged
// npx husky init
// The init command creates .husky/pre-commit
// and adds "prepare": "husky" to package.json.
// ============================================
// PART 2: THE HOOK FILE
// ============================================
// .husky/pre-commit
// npx lint-staged
// The file is executable.
// The command runs the locally installed lint-staged.
// ============================================
// PART 3: THE CONFIGURATION
// ============================================
// package.json
{
"scripts": {
"prepare": "husky",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css,scss}": [
"prettier --write"
]
}
}
// The ESLint command runs first, then Prettier.
// The corrected files are re-staged automatically.
// ============================================
// PART 4: THE COMMIT FLOW
// ============================================
// The developer stages a file with a lint error:
// git add src/app.js
// The developer commits:
// git commit -m "feat: add app"
// The hook runs npx lint-staged.
// lint-staged passes src/app.js to eslint --fix.
// ESLint corrects the fixable problems.
// Prettier formats the corrected file.
// lint-staged re-stages the corrected file.
// The commit succeeds with the corrected code.
// ============================================
// PART 5: THE FAILURE CASE
// ============================================
// The developer stages a file with an unfixable error:
// git add src/app.js
// The developer commits:
// git commit -m "feat: add app"
// The hook runs npx lint-staged.
// eslint --fix reports the unfixable problem.
// The command exits with a non-zero code.
// lint-staged aborts the commit.
// The developer fixes the problem manually and retries.
// ============================================
// PART 6: THE BYPASS
// ============================================
// In an emergency, the hook can be bypassed:
// git commit -m "fix: urgent" --no-verify
// The --no-verify flag skips the hook entirely.
// This is not a recommended habit.
// The CI pipeline is the backup gate.
// ============================================
// PART 7: THE CI SAFEGUARD
// ============================================
// The pre-commit hook is a convenience.
// The CI pipeline must run the same checks without the hook.
// package.json
{
"scripts": {
"lint": "eslint .",
"lint:ci": "eslint . --max-warnings 0"
}
}
// The CI runs npm run lint:ci.
// The hook catches problems early; CI catches everything.
// ============================================
// PART 8: THE HUSKY DISABLE FOR CI
// ============================================
// In CI, Husky should not install hooks.
// The prepare script should be skipped or made non-fatal.
// package.json
{
"scripts": {
"prepare": "husky || true"
}
}
// Or set HUSKY=0 in the CI environment.
// env:
// HUSKY: 0
// ============================================
// PART 9: THE WORKTREE CAVEAT
// ============================================
// In a linked Git worktree, the hook may resolve the wrong path.
// The hook should use git rev-parse to find the active worktree.
// For most projects, this is not an issue.
// It becomes relevant in monorepos with multiple worktrees.
// ============================================
// PART 10: THE VERIFICATION
// ============================================
// [ ] husky and lint-staged are installed as devDependencies.
// [ ] .husky/pre-commit contains npx lint-staged.
// [ ] .husky/pre-commit is executable.
// [ ] package.json has "prepare": "husky".
// [ ] package.json has the lint-staged configuration.
// [ ] A test commit triggers the hook.
// [ ] A fixable problem is corrected and committed.
// [ ] An unfixable problem blocks the commit.
The ten parts covered the installation, the hook file, the configuration, the commit flow, the failure case, the bypass, the CI safeguard, the CI disable, the worktree caveat, and the verification checklist. The through-line is that the hook is a convenience, and the CI pipeline is the enforcement.
Quick Reference
Installation commands
| Command | Purpose |
|---|---|
npm install -D husky lint-staged | Install both packages |
npx husky init | Create .husky/ and add prepare script |
The hook file
| File | Content |
|---|---|
.husky/pre-commit | npx lint-staged |
The lint-staged configuration
| Glob | Command | Order |
|---|---|---|
*.{js,jsx,ts,tsx} | eslint --fix then prettier --write | ESLint first |
*.{json,md,css} | prettier --write | N/A |
The CI safeguard
| Setting | Purpose |
|---|---|
"prepare": "husky || true" | Prevent CI failure if Husky is not installed |
HUSKY: 0 | Disable Husky in CI |
Best Practices
✅ Do This:
// Use the prepare script for persistence
"prepare": "husky" // ✅
// Run ESLint before Prettier
"*.{js,ts}": ["eslint --fix", "prettier --write"] // ✅
// Keep the CI check separate
"lint:ci": "eslint . --max-warnings 0" // ✅
❌ Don’t Do This:
// Run Prettier before ESLint
"*.{js,ts}": ["prettier --write", "eslint --fix"] // ❌
// Rely on the hook as the only gate
// The hook can be bypassed with --no-verify. // ❌
// Run type-checking through lint-staged
"*.ts": "tsc --noEmit" // passes individual files, not the project // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Hook does not run | Husky not initialized or prepare script missing | Run npx husky init and verify prepare |
| Commit succeeds without fixes | --fix or --write missing | Add the flags to the commands |
| Fixes are not committed | lint-staged re-staging fails | Check for worktree issues or file permissions |
CI fails on prepare | Husky not installed in CI | Use husky || true or HUSKY: 0 |
| Type-check runs on individual files | tsc --noEmit passes individual files | Use a separate script, not lint-staged |
Real-World Examples
1. Basic hook
# .husky/pre-commit
npx lint-staged
2. Basic config
"lint-staged": { "*.js": "eslint --fix" }
3. ESLint and Prettier
"*.{js,ts}": ["eslint --fix", "prettier --write"]
4. Prettier only
"*.{json,md}": "prettier --write"
5. Prepare script
"prepare": "husky"
6. CI disable
env: { HUSKY: 0 }
7. CI safeguard
"prepare": "husky || true"
8. Bypass
git commit -m "msg" --no-verify
9. Config file
// .lintstagedrc.json
{ "*.js": "eslint --fix" }
10. Verify hook
ls -la .husky/pre-commit
Visual
The Commit Flow
┌──────────────────────────────────────────────────────────────┐
│ FROM git commit TO COMMIT COMPLETE │
│ │
│ git commit -m "msg" │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Git runs the │ Husky installed the hook │
│ │ pre-commit hook │ at .husky/pre-commit │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ npx lint-staged │ The hook's only command │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Get staged files │ git diff --staged --name-only │
│ │ Match globs │ │
│ │ Run commands │ eslint --fix, prettier --write │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Re-stage fixed files│ The fixes are included │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ All commands exit 0?│ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ Yes │ No │
│ ▼ ▼ │
│ ┌────────┐ ┌─────────────┐ │
│ │ Commit │ │ Commit │ │
│ │ created│ │ aborted │ │
│ └────────┘ └─────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Two Tools
┌──────────────────────────────────────────────────────────────┐
│ HUSKY AND LINT-STAGED, SEPARATED │
│ │
│ Husky lint-staged │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Installs the hook │ │ Runs the commands │ │
│ │ Manages execution │ │ Filters staged files│ │
│ │ Sets core.hooksPath │ │ Re-stages fixes │ │
│ │ │ │ Stashes unstaged │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ The hook is a one-liner: npx lint-staged │
│ The configuration lives in package.json or .lintstagedrc │
└──────────────────────────────────────────────────────────────┘
The CI Gate
┌──────────────────────────────────────────────────────────────┐
│ HOOK IS CONVENIENCE, CI IS ENFORCEMENT │
│ │
│ Local machine CI pipeline │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ pre-commit hook │ │ npm run lint:ci │ │
│ │ Can be bypassed │ │ Cannot be bypassed │ │
│ │ Runs on staged files│ │ Runs on all files │ │
│ │ Fast │ │ Comprehensive │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ The hook catches problems early. │
│ The CI pipeline catches everything the hook missed. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Hook installer | Husky |
| Staged-file runner | lint-staged |
| Hook command | npx lint-staged |
| Configuration location | package.json or .lintstagedrc |
| Command order | eslint --fix then prettier --write |
| Re-staging | Automatic for fixed files |
| Bypass | git commit --no-verify |
| CI safeguard | "prepare": "husky || true" or HUSKY: 0 |
Key takeaways:
- Husky installs the hook; lint-staged runs the commands. The
npx husky initcommand creates the.husky/pre-commitfile and adds thepreparescript. The hook file’s content isnpx lint-staged, which is the only command Husky needs to execute . - lint-staged only runs on staged files. It passes the staged file list to the configured commands, which keeps the hook fast enough to run on every commit. It also re-stages the fixed files so that the corrections are included in the commit .
- The command order matters.
eslint --fixruns first, thenprettier --write. The ESLint corrections are formatted by Prettier, and the final output satisfies both tools, provided the ESLint configuration does not conflict with Prettier’s formatting . - The hook is a convenience, not a security control. It can be bypassed with
--no-verify, and it only runs on machines where the dependencies are installed. The CI pipeline must run the same checks without the hook. The hook catches problems early; CI catches everything . - The
preparescript makes the hook persistent. When a new developer clones the repository and runsnpm install, thepreparescript runs Husky’s installation, which setscore.hooksPathin the local Git configuration. Without it, the developer would need to runnpx husky initmanually .
Remember: The pre-commit hook is the last line of defense before code enters the repository, but it is a voluntary defense. Husky makes the hook run automatically, and lint-staged makes it fast by limiting the scope to the staged files. The combination catches the common cases—a missing semicolon, an unused variable, a formatting inconsistency—before they become part of the repository’s history. The rare cases that the hook misses are caught by the CI pipeline, which is the enforcement gate that cannot be bypassed. The hook is for the developer’s convenience; the CI is for the project’s integrity. The two are complementary, and neither replaces the other.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!