add an executable script to .githook/ named after the git hook you want:
echo '#!/bin/sh npm test' > .githook/pre-commit chmod +x .githook/pre-commit
the script must be executable (chmod +x) and start with a shebang (#!/bin/sh).
supported hooks: pre-commit, commit-msg, pre-push, prepare-commit-msg, post-commit, post-merge, pre-rebase, post-checkout, post-rewrite, pre-auto-gc, applypatch-msg, pre-applypatch, post-applypatch.
pre-commit — runs before commit is created. use for linting, formatting, tests:
#!/bin/sh npm run lint && npm test
commit-msg — validate commit message format. $1 is the temp file containing the message:
#!/bin/sh if ! grep -qE "^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+" "$1"; then echo "error: commit message must follow conventional commits format" echo "example: feat(auth): add login endpoint" exit 1 fi
pre-push — runs before push. use for full test suite or build verification:
#!/bin/sh npm run build && npm run test:all
post-merge — runs after merge/pull. use to reinstall deps if lockfile changed:
#!/bin/sh changed_files=$(git diff-tree -r --name-only ORIG_HEAD HEAD) if echo "$changed_files" | grep -q "package-lock.json"; then echo "package-lock.json changed, running npm install..." npm install fi
run your hook directly without making a commit:
./.githook/pre-commit
to test that hooks block commits properly, add exit 1:
#!/bin/sh echo "this will block the commit" exit 1
a non-zero exit code aborts the git operation. exit 0 (or no exit) allows it to proceed.
for a single command, use git's built-in --no-verify flag:
git commit --no-verify -m "wip" git push --no-verify
to disable all hooks for multiple commands:
GITHOOK=0 git commit -m "skip" GITHOOK=0 git push
or export it for your session:
export GITHOOK=0 git commit -m "first" git commit -m "second" unset GITHOOK
hooks are for local development—disable them in CI/CD and containers with GITHOOK=0:
# github actions env: GITHOOK: 0 # gitlab ci variables: GITHOOK: "0" # docker ENV GITHOOK=0
auto-install hooks when developers run npm install:
npm / pnpm / bun:
npm pkg set scripts.prepare="./.githook.sh install"
yarn:
npm pkg set scripts.postinstall="./.githook.sh install"
yarn uses postinstall instead of prepare.
add a target for installing hooks:
.PHONY: prepare prepare: ./.githook.sh install
run after cloning:
make prepare
hooks are automatically migrated during setup if .husky/ exists. to migrate manually:
./.githook.sh migrate husky
this copies hook scripts from .husky/ to .githook/. after verifying:
rm -rf .husky npm uninstall husky
hooks can be written in any language. use a shell wrapper:
#!/bin/sh node .githook/pre-commit.js
or use a shebang directly (the file must still be executable):
#!/usr/bin/env python3 import subprocess import sys result = subprocess.run(["pytest", "-x"]) sys.exit(result.returncode)
#!/usr/bin/env ruby
system("bundle exec rubocop") || exit(1)
gui clients and ides may not load your shell profile, causing "command not found" errors. create ~/.config/githook/init.sh to fix this globally:
mkdir -p ~/.config/githook cat > ~/.config/githook/init.sh << 'EOF' export NVM_DIR="$HOME/.nvm" -s "$NVM_DIR/nvm.sh" && . "$NVM_DIR/nvm.sh" EOF
this is sourced before every hook runs. for fnm use eval "$(fnm env)", for volta add $VOLTA_HOME/bin to PATH.
enable debug output:
GITHOOK_DEBUG=1 git commit -m "test"
common issues:
• "command not found" — see nvm section
• hook not running — check it's executable: ls -la .githook/
• wrong directory — hooks run from repo root
remove git configuration (keeps your hook scripts):
./.githook.sh uninstall
to fully remove:
rm -rf .githook .githook.sh
githook.sh sets core.hooksPath to .githook/_ which contains wrapper scripts. when git runs a hook, the wrapper sources ~/.config/githook/init.sh, checks GITHOOK=0, then runs your script from .githook/.
quick setup (curl | sh) runs install if already setup.
each developer runs ./.githook.sh install once after cloning because git config is local.