# Hooks You Will Actually Use

Canonical: <https://mnixblog.netlify.app/hooks-you-will-actually-use/>

Stop a Bad Commit Before It Exists, and Know What a Hook Cannot Stop

Somebody commits a `.env` file with a real password in it. Nobody notices until it is pushed.

You can write a rule for that in a README. You can mention it in review. Or you can make Git refuse the commit before it exists.

A **hook** is a script that Git runs at a fixed moment, such as just before a commit is created. If the script exits with an error, Git stops.

We will write two hooks, make them part of the repository so a teammate gets them, and then find out exactly how to get around them, because that decides what hooks are for. This is the result, and it does not promise more than it shows:

```console
$ git commit -m "Add env"
pre-commit: refusing to commit a .env file.
```


> **Note:**
> Every command and output in this article comes from Git 2.45.2 on Windows 11, run in Git Bash. Our stand-in for GitHub is a bare repository in a folder called `hub.git`.

## What a hook is, and where it lives

Every repository starts with a `.git/hooks` folder full of examples:

```console
$ ls .git/hooks
applypatch-msg.sample
commit-msg.sample
fsmonitor-watchman.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-merge-commit.sample
pre-push.sample
pre-rebase.sample
pre-receive.sample
prepare-commit-msg.sample
push-to-checkout.sample
sendemail-validate.sample
update.sample
```


Git ignores anything ending in `.sample`. To turn one on, you make a file with the hook's name and no extension: `pre-commit`, not `pre-commit.sh`.

The two we will write:

- `pre-commit` runs before Git asks for a commit message. It can look at what is staged. A non-zero exit stops the commit.
- `commit-msg` runs after you write the message. Git gives it the path of the file that holds the message.

## A pre-commit hook that checks one thing

A hook is just a script. This one stops a `.env` file from being added:

```bash
#!/bin/sh
# Stop a .env file from being added to a commit.
if git diff --cached --name-only --diff-filter=AM | grep -q "\.env$"; then
  echo "pre-commit: refusing to commit a .env file."
  exit 1
fi
```


`git diff --cached --name-only` lists the files in the staging area. `--diff-filter=AM` keeps only those that are **A**dded or **M**odified. Without that part, the hook has a bug we hit in our test: a commit that removes `.env` from Git also lists `.env`, so the hook blocked the very commit that fixed the problem.

```console
$ git commit -m "Stop tracking env"
pre-commit: refusing to commit a .env file.
```


With `--diff-filter=AM`, the same commit went through:

```text
[main 6aa0a43] Stop tracking env
 1 file changed, 1 deletion(-)
 delete mode 100644 .env
```


## Windows: who runs it?

The first line, `#!/bin/sh`, is the shebang: it names the program that runs the file. Windows has no `/bin/sh`, and Git for Windows brings its own. Git runs hooks with that bundled shell, so the hook works no matter which terminal you start Git from. We committed from Git Bash and from PowerShell, and in both the hook ran and refused.

The executable bit is the other thing everybody asks about. On Linux and macOS, a hook must be executable or Git skips it. On Windows, there is no such bit on the file system. In our test, `chmod -x` on a hook in `.git/hooks` changed nothing: `ls -l` still showed `-rwxr-xr-x`, and the hook still ran. So on Windows, you do not need `chmod` for the hook to run for you.

It matters when a teammate on Linux or macOS gets your hook through the repository. That comes next.

## Make everybody get the hooks

`.git/hooks` is inside `.git`, and `.git` is never part of a clone. A fresh clone has the samples and none of your hooks. We check, in a clone of the same project:

```console
$ git config core.hooksPath
$ ls -A .git/hooks | grep -c sample
14
```


The setting prints nothing, so it is not set. To share the hooks, we move them into a folder that is tracked, `.githooks`, and tell Git to look there:

```console
$ git config core.hooksPath .githooks
$ git config core.hooksPath
.githooks
```


Two details:

1. **The setting is per clone.** It lives in `.git/config`, which is not shared either. Every person runs that one command once, or a setup script does. In our test, a teammate's fresh clone had the `.githooks` folder, and a commit with a `.env` in it went straight through, because the setting was not there yet.
2. **Record the executable bit in Git.** The files are `100644` by default on Windows. Make them `100755`, so a teammate on Linux or macOS gets runnable scripts:

```console
$ git ls-files -s .githooks/pre-commit
100644 d9a4c991b07dbd9b526df46c0956c7b5ac972df2 0	.githooks/pre-commit
$ git update-index --chmod=+x .githooks/pre-commit
$ git ls-files -s .githooks/pre-commit
100755 d9a4c991b07dbd9b526df46c0956c7b5ac972df2 0	.githooks/pre-commit
```


`git add --chmod=+x <file>` does the same thing when the file is new. After a commit and a push, the teammate runs the one config command, and:

```console
$ git commit -m "Add env from clone with hooksPath"
pre-commit: refusing to commit a .env file.
```


To run a hook by hand without committing, `git hook run pre-commit` runs the same script and exits with its status.

## A commit-msg hook

The second hook reads the message file Git passes as `$1`. This one keeps the first line short:

```bash
#!/bin/sh
# commit-msg gets the message file as $1. Keep the first line short.
if [ "$(head -n 1 "$1" | wc -c)" -gt 51 ]; then
  echo "commit-msg: first line is longer than 50 characters."
  exit 1
fi
```


`wc -c` counts the newline too, which is why the limit is 51.

```console
$ git commit -m "This subject line is much longer than fifty characters in total"
commit-msg: first line is longer than 50 characters.
$ git commit -m "Short line"
[main 958c11f] Short line
 1 file changed, 1 insertion(+)
```


Keep hooks like these quick and narrow. A hook that takes a minute will be skipped by everyone, and it will be right to be.

## Skip it, knowingly

Sometimes the hook is wrong, or the file really is fine. `--no-verify` skips the `pre-commit` and `commit-msg` hooks for that one commit:

```console
$ git commit --no-verify -m "Add env anyway"
[main bf37352] Add env anyway
 1 file changed, 1 insertion(+)
 create mode 100644 .env
```


`-n` is the short form. Git did not ask for a reason.

## What hooks cannot enforce

Keep this part in mind.

- **They are optional for the person committing.** `--no-verify` is one flag, and Git does not ask why.
- **They are not delivered by `git clone`.** Without the `core.hooksPath` command in each clone, they do not run. We saw a commit with a `.env` go through in a fresh clone.
- **They run on one machine.** A hook cannot know what happens in another clone or on someone else's computer.

So a hook is a **courtesy to the person committing**: it tells them early, while the fix is cheap. It is not a gate. A check that must hold for the whole team has to run on the server, in CI or in branch protection, where nobody can pass a flag to skip it. This article does not test either of those.

The practical rule: put the fast, friendly checks in hooks and the checks you cannot live without in CI. Run the same script in both if you can.

## Try it

Here is the whole loop in six commands. The `.githooks` folder is already in the repository, with a `pre-commit` hook that refuses a `.env` file, and one is already staged. Type them one at a time.

**Practice terminal · share a hook with the team**

Type these commands in order, in a practice folder:

1. Tell Git to run hooks from the tracked .githooks folder. Try `git config core.hooksPath .githooks`. This is stored in your clone's .git/config, so every clone needs it once.
2. Check that the setting took. Try `git config core.hooksPath`. It prints `.githooks`.
3. Commit with a .env file staged and watch the hook refuse. Try `git commit -m "Add env"`. Git prints `pre-commit: refusing to commit a .env file.` and creates no commit.
4. Decide the file is fine and skip the hooks for this one commit. Try `git commit --no-verify -m "Add env anyway"`. `-n` is the short form. Git does not ask why.
5. Record the pre-commit script as executable, so teammates on Linux and macOS can run it. Try `git update-index --chmod=+x .githooks/pre-commit`. `git add --chmod=+x .githooks/pre-commit` does the same.
6. Confirm the mode. Try `git ls-files -s .githooks/pre-commit`. The mode column shows `100755`, where `100644` means not executable.

[Try the interactive Git practice terminal on the canonical article](https://mnixblog.netlify.app/hooks-you-will-actually-use/).


Two things worth trying in the terminal: run step 3 before step 1 and see what the commit does, and use `git commit -n` in step 4.

**Challenge: one more file type**

Change the `pre-commit` hook so it also refuses a `.pem` file, because a private key is no better than a password. Removing a `.pem` file from Git must still work.

**Expected result:** with `core.hooksPath` set, `git commit -m "Add key"` with `secret.pem` staged prints `pre-commit: refusing to commit a .env or .pem file.` and makes no commit. After `--no-verify` has let the file in, `git rm secret.pem` followed by `git commit -m "Stop tracking key"` goes through.

<details>
<summary>Answer</summary>

```bash
#!/bin/sh
# Stop a .env or .pem file from being added to a commit.
if git diff --cached --name-only --diff-filter=AM | grep -Eq "\.(env|pem)$"; then
  echo "pre-commit: refusing to commit a .env or .pem file."
  exit 1
fi
```


`-E` turns on extended patterns, so `(env|pem)` works. Keep `--diff-filter=AM`, or the commit that removes the key is refused too.

</details>

**Question:** You write a `pre-commit` hook in `.git/hooks`, push your work, and a teammate clones the repository. Does the hook run for them?

1. No, `.git` is not part of a clone, so they get only the sample hooks
2. Yes, hooks are committed along with the code
3. Yes, but only on Windows

**Answer:** 1. No, `.git` is not part of a clone, so they get only the sample hooks

> **Explanation:** In our test, a fresh clone had 14 `.sample` files and `core.hooksPath` was unset. A tracked folder plus `git config core.hooksPath` in each clone is how you share hooks.


**Question:** What does `git commit --no-verify` skip?

1. The `pre-commit` and `commit-msg` hooks for that commit
2. The commit message
3. Every check, including the ones in CI

**Answer:** 1. The `pre-commit` and `commit-msg` hooks for that commit

> **Explanation:** `--no-verify` only affects hooks on your machine. Checks that run on a server, such as CI, are not affected by a flag you pass locally.


**Question:** On Windows, you remove the executable bit from `.git/hooks/pre-commit` with `chmod -x`. What happened in our test?

1. Nothing. The hook still ran, because the file system keeps no executable bit
2. Git skipped the hook and printed a warning
3. Git deleted the hook

**Answer:** 1. Nothing. The hook still ran, because the file system keeps no executable bit

> **Explanation:** `ls -l` still showed `-rwxr-xr-x` afterwards and the hook still refused the commit. The bit matters for teammates on Linux and macOS, so record it with `git update-index --chmod=+x`.


## What we learned

- A hook is a script named after a Git moment (`pre-commit`, `commit-msg`) with no extension. A non-zero exit stops the action.
- Git for Windows runs hooks with its bundled shell, from Git Bash or PowerShell. The executable bit does not exist on the file system, but record it with `git update-index --chmod=+x` for teammates elsewhere.
- `.git/hooks` is not cloned. Put hooks in a tracked folder and run `git config core.hooksPath .githooks` once per clone.
- `git commit --no-verify` (or `-n`) skips `pre-commit` and `commit-msg`.
- A hook is a friendly early check on one machine. Anything the whole team must obey belongs in CI or on the server.

A hook that cannot be skipped is not a hook. It is a server.
> **Portable fallback:** The authored description of the demo is preserved; the live demonstration remains on the canonical article. Review: Review the authored description of the demo and keep the canonical link for the live version.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

> **Portable fallback:** The question and answer are preserved; scoring, retry, and remembered attempts are unavailable. Review: Review the static question and answer; keep the canonical link when scoring or retries matter.

[Join the discussion on the canonical article](https://mnixblog.netlify.app/hooks-you-will-actually-use/#ob-discussion)
