# Git hooks

> 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.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-31 | Topics: [Git](https://flaviocopes.com/tags/git/) | Canonical: https://flaviocopes.com/git-hooks/

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:

```text
.git/hooks/pre-commit
.git/hooks/commit-msg
.git/hooks/post-commit
.git/hooks/pre-push
```

You can ask Git for the exact directory:

```bash
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:

```bash
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`:

```bash
#!/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:

```bash
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:

```bash
.git/hooks/pre-commit
```

Then check its exit status:

```bash
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:

```bash
#!/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:

```bash
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:

```bash
#!/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](https://git-scm.com/docs/githooks) 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:

```bash
#!/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:

```bash
#!/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](https://flaviocopes.com/log-git-commits-plain-text/). 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:

```text
.githooks/
  pre-commit
  commit-msg
```

Then tell Git to use it:

```bash
git config core.hooksPath .githooks
```

You only need to run this command once per clone.

Check the current setting with:

```bash
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](https://typicode.github.io/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`:

```bash
git commit --no-verify -m 'fix production error'
```

You can also bypass `pre-push`:

```bash
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:

```bash
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:

```bash
#!/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](https://flaviocopes.com/courses/git/). Hooks make more sense once the basic Git workflow feels familiar.
