A git hook is a script git runs automatically at a specific point in its workflow: right before a commit is recorded, after you switch branches, before a push hits the remote. You drop an executable script with the right name into a hooks directory and git fires it at the matching moment. The classic use is a pre-commit hook that lints your code and blocks the commit if the lint fails, so broken code never gets recorded in the first place.
Every git repository already has the hooks wired up. Look in .git/hooks/:
ls .git/hooks/You will see a set of files ending in .sample:
applypatch-msg.sample
commit-msg.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-push.sample
pre-rebase.sample
prepare-commit-msg.sample
update.sampleThose are templates git ships so you can see the shape of each hook. They do nothing, because the .sample suffix means git ignores them. To activate a hook you create a file with the bare name (no .sample) and make it executable.
Write your first pre-commit hook
A hook is just a script. The first line is a shebang, the rest is whatever you want to run. Here is a pre-commit hook that runs your linter and aborts the commit on a non-zero exit:
#!/bin/sh
npm run lintSave that as .git/hooks/pre-commit, then make it executable. The executable bit is the part people forget, and a non-executable hook is silently skipped with no error:
chmod +x .git/hooks/pre-commitNow run git commit. If npm run lint exits non-zero, git stops and the commit is never made. A clean lint, exit zero, and the commit goes through. That exit code is the whole contract: a "pre" hook that exits non-zero cancels the operation.
You are not limited to shell. The shebang can point at any interpreter on the box. A Python or Node hook works the same way:
#!/usr/bin/env nodeThe common hooks and when they fire
There are more than a dozen hooks, but most real work uses a handful. The names tell you the timing: a pre- hook runs before the action and can abort it, a post- hook runs after and is for side effects (it cannot stop anything).
| Hook | Fires | Typical use |
|---|---|---|
pre-commit | before the commit message is even requested | lint, run fast tests, block secrets |
commit-msg | after you write the message, before the commit is finalized | enforce a message format (Conventional Commits, a ticket ID) |
pre-push | before a push transfers objects to the remote | run the full test suite, block a push to main |
post-checkout | after git checkout or git switch changes your working tree | rebuild a dependency cache, warn on a branch switch |
post-merge | after a merge completes (including a git pull) | run npm install when lockfiles changed |
pre-commit and pre-push are the two that earn their keep: catch problems locally before they cost a CI run or land in shared history. commit-msg is how teams keep their log machine-readable. The post- hooks are convenience automation, the kind of thing that re-installs dependencies for you when a teammate's merge touched package-lock.json.
The catch: .git/hooks is not shared
Here is the limitation that sends every team to a hook manager. The .git/hooks/ directory lives inside .git/, which is not version-controlled and not part of what gets cloned, pushed, or pulled. Your carefully written pre-commit hook exists only on your machine. A teammate who clones the repo gets none of it, and there is no built-in way to commit a hook so everyone receives it.
That is by design (a hook is arbitrary code that runs on your machine, so git refuses to auto-install one from a clone for security reasons), but it makes "the team should all run this lint check" impossible with native hooks alone.
Share hooks with core.hooksPath
The native fix is core.hooksPath, a config option added in git 2.9 (2016) that points git at a different hooks directory, one you can track in the repo. Create a folder, commit your hooks into it, and tell git to use it:
mkdir .githooks
mv .git/hooks/pre-commit .githooks/pre-commit
git config core.hooksPath .githooks
git add .githooks
git commit -m "Add shared git hooks"Now the hooks are real files in the repository, reviewed and updated like any other code. The one piece that does not travel automatically is the config line itself: core.hooksPath is local config, so each teammate still has to run git config core.hooksPath .githooks once after cloning. Most teams put that in a setup script, a Makefile target, or a postinstall so it runs on first install. The hooks stay executable in git, so you do not re-chmod after every clone.
This convenience is also the security trade-off, so it is worth being explicit about. The reason git does not auto-install hooks from a clone is that a hook is arbitrary code that runs on your machine. The moment you point core.hooksPath at a tracked directory, you are opting in to running whatever scripts are committed there, by anyone with push access, every time you commit, push, or check out. On an untrusted or unfamiliar repository, read the hooks before you enable them, the same way you would read a build script before running it. Code review on the hooks themselves matters as much as review on the app code.
For JS projects: husky v9
In a Node project the standard tool is husky, which wires up core.hooksPath for you and survives npm install so the whole team is covered automatically. Husky's setup changed completely in v9 (and again from the old v4 era), so any tutorial showing a package.json "husky" block with a "hooks" object, or the even older v0.10 top-level "precommit" script key, is out of date. That entire config-in-package.json approach was removed. Hooks are now plain files under a .husky/ directory.
The current setup is two steps. Install husky as a dev dependency, then initialize it:
npm install --save-dev husky
npx husky initnpx husky init creates the .husky/ directory with a sample pre-commit file, and adds a prepare script to your package.json:
{
"scripts": {
"prepare": "husky"
}
}The prepare script is the magic: npm runs it automatically on every npm install, so when a teammate clones the repo and installs dependencies, husky sets core.hooksPath to .husky/ for them with no extra step. That is the problem core.hooksPath alone leaves you to solve manually, handled.
The flip side is the same trust point as above, and it is easy to miss because it happens silently. Running npm install on a repository activates whatever hooks are committed under .husky/, so on a project you do not control you are running someone else's scripts at install time. That is a known npm supply-chain surface (the prepare and postinstall lifecycle scripts run arbitrary code), so treat committed hooks as code you review, and be wary of enabling them on a repo you do not trust.
To set what a hook does, write the command into the hook file. There is no husky add command and no shebang to manage in v9, just the command itself:
echo "npm test" > .husky/pre-commitThat file is committed, so the hook is shared the moment it lands on the default branch.
Run linters only on staged files with lint-staged
A pre-commit that lints the whole project is slow and noisy: it flags files you never touched. The fix everyone reaches for is lint-staged, which runs your linters against only the files staged for this commit. It pairs with husky directly. Install it, configure which commands run on which file globs, and call it from the hook:
npm install --save-dev lint-stagedA minimal lint-staged config in package.json:
{
"lint-staged": {
"*.js": "eslint --fix",
"*.{css,md}": "prettier --write"
}
}Then the pre-commit hook is a single line:
echo "npx lint-staged" > .husky/pre-commitNow a commit only lints and auto-fixes the files in that commit, which is fast enough that nobody is tempted to skip it.
Bypass a hook when you need to
Hooks block the operation, and sometimes you legitimately need them out of the way: an emergency hotfix, a work-in-progress commit you will squash later, a commit on a machine without the toolchain installed. The --no-verify flag (short form -n) skips the pre-commit and commit-msg hooks:
git commit --no-verify -m "WIP, will fix lint before PR"git push --no-verify does the same for the pre-push hook. Use it sparingly. The whole point of the hook is to stop bad commits, and a team that bypasses by reflex has no hook at all. The real backstop is CI running the same checks, so a --no-verify locally still gets caught before merge.
FAQ
See also
- How to undo things in git: restore, reset, revert, and amend, for when a hook did not catch it in time.
- Git aliases that save you time: shortcut the commands you run all day, including hook-friendly wrappers.
- How to squash git commits: clean up the work-in-progress commits a
--no-verifyleft behind before you open the PR.
Sources
Authoritative references this article was fact-checked against.
- githooks documentation (official)git-scm.com
- Husky: Get started (official docs)typicode.github.io
- git config core.hooksPath (official)git-scm.com
- git-push documentation: --no-verify (official)git-scm.com





