A Git hook is a local executable that Git runs at a named event. For a basic repository using the default hooks directory, save this as .git/hooks/pre-commit:
#!/bin/sh
# Check the project before committing.
# Author: Ishan Karunaratne - https://techearl.com/git-hooks-explained
npm run lintThen enable it:
chmod +x .git/hooks/pre-commitThis assumes the project already has a working lint script and installed dependencies. A nonzero result blocks the commit. It checks the working tree according to that script, not an isolated copy of exactly what is staged. For staged-file handling, use the lint-staged example below.
What is a Git hook?
Git recognizes specific filenames such as pre-commit, commit-msg and pre-push. A file named pre-commit.sample is only a sample; remove the suffix and make it executable to activate it. The interpreter comes from the shebang, so shell, Python and Node hooks all need their runtime installed where Git runs.
The default location is $GIT_DIR/hooks. In an ordinary checkout that is .git/hooks, but linked worktrees, bare repositories and core.hooksPath can change the layout. Check a configured override with:
git config --show-origin --get core.hooksPathNo output normally means no override is configured. Do not assume .git is always a directory; worktrees can use a .git file pointing to metadata elsewhere.
Common hooks and when they run
| Hook | When it runs | Typical use |
|---|---|---|
pre-commit | Before recording the commit | Fast checks or a linter |
prepare-commit-msg | Before the commit-message editor | Populate a ticket or template |
commit-msg | With the proposed message file | Validate message format |
post-commit | After the commit | Local notifications |
pre-push | Before transferring the push | Tests or an accidental-push check |
post-checkout | After a checkout or branch switch | Refresh local derived files |
post-merge | After a successful merge | Report dependency changes |
Blocking behavior belongs to each hook's documented contract. pre-commit, commit-msg and pre-push can reject their operation with a nonzero exit status. A post-commit notification cannot undo the commit that already happened.
Server-side hooks are a different enforcement point. See pre-receive hook declined for rejected pushes and post-receive deployment hooks for deployment behavior.
A pre-commit hook with useful failure output
A little output makes failures easier to diagnose:
#!/bin/sh
# Run the repository lint script before a commit.
# Author: Ishan Karunaratne - https://techearl.com/git-hooks-explained
te_log() {
printf '[pre-commit] %s\n' "$1"
}
te_log 'running npm run lint'
if ! npm run lint; then
te_log 'lint failed; commit aborted'
exit 1
fi
te_log 'lint passed'For example, a configured linter could report:
[pre-commit] running npm run lint
src/login.js: an unused variable failed the lint check
[pre-commit] lint failed; commit abortedThe exact linter output depends on its configuration. Fix the error, stage the intended fix and retry. Git has not created a commit when pre-commit rejects it. An unstaged change can also cause this full-working-tree check to fail, even when the staged change is valid.
Share native hooks with core.hooksPath
Files inside .git/hooks do not travel with a clone. To review and version the scripts, move them into a tracked directory:
mkdir -p .githooks
mv .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
git config --local core.hooksPath .githooks
git add .githooks/pre-commitCommit the tracked file through the normal project workflow. Each new clone still needs the local activation step:
git config --local core.hooksPath .githooksInspect the scripts before enabling them. A tracked hook executes code from the repository, including future updates to that hook; cloning alone deliberately does not activate arbitrary repository scripts. A setup script can make activation convenient without hiding this trust decision.
Choose one owner for core.hooksPath. A native .githooks setup and Husky should not compete to configure the same clone.
Share hooks with Husky
For an npm-based project, the current Husky setup is:
npm install --save-dev husky
npx husky initinit adds a prepare script and creates .husky/pre-commit. Edit that file to contain the command you want:
npm run lintCommit the project configuration, lockfile and .husky/pre-commit. Husky's current generated launchers live in .husky/_, and it points core.hooksPath there; the editable hook remains in .husky/pre-commit. Do not manually point Git at the wrong directory or copy old Husky bootstrap lines into a current installation.
Installation needs its lifecycle script to run with Git available. A clone with scripts disabled, development dependencies omitted or HUSKY=0 is not automatically protected. Treat the local hook as useful feedback and keep required checks in CI.
Lint only the staged changes with lint-staged
A pipeline that extracts filenames from git diff and sends them to xargs is easy to get wrong. It can split paths with spaces, omit renames, or lint unstaged content in a partially staged file. I use lint-staged for that workflow:
npm install --save-dev lint-stagedMerge this key into package.json; the relevant ESLint and Prettier dependencies and configuration must already exist:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --fix",
"*.{css,md}": "prettier --write"
}
}Change .husky/pre-commit to:
npx lint-stagedBy default, lint-staged creates a backup stash and hides unstaged changes in partially staged files while tasks run, then restores them. Successful task changes are included in the commit. Those safeguards are configurable, so do not disable them casually. Test the setup with a filename containing spaces and with a partially staged file; review the resulting diff when a formatter changes code.
Lint-staged runs commands on matching paths. It is not a substitute for a full build or test suite that depends on the whole project.
Bypassing and disabling hooks
git commit --no-verify -m "Describe the change"
git push --no-verifyFor git commit, --no-verify skips pre-commit and commit-msg; it does not skip prepare-commit-msg. For a push, it skips pre-push. A user can also change or remove their local hooks, so they are not a security boundary. Required CI checks and protected-branch policy provide enforcement at the shared repository.
To disable a raw hook temporarily:
chmod -x .git/hooks/pre-commitFor a custom hooks path, use the actual configured file. To remove a custom configuration after uninstalling the tool that owns it:
git config --local --unset core.hooksPathThat returns this clone to its default path unless a higher-level Git configuration supplies another value.
Why a hook does not run
Check the filename, executable permission, shebang and current core.hooksPath. Also check whether the event really occurred: an editor opening is not evidence of a completed commit, and an interrupted merge is not a successful post-merge event.
A GUI Git client can have a different PATH from your interactive shell, especially when Node comes from a version manager. Run the command under the same environment and check the tool's own troubleshooting instructions. On Windows, preserve LF line endings in shell hooks. Non-executable hooks may produce Git advice rather than a hard error.
FAQ
See also
Sources
Authoritative references this article was fact-checked against.
- Git hook events and behaviorgit-scm.com
- Git core.hooksPathgit-scm.com
- Husky setuptypicode.github.io
- Husky hook-path implementationraw.githubusercontent.com
- lint-staged usage and safeguardsgithub.com





