Version Git hooks instead of configuring them by hand
Local Git hooks live in .git/hooks and are never versioned with the repository, so every developer has to set them up individually and teams end up with no reliable way to enforce code quality before a commit. Husky distributes hooks automatically through npm, lint-staged limits checks to changed files, and commitlint enforces clean commit messages, all without manual setup.
Table of Contents
- 1. The problem: unversioned local Git hooks across a team
- 2. What Husky is and how it installs hooks automatically
- 3. lint-staged: checking only changed files instead of the whole repository
- 4. A practical setup for a mixed PHP/JS project
- 5. A commit-msg hook with commitlint for Conventional Commits
- 6. Configuring PHPCS, PHP-CS-Fixer, ESLint, and Prettier per file type
- 7. CI vs. local hook behavior and the --no-verify escape hatch
- 8. Common pitfalls in everyday team use
- 9. Manual hooks vs. core.hooksPath vs. Husky+lint-staged compared
- 10. Summary
- 11. FAQ
1. The problem: unversioned local Git hooks across a team
Every locally cloned Git repository ships by default with a .git/hooks directory containing sample scripts for pre-commit, commit-msg, and other events. This directory sits outside the versioned files: Git categorically excludes .git/ from every transfer, so a hook set up locally never makes it into the repository and never reaches another developer. Anyone who wants to enforce code quality before a commit, for example through automatic linting or formatting, would have to manually copy that hook to every single developer machine and mark it executable.
In practice this means a team of five developers ends up, at best, with five manually maintained copies of the same script that are guaranteed to drift apart eventually, and at worst, only one person has an active hook at all. An alternative approach is git config core.hooksPath, which lets you configure a project-owned, versioned directory as the hook source. That solves the versioning problem, but not the distribution problem: every developer still has to run the command manually once, and nothing reminds new team members to do it in the first place. This is exactly the gap Husky closes, by tying hook setup to a step that already has to happen anyway: installing the npm dependencies.
2. What Husky is and how it installs hooks automatically
Husky is a lightweight npm package that manages Git hooks as regular, versioned files in the project, typically inside a .husky/ directory. Instead of manually copying scripts into .git/hooks, Husky sets core.hooksPath once during setup to point at this versioned directory. From that point on, every file in it, such as .husky/pre-commit, is treated by Git like a native hook and runs automatically on the matching event.
The decisive trick lies in npm's own prepare script. npm automatically runs the script listed under scripts.prepare in package.json after every npm install, unless a --production flag or NODE_ENV=production is set. Adding husky there means the hook path gets configured automatically for every developer and every fresh checkout, as soon as they run the installation step they need to run anyway. No extra command, no onboarding checklist item that can be forgotten.
# Install Husky as a dev dependency
npm install --save-dev husky
# Initialize Husky: creates .husky/ and sets the prepare script
npx husky init
# package.json will then automatically contain:
# "scripts": { "prepare": "husky" }
# Add an actual hook, an executable shell script
echo "npx lint-staged" > .husky/pre-commit
chmod +x .husky/pre-commit
# Version the hook so it reaches the whole team
git add .husky/pre-commit package.json
git commit -m "chore: add husky pre-commit hook"
3. lint-staged: checking only changed files instead of the whole repository
A naive pre-commit hook that runs the full linter and formatter over the entire repository on every commit quickly becomes unusable on a mature Magento or Hyvä project with thousands of PHP and JavaScript files. A commit that only changes a single .phtml file would still wait several minutes for a full PHPCS run across the entire app/code tree. This is exactly the problem lint-staged solves: it reads the files actually staged for the commit from the Git index and applies the configured commands only to that subset.
Configuration works through glob patterns as keys and one or more commands as values, either in a dedicated lint-staged.config.js file or directly under the lint-staged key in package.json. For patterns whose linters can auto-fix files, such as ESLint with --fix or PHP-CS-Fixer, lint-staged automatically re-adds the fixed files to the commit afterward. That way, unformatted code never ends up in the repository without a developer having to run the formatter manually.
{
"lint-staged": {
"*.php": [
"php vendor/bin/phpcbf --standard=Magento2",
"php vendor/bin/phpcs --standard=Magento2"
],
"*.{js,ts}": [
"eslint --fix",
"prettier --write"
],
"*.{css,pcss}": [
"stylelint --fix"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}
}
4. A practical setup for a mixed PHP/JS project
A typical Magento 2 project with a Hyvä theme mixes PHP code in app/code with JavaScript, Alpine.js components, and Tailwind CSS in the theme directory. Both worlds need their own tools, but should run through the same single pre-commit hook. The solution is that Husky provides only the entry point, namely the call to npx lint-staged, while lint-staged itself handles branching by file type and invokes the appropriate PHP or JS tools.
It is important that every command referenced in lint-staged works without a global installation. PHP tools therefore live in vendor/bin via Composer, JavaScript tools in node_modules/.bin via npm, and both directories need to be discoverable on the PATH, which lint-staged and npx ensure by default. For projects where PHP runs inside a Docker container, such as a Mark Shust setup, the hook instead calls the corresponding wrapper like bin/phpcs, so the hook runs on the host but PHPCS executes inside the container and checks against exactly the same PHP version as production.
#!/usr/bin/env sh
# .husky/pre-commit
# A single entry point, lint-staged handles branching by
# file type and calls the right PHP or JS tools.
npx lint-staged
# Docker variant (Mark Shust setup): run PHP tools inside the
# container so the local PHP version never drifts from production.
# In this case lint-staged references "bin/phpcbf" instead of
# "vendor/bin/phpcbf".
5. A commit-msg hook with commitlint for Conventional Commits
Husky is not limited to pre-commit. Any hook name supported by Git, such as commit-msg, pre-push, or post-merge, can be placed as its own file in .husky/. To standardize commit messages, Husky is combined with commitlint: the commit-msg hook receives the path to a temporary file containing the entered commit message as an argument from Git and passes it on to commitlint, which checks the message against a configurable rule set.
The most common standard is Conventional Commits, where every message starts with a type such as feat, fix, chore, or refactor, followed by an optional scope and a short description. This doesn't enforce a stylistic preference, it makes commit history machine-readable: automatically generated changelogs, semantic versioning, and targeted release notes can all be derived directly from commit history once every message follows the same pattern. Without an enforced standard, a commit history drifts over months into an unstructured pile of "fix," "wip," and "asdf."
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [
2,
"always",
["feat", "fix", "chore", "docs", "refactor", "test", "perf", "style"]
],
"subject-case": [2, "never", ["upper-case", "start-case"]],
"header-max-length": [2, "always", 100]
}
}
6. Configuring PHPCS, PHP-CS-Fixer, ESLint, and Prettier per file type
Every tool in the lint-staged chain needs its own project-owned configuration file, so it applies the same rules independent of the hook, in the IDE, and in the CI pipeline. For PHP in Magento projects, phpcs.xml based on the Magento2 coding standard is the common baseline, extended with project-specific exceptions for generated code. PHP-CS-Fixer complements PHPCS wherever automatic correction is desired, for example indentation, import sorting, and blank-line conventions, while PHPCS tends to act as a pure checker for rules that can't be safely auto-fixed.
On the JavaScript side, ESLint with a project-wide eslint.config.js handles structural checks of Alpine.js components and other frontend code, while Prettier is responsible exclusively for formatting and is decoupled from the linter's style rules via eslint-config-prettier to avoid conflicting fixes. For Tailwind-related CSS and PostCSS files in the theme directory, Stylelint plays the same role for CSS that ESLint plays for JavaScript. Across all configurations, generated directories like vendor/, var/, pub/static/, and node_modules/ consistently belong in an ignore file, otherwise the hook ends up checking code nobody wrote by hand.
7. CI vs. local hook behavior and the --no-verify escape hatch
Git hooks are a purely local mechanism. A pre-commit hook runs on the machine where the commit is created, never retroactively on push or inside a CI pipeline. That has two implications: first, the CI pipeline has to independently run the same checks again, because a hook that was skipped locally or never installed leaves no trace in the pipeline. Second, hooks should never be relied on as the sole quality gate, they're a fast feedback mechanism for the developer, not a substitute for a server-side gate check before merging.
Git offers a deliberate escape hatch with the --no-verify flag on git commit and git push, which skips all hooks for exactly that one command. That's legitimate for genuine emergencies, such as an urgent hotfix whose linting issues are already known and scheduled to be fixed, but in practice it also tends to get abused to permanently dodge annoying checks. That's exactly why the second, independent CI check isn't an optional extra but the actual enforcement layer: a pull request with a failing lint check in the pipeline can't be merged, regardless of whether the author used --no-verify locally or not.
# .github/workflows/lint.yml
# CI reruns the same checks independently of any local hooks
name: Lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# npm ci instead of npm install: reproducible, and skips
# Husky's prepare step, which CI doesn't need anyway
- run: npm ci --ignore-scripts
- run: npx eslint . --max-warnings=0
- run: npx stylelint "**/*.{css,pcss}"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
- run: composer install --no-interaction --prefer-dist
# Full run across the whole directory, not just staged
# files, since CI checks the complete diff state
- run: php vendor/bin/phpcs --standard=Magento2 app/code
- run: npx commitlint --from=origin/main --to=HEAD
8. Common pitfalls in everyday team use
The most common pitfall involves team members who clone an existing repository but never run npm install afterward, for example because they only work on PHP code and the JavaScript dependencies seem irrelevant to their task. Without the prepare step that npm install triggers, Husky never sets core.hooksPath, and the hook simply doesn't exist for that person, with no error message pointing that out. A README note alone isn't enough: a postinstall hint or a CI check that verifies core.hooksPath can be set correctly on a fresh checkout makes the problem visible instead of silently tolerating it.
A second, subtler pitfall involves the executability of hook files after a checkout, particularly on Windows or with certain CI checkout actions that don't reliably preserve the executable bit. Husky from version 8 onward largely compensates for this with lean, POSIX-compatible shell wrappers, but line endings remain a risk: a hook file saved with Windows line endings (CRLF) can fail on a Linux machine with a bad interpreter error. A project-owned .gitattributes entry with .husky/* text eol=lf reliably prevents that. In CI environments, meanwhile, you usually want to explicitly disable Husky hooks, for example via the HUSKY=0 environment variable, since CI never creates interactive commits anyway and a failing prepare step could otherwise unnecessarily break entire installation steps.
9. Manual hooks vs. core.hooksPath vs. Husky+lint-staged compared
All three approaches solve the same underlying problem of checking code quality before a commit, but they differ substantially in how reliably they actually reach every team member and how much manual effort they add to onboarding.
| Aspect | Manual .git/hooks | core.hooksPath | Husky + lint-staged |
|---|---|---|---|
| Versioned in the repository | Not possible, .git/ is never transferred | Possible, a dedicated directory is versioned | Fully versioned inside .husky/ |
| Setup after clone | Manual copy per developer | Manual git config command required | Automatic via the npm prepare script |
| Team-wide consistency | Guaranteed to drift apart | Consistent if the setup is followed | Consistent, tied to npm install |
| Speed on large repositories | Depends on the script content | Depends on the script content | Fast thanks to lint-staged, changed files only |
| Warning when setup is missing | None, the hook simply doesn't exist | None without extra tooling | Only if npm install is actually run |
In practice, combining Husky and lint-staged isn't just a convenience feature, it's the only one of the three approaches that ties hook distribution to a step that's already mandatory: without npm install, no JavaScript tooling in the project works at all, so the hook is effectively never skipped either. The remaining bit of reliability still needs to be backed by an independent CI check, since no client-side mechanism replaces a server-side control before a merge.
Mironsoft
Git workflows, tooling setup, and CI/CD pipelines for PHP and Magento teams
Ready to finally enforce code quality reliably across your team?
We set up Husky, lint-staged, and commitlint for your PHP and Magento stack, connect them to PHPCS, ESLint, and a matching CI pipeline, and make sure the checks reach every team member automatically.
Hook setup
Setting up Husky, lint-staged, and commitlint versioned inside the repository
Linter configuration
Aligning PHPCS, PHP-CS-Fixer, ESLint, and Stylelint with each other
CI integration
Building server-side gate checks as a backstop to local hooks
10. Summary
Husky and lint-staged solve a problem many teams ignore for years: local Git hooks in .git/hooks can't be versioned and therefore never reliably reach every team member without extra manual effort. Husky ties hook setup to npm's own prepare script, and thus to a step every developer already has to run anyway. lint-staged keeps pre-commit checks fast by applying them only to files that are actually changed and staged, instead of scanning the entire repository on every commit. commitlint rounds both out with standardized commit messages following the Conventional Commits standard.
It remains important to stay aware of this approach's limits: Git hooks are a purely local, client-side mechanism that developers can deliberately bypass with --no-verify, and one that's entirely absent if nobody ever ran npm install. An independent CI pipeline that reruns the same linters, formatters, and commitlint rules server-side therefore remains the actual enforcement layer. Husky and lint-staged are the fast, convenient first filter, not the last one.
Husky and lint-staged across a team, the essentials at a glance
Automatic installation
Add Husky to the npm prepare script, and every npm install sets up hooks automatically for the whole team.
Changed files only
lint-staged limits PHPCS, ESLint, and Prettier to staged files instead of checking the entire repository.
Commit messages
A commit-msg hook plus commitlint enforces Conventional Commits and makes the history machine-readable.
CI as a second layer
Hooks are local and can be bypassed with --no-verify. CI must rerun the same checks independently.