# git hook

## What it is

A git hook is a program that git itself starts at a fixed point in its own work:
before a commit is recorded, once a commit message has been written, before a
push leaves the machine, or when a server receives one. Each hook is a file
named after its moment, such as `pre-commit`, `commit-msg` or `pre-push`, with
no extension. By default git looks for them in `.git/hooks` inside the
repository, and the `core.hooksPath` setting points it elsewhere. The file has
to be executable, since git ignores one without the executable bit, but it can
be written in any language the machine runs.

Client-side hooks run in a developer's own clone and fire on operations such as
committing and merging. Server-side hooks run on the machine that receives
pushes, such as a forge, and fire when pushed commits arrive.

What turns a hook from a notification into a guard is its exit status. The
`pre-commit` hook takes no arguments, and a non-zero exit aborts the commit. The
`commit-msg` hook receives the path of a file holding the proposed message and
may reject the message or edit it. The `pre-push` hook gets the remote's name
and location as arguments and one line per reference on standard input, and a
non-zero exit stops the push before anything is sent. On the server,
`pre-receive` runs once per push, reads the old object name, new object name and
reference name for each reference, and if it exits non-zero, none of the
references are updated. Hooks that run after the event, such as `post-commit`,
are meant for notification and cannot change its outcome.

When `git init` creates a repository, it fills the hooks directory with example
scripts whose names end in `.sample`. git runs one only after it has been
renamed without the suffix.

## Why it matters

A hook turns a check that someone has to remember into one that happens on its
own, at the cheapest moment. A formatter or a secret scanner that runs as the
commit is made catches a problem while the change is still in its author's head,
rather than in review days later or in a CI run after the work has been shared.

Hooks are also how a rule becomes a property of a repository rather than of
whoever happens to be committing. A convention in a contributing guide holds
only as long as people read the guide; the same convention in a `commit-msg`
hook applies to every commit.

The two groups differ in how much they can promise. The Pro Git book points out
that cloning a repository does not copy its client-side hooks, and recommends
enforcing policy on the server side. A server-side hook such as `pre-receive`
applies to every push from everyone, whatever they have installed locally. The
cost is time: a hook runs on every commit or every push, and a check that makes
committing slow invites people to skip it.

## What it looks like in practice

This site's own repository installs its hooks through the pre-commit framework,
configured in `.pre-commit-config.yaml` and installed by `task setup`, which
runs `pre-commit install --install-hooks`. The file installs three git hooks. At
the pre-commit stage it runs Prettier, markdownlint, stylelint, yamllint,
codespell, Ruff's lint and format checks, a gitleaks secret scan, an emoji check
and a tone check. At the commit-msg stage it runs
one script that holds messages to a conventional, emoji-free form. At the
pre-push stage it runs the whole gate, `task check`, which a comment in the file
puts at about six minutes, worth paying because a push is rare. The file's
header says never to bypass the hooks with `--no-verify`, while the comment on
the pre-push hook keeps `--no-verify` as the escape for the case that genuinely
cannot wait.

A commit-message hook also holds
[four repositories to one history standard](/portfolio/held-five-repositories-to-one-history-standard-165/):
a conventional prefix and scope, a subject under a fixed length, no emojis and
no attribution trailers. The hook rejects anything that does not conform in
every repository that carries work, so the standard belongs to the repository
rather than to whoever commits.

[A server-side hook for the company's own forge was rehearsed](/portfolio/rehearsed-the-forge-side-ci-hook-151/)
before any real push reached it. Such a hook executes in a bare repository with
no working tree, under an environment the forge sets, reading pushed references
from standard input, so running it by hand on a laptop proves little. The
rehearsal creates a bare repository, installs the hook, clones, commits, pushes
and asserts on both the accept and the reject path. It found two defects that
were invisible in the file, both described below.

On one project,
[the commit hook and the quality gate turned out to run different checks](/portfolio/found-the-commit-hooks-and-the-gate-disagreeing-150/),
although a document said the hook ran the gate. A test now resolves both to the
checks they actually invoke and fails when the sets diverge. The five that had
run only in the gate were the ones reading the CV prose, so the code was guarded
on every commit and the writing only when someone remembered.

A client-side hook does not need a framework at all. This one, saved as
`.git/hooks/commit-msg` and made executable, rejects a commit whose subject does
not start with a conventional type:

```sh
#!/bin/sh
# git passes the path of the proposed commit message as $1
pattern='^(feat|fix|docs|chore)(\([a-z0-9-]+\))?: .+'
if head -n 1 "$1" | grep -Eq "$pattern"; then
    exit 0
fi
echo "commit-msg: start the subject with a type, as in 'fix(nav): ...'" >&2
exit 1
```

## Where it goes wrong

- **A fresh clone arrives without the hooks.** Each clone has to install them,
  which is the job `task setup` does here. A comment in this site's
  `Taskfile.yml` spells out the quiet version of the failure: without
  `pre-commit` on the machine, `task setup` installs nothing, every hook
  silently does not run, and the result looks exactly like a repository that is
  passing.
- **`--no-verify` walks straight past them.** `git commit --no-verify` bypasses
  the `pre-commit` and `commit-msg` hooks, `git push --no-verify` bypasses
  `pre-push`, and setting `core.hooksPath` to `/dev/null` turns off every hook
  at once. A rule that must hold belongs in a server-side hook as well.
- **git's environment follows the hook into other repositories.** git exports
  variables such as `GIT_DIR` so that git commands in a hook find the right
  repository, and githooks(5) advises clearing them when a hook works in another
  one. The forge rehearsal met exactly this: the gate checked out the pushed
  revision and ran the tests inside it, the tests inherited git's pointer to the
  bare repository, and nineteen of them went red on correct code.
- **Standard input carries the edge cases.** The rehearsal's other defect was a
  fallback that, with no reference to work with, substituted a placeholder git
  cannot resolve; because the value arrives on standard input, the failure
  surfaced as an unrelated error much further down. The page also notes that the
  forge will see forced updates and branch deletions, which the rehearsal does
  not construct. In githooks(5), a deleted reference reaches `pre-push` as
  `(delete)` with an all-zeroes object name.
- **A slow hook gets skipped.** Bringing the five prose checks into the commit
  hook meant making two of them fast enough for a pre-commit budget, and that
  page is plain that runtime grows with the content and that there is a point at
  which a slow hook gets bypassed.
- **The format passes and the meaning goes unchecked.** As the page on the four
  repositories puts it, the format is checked and the content is not, so a
  conforming subject line that describes nothing passes exactly as well as one
  that explains the change.

## Further reading

- [githooks documentation](https://git-scm.com/docs/githooks) — every hook git
  supports, when it runs, what it receives and what its exit status decides.
- [Pro Git: Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks)
  — client-side and server-side hooks, how to install one, and why a clone
  leaves them behind.
- [Pro Git: An Example Git-Enforced Policy](https://git-scm.com/book/en/v2/Customizing-Git-An-Example-Git-Enforced-Policy)
  — a server-side `update` hook that enforces a message format and access rules,
  with client-side hooks that warn before a push.
- [git config](https://git-scm.com/docs/git-config) — the `core.hooksPath`
  setting, for moving hooks out of `.git/hooks` or switching them all off.
- [git commit](https://git-scm.com/docs/git-commit) — the `--no-verify` option
  and the two hooks it bypasses.
- [git push](https://git-scm.com/docs/git-push) — the `pre-push` hook and the
  `--no-verify` option that skips it.

<https://engineer.company/glossary/git-hook/>
