Your team spent hours on a pre-commit hook that runs eslint and a secret scanner before every commit. The script is committed to the repo and documented in the README. But new developers who clone the repo report that nothing runs — they can commit lint-breaking code with no warning at all. This is by design, not a bug: Git never clones or version-controls the .git/ directory, so any hook sitting in .git/hooks/ exists only on the machine that put it there. Git deliberately refuses to ship executable hooks across a clone, because running unreviewed code the instant you clone a stranger’s repo would be a security hole. Sharing hooks therefore needs one deliberate activation step.
TL;DR — fastest fix
Move the hook into a tracked directory (e.g. .githooks/), then point Git at it:
git config core.hooksPath .githooks # per-clone; each developer runs this once
chmod +x .githooks/* # hooks must be executable
That one core.hooksPath line is the whole fix for most teams. The catch: it is local to each clone and is not carried over by git clone, so you must automate it (a prepare script, a Makefile target, or a setup script — see Step 3). If you are on the npm ecosystem, install husky v9 instead and let npm install activate hooks automatically.
Which bucket are you in?
| Symptom | Most likely cause | Jump to |
|---|---|---|
ls .git/hooks/ shows only *.sample files | Hooks live in a tracked dir but Git isn’t pointed at it | Cause 1, 2 |
Hook file exists in .git/hooks/ but never fires | Not executable, or CRLF shebang | Cause 3, 6 |
.husky/ exists, no hooks fire | npm install / husky was never run | Cause 4 |
| Hooks work locally, never in CI | --no-verify in a CI script or alias | Cause 5 |
git config core.hooksPath prints nothing | hooksPath never set in this clone | Cause 2 |
Common causes
Ordered by hit rate, highest first.
1. Hooks are in a tracked directory but .git/hooks/ is never populated
The repo has a hooks/ or .githooks/ directory with pre-commit and pre-push scripts committed. Developers assume cloning the repo activates them. It does not — by default Git only looks in .git/hooks/, never in a tracked directory.
How to spot it: ls .git/hooks/ shows only .sample files. The real hooks sit in hooks/ or .githooks/, neither symlinked nor copied into .git/hooks/.
2. core.hooksPath is not set in this clone
core.hooksPath (added in Git 2.9.0, June 2016) tells Git to look in a tracked directory instead of .git/hooks/. But this setting lives in .git/config, which is never propagated by git clone. Every fresh clone starts with the default (.git/hooks), so unless your setup step runs, the tracked hooks are invisible.
How to spot it: git config core.hooksPath returns empty on a fresh clone.
3. Hook file is not executable
The hook was committed without the executable bit. git clone preserves the stored file mode, so the hook lands as a non-executable file. Git silently skips non-executable hooks — no error, no exit code.
How to spot it: ls -la .githooks/pre-commit (or .git/hooks/pre-commit). If permissions read -rw-r--r-- (no x), the hook will not run. Confirm what Git stored with git ls-files -s .githooks/pre-commit — mode 100644 means non-executable, 100755 means executable.
4. Hook manager (husky, lefthook) was not installed after clone
The repo uses husky v9, which keeps hooks in .husky/ and activates them by setting core.hooksPath to .husky/_ during install. That install only happens when npm install runs the prepare script. A developer who ran git clone but never npm install (or used a different package manager that skipped lifecycle scripts) has no active hooks.
How to spot it: .husky/ exists and package.json has "prepare": "husky". Run git config core.hooksPath — if it does not print .husky/_, husky never ran in this clone.
Note: husky v9 changed the syntax. The old v8 "prepare": "husky install" and the husky add command are deprecated; v9 uses bare "prepare": "husky" and you create hook files manually in .husky/. An inherited project still on the old syntax can break on a fresh install — re-run npx husky init to migrate.
5. Hooks skipped by --no-verify in a CI script or alias
A CI pipeline or a shell alias appends --no-verify (or -n) to git commit / git push, bypassing all client-side hooks. Hooks run fine for developers who do not use the alias but are completely absent in CI.
How to spot it: grep -rn "no-verify\|\-n\b" .github/ Makefile scripts/ ~/.gitconfig — any --no-verify is skipping hooks. Also check IDE settings: VS Code’s git.allowNoVerifyCommit and JetBrains’ “Run Git hooks” checkbox in the commit dialog.
6. Windows line endings in the hook script break the shebang
The hook was written on Windows and committed with CRLF line endings, or a .gitattributes rule re-converted it on checkout. On macOS and Linux the kernel cannot parse #!/bin/sh\r (trailing carriage return) as a shebang and refuses to execute the file, usually with a confusing bad interpreter error or silence.
How to spot it: file .git/hooks/pre-commit returns “with CRLF line terminators.” Or head -c 12 .git/hooks/pre-commit | xxd shows 0d 0a (CRLF) at the end of the shebang line.
Shortest path to fix
Step 1: Move hooks into a tracked directory and point Git at it
The fastest fix that works for the whole team with one commit:
mkdir -p .githooks
git mv hooks/pre-commit .githooks/pre-commit # if you already have a hooks/ dir
chmod +x .githooks/*
git config core.hooksPath .githooks # per-clone activation
git add .githooks
git commit -m "chore: track git hooks under .githooks/"
core.hooksPath accepts a path relative to the repo root, so .githooks resolves correctly for every developer. It overrides the default .git/hooks/ for this repo only.
Step 2: Fix executable bit and line endings (so the hook actually runs)
chmod +x .githooks/pre-commit .githooks/pre-push
git update-index --chmod=+x .githooks/pre-commit .githooks/pre-push # store the +x bit in Git
# Strip CRLF if the file was authored on Windows (macOS BSD sed: use sed -i '' )
sed -i 's/\r$//' .githooks/pre-commit .githooks/pre-push
git add .githooks
git commit -m "fix: make hook scripts executable with LF line endings"
The git update-index --chmod=+x step is what guarantees the executable bit survives the next person’s clone — a plain chmod only changes your working copy.
Step 3: Automate activation so nobody has to remember it
core.hooksPath does not travel with a clone, so wire the setup into a command developers already run.
For npm/yarn/pnpm projects, a prepare script runs automatically after every install:
// package.json
{
"scripts": {
"prepare": "git config core.hooksPath .githooks"
}
}
For non-JS projects, add a Makefile target or a setup script and reference it in CONTRIBUTING.md:
# scripts/setup.sh
#!/bin/sh
git config core.hooksPath .githooks
chmod +x .githooks/*
echo "Git hooks activated."
Step 4: Or install husky v9 correctly (npm ecosystem)
If you want a managed solution, husky v9 sets core.hooksPath for you on install:
npm install --save-dev husky
npx husky init # adds "prepare": "husky" to package.json and creates .husky/pre-commit
# put your check inside .husky/pre-commit, e.g.: npx lint-staged
After this, any teammate’s npm install activates the hooks. Verify:
git config core.hooksPath # should print .husky/_
ls .husky/ # your hook files: pre-commit, pre-push, ...
Prefer a single static binary with no Node dependency? lefthook is a popular alternative: put your config in lefthook.yml, run lefthook install (it writes thin scripts into .git/hooks/), and optionally add a prepare/postinstall to run lefthook install automatically.
Step 5: Verify hooks are running
touch test-hook.txt && git add test-hook.txt
git commit -m "test hook activation"
The pre-commit output should appear in the terminal. If it does not, run the hook directly to surface errors that Git hides:
sh .githooks/pre-commit ; echo "exit code: $?"
A non-zero exit code means the hook would block the commit; exit 0 (or no output) means the hook either passed or never ran — recheck Cause 3 and 6.
Step 6: Remove --no-verify from CI and shared config
grep -rn "no-verify" .github/ .gitlab-ci.yml Makefile scripts/
# Remove --no-verify from each occurrence. Better: run the same checks as
# explicit CI steps so the build still fails even if a local hook is skipped.
Prevention
- Commit hooks to a tracked directory (
.githooks/) and automategit config core.hooksPath .githooksvia apreparescript,Makefiletarget, or husky/lefthook install — never rely on a developer reading the README. - Store the executable bit in Git with
git update-index --chmod=+x .githooks/*so the+xsurvives every future clone. - Commit
.gitattributeswith.githooks/* text eol=lf(or*.sh text eol=lf) to lock hook scripts to LF on every platform, including Windows. - Mirror every pre-commit check (lint, secret scan, tests) as a real CI step, so a
--no-verifycommit still fails the build. Client-side hooks are a convenience, not an enforcement boundary — they can always be bypassed. - Keep pre-commit checks fast (target a couple of seconds) so developers are not tempted to reach for
--no-verify. - Add “after cloning, run
npm install(ormake setup) to activate Git hooks” toCONTRIBUTING.mdand the onboarding checklist.
FAQ
Q: Is there any way to share hooks that activate on clone with zero setup step?
A: Not natively, and that is intentional — Git will not auto-run hooks from a tracked directory, because cloning a repo would then execute arbitrary code. The closest workaround is git config --global init.templateDir ~/.git-templates with hooks placed in ~/.git-templates/hooks/; Git copies that template into the .git/ of any repo you clone after setting it. But it only applies to new clones on machines that already configured the template, and template updates do not propagate to existing repos — so a prepare script or husky is still more reliable for a team.
Q: My hooks run on macOS but not in our Docker-based CI. Why?
A: The CI image clones the repo but never runs the activation step, so core.hooksPath is unset. Add git config core.hooksPath .githooks && chmod +x .githooks/* to the Dockerfile or CI setup. In practice, prefer running the same lint/test commands as explicit CI steps rather than depending on hooks inside CI.
Q: git config core.hooksPath prints nothing — is that the problem?
A: Probably. Empty means Git is using the default .git/hooks/, where your tracked hooks do not live. Set it (git config core.hooksPath .githooks) and confirm it now echoes the path. If you use husky, expect it to print .husky/_, not .husky.
Q: We use Git LFS. Does moving to core.hooksPath break it?
A: It can. git lfs install writes its own post-checkout, post-commit, post-merge, and pre-push hooks into .git/hooks/. Once you redirect core.hooksPath to .githooks/, Git stops reading .git/hooks/, so LFS hooks no longer fire. Either run git lfs install --manual and call git lfs commands from your own hooks, or have your .githooks/ scripts invoke the LFS hooks explicitly.
Q: How do I run a pre-commit check only for certain file types?
A: Filter the staged files inside the hook: git diff --cached --name-only --diff-filter=ACM | grep '\.ts$', then run the linter only when that output is non-empty. Tools like lint-staged (with husky) or lefthook’s glob: filters do this for you.