Git hooks
By Flavio Copes
Learn Git hooks: where they live, write a pre-commit hook by hand, share hooks with core.hooksPath or Husky, and when to skip with --no-verify.
Git hooks let you run a program when something happens in Git.
You can run a check before a commit. You can inspect a commit message. You can run tests before a push. You can also record a commit after Git creates it.
I use hooks for small jobs tied directly to Git. They are useful, but they can also become annoying fast. A hook that takes five minutes will soon be skipped by everyone.
Let’s see how they work.
Where Git hooks live
Git keeps local hooks inside the Git directory:
.git/hooks/pre-commit
.git/hooks/commit-msg
.git/hooks/post-commit
.git/hooks/pre-push
You can ask Git for the exact directory:
git rev-parse --git-path hooks
This command is better than assuming the path is always .git/hooks.
If you open that directory, you might see files ending in .sample. Git does not run them. A hook must use the exact hook name, without the .sample extension.
The file must also be executable:
chmod +x .git/hooks/pre-commit
Forgetting this step is probably the most common hook problem.
Your first pre-commit hook
A pre-commit hook runs before Git creates a commit.
Here is a small hook that checks staged JavaScript and TypeScript changes. It stops the commit when you add a console.log:
#!/bin/sh
if git diff --cached -U0 -- '*.js' '*.jsx' '*.ts' '*.tsx' \
| grep -E '^\+.*console\.log'; then
echo 'Remove console.log before committing'
exit 1
fi
Save it as .git/hooks/pre-commit, then make it executable:
chmod +x .git/hooks/pre-commit
Now add a console.log, stage the file, and try committing it.
Git runs the hook first. The script exits with status 1, so Git stops the commit.
An exit status of 0 lets Git continue. A non-zero status stops the current operation when that hook supports it.
Test a hook by hand
A hook is just a program. You do not need to create a commit every time you test it.
Run the file directly:
.git/hooks/pre-commit
Then check its exit status:
echo $?
The message printed by a hook should explain the problem. Something failed is not useful. Remove console.log before committing tells you what to fix.
I also prefer hooks that call a normal project command:
#!/bin/sh
npm run lint
This keeps the real check in package.json. You can run it without Git, and CI can run the same command.
A hook sees the staged changes
Git commits the staged snapshot, also called the index. It does not always commit the complete file you see in your editor.
You might stage one finished change and leave another change unstaged in the same file. This is where automatic formatting inside hooks can cause trouble. A formatter may rewrite the complete file and mix both changes.
My advice is to keep pre-commit hooks read-only. Let them check files without rewriting them.
Before committing, you can see the exact staged patch with:
git diff --cached
That output is what goes into the commit.
If you want a hook to fix staged files, test partial staging carefully. Tools like lint-staged exist for this job, but you still need to understand what they change.
Check the commit message
The commit-msg hook runs after Git prepares the message. Git passes the message file as the first argument.
This example rejects subjects shorter than ten characters:
#!/bin/sh
message_file=$1
subject=$(sed -n '1p' "$message_file")
if [ "${#subject}" -lt 10 ]; then
echo 'Commit subject must contain at least 10 characters'
exit 1
fi
Save it as .git/hooks/commit-msg and make it executable.
Each hook receives different information. Some hooks get arguments. Others read standard input or environment variables. Check the Git hooks documentation before using a hook you do not know.
Run tests before a push
A pre-push hook runs before Git sends objects to the remote.
For example, you can run your test suite:
#!/bin/sh
npm test
Save this as .git/hooks/pre-push.
This works well when your tests finish quickly. I would not put a ten-minute browser test suite there. People push often, and a slow hook gets in the way.
Run long checks in CI. Keep the local hook focused on fast feedback.
Run a program after a commit
The post-commit hook runs after Git creates the commit.
At this point, the commit already exists. Returning an error will not remove it. This makes post-commit a good place for notifications and local records.
Here is a small commit log:
#!/bin/sh
subject=$(git log -1 --pretty=%s)
printf '%s\n' "$subject" >> .git/commit-log.txt
The file stays inside .git, so it does not appear as a project change.
I use a more complete version of this idea to log every Git commit to one plain text file. I tried watching shell commands first. That missed some commits. Letting Git report its own completed commits was much more reliable.
Share hooks with the team
Files inside .git/hooks are local. Git does not include them when someone clones the repository.
For shared hooks, put the files in a tracked directory:
.githooks/
pre-commit
commit-msg
Then tell Git to use it:
git config core.hooksPath .githooks
You only need to run this command once per clone.
Check the current setting with:
git config --get core.hooksPath
You can add that setup command to the project README. A small setup script also works.
Do not set a project hook path globally unless you want it in every repository. I keep project rules inside each project.
There is also a security reason Git does not enable cloned hooks automatically. Hooks run programs on your machine. Read a shared hook before turning it on.
Do you need Husky?
Husky helps JavaScript projects install and share Git hooks. It also works well with tools such as lint-staged.
I would use it when a team needs the same setup across different machines. It saves everyone from managing hook files by hand.
For one short shell script, I usually prefer core.hooksPath. There is less to install and less to debug.
Both options are fine. Pick the one your team will understand six months from now.
You can skip some hooks
Git lets you bypass pre-commit and commit-msg:
git commit --no-verify -m 'fix production error'
You can also bypass pre-push:
git push --no-verify
This is useful during a real emergency or when a check has a known bug. It should not become your normal workflow.
Not every hook follows --no-verify. For example, Git still runs prepare-commit-msg.
The bypass option also tells us something about local hooks. They cannot enforce a rule on their own. A developer can skip the hook, delete it, or have an old copy.
CI is where shared rules belong.
Hooks and CI have different jobs
A hook gives you feedback before you leave your machine. CI checks the repository on a clean machine.
I often run the same quick command in both places. The hook catches the mistake early. CI catches it again if the hook was missing or skipped.
Branch protection can require CI to pass. It cannot require a local hook that Git never received.
I treat hooks as help for the developer, not as a security boundary.
Debug a hook
When a hook does nothing, first check the path and permissions:
git rev-parse --git-path hooks
git config --get core.hooksPath
ls -l .git/hooks/pre-commit
Then run the hook directly.
You can also ask the shell to print each command. Add -x to the first line while debugging:
#!/bin/sh -x
Git runs normal local hooks from the root of the working tree. Use paths relative to that root, or use absolute paths. Do not depend on the directory where someone typed git commit.
Also check that every command used by the hook exists. Your machine may have a tool that another developer has never installed.
How I use hooks
I keep hooks short and boring.
My pre-commit hooks run quick checks. My post-commit hook updates a work log. The real logic lives in scripts I can run and test without Git.
I do not run complete test suites before every commit. I also do not trust a local hook to protect the main branch. CI handles that job.
If staging and commits are still new to you, start with my free Git course. Hooks make more sense once the basic Git workflow feels familiar.
Want me to talk about your product? You can sponsor this site.