Handbooks / Git / Chapter 6

Git Hooks & Automation

42 pages · ~72 min✓ Reviewed

Builds on History Archaeology.

Part 1 · Git Hooks & Automation

Enforcing Quality at Commit Time with Git Hooks

Every team has rules that live in a wiki and get broken anyway: run the linter, write commit messages in a consistent format, don't push a failing build. Git hooks turn those rules into scripts that Git itself runs at specific moments, such as just before a commit is recorded or just before a push leaves your machine. A rule that runs automatically on every commit does not depend on anyone remembering it, and that is the difference between a convention and a guarantee.

This chapter starts with how hooks work internally: where Git looks for them, how it decides to run them, and how a script's exit code can stop an operation. From there we build the everyday client-side hooks, pre-commit, commit-msg and pre-push. We then look at the server side, where checks run on the remote and cannot be skipped by the person pushing. Along the way we compare the two sides with a threat model, because a client-side hook is a convenience that a developer can bypass, while a server-side hook is real enforcement.

Once you have the mechanics, we move to the tooling most teams actually use: husky to share hooks through the repository, lint-staged to check only the files you changed, and commitlint to validate commit messages. By the end you will be able to write a working hook by hand, explain which checks belong on the client and which on the server, and set up a fast, shareable commit-time pipeline that catches problems before they reach code review.

Before you start

You need Git 2.9 or newer installed and a throwaway repository to experiment in (git init hooks-lab is enough). Be comfortable with git add, git commit and git push, and with reading a short shell script. For the tooling sections, Node.js 18 or newer and npm help, but no JavaScript project knowledge is required.

Part 2 · What Are Git Hooks?

Scripts Git runs for you

A Git hook is an executable script that Git runs automatically at a fixed point in its lifecycle: before or after a commit, around a push, during a merge or a rebase. You don't call the script yourself. Git looks for it, runs it, and lets its result decide what happens next.

Hooks live in the .git/hooks/ folder of each repository. When you run git init, Git fills that folder with sample files named like pre-commit.sample. They do nothing while they carry the .sample suffix. To activate one, rename it by dropping .sample and make sure it is executable.

bash
ls .git/hooks
# pre-commit.sample  commit-msg.sample  pre-push.sample ...
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit

Dropping .sample is the whole activation step

Git does not care what language a hook is written in. The file can be bash, Python, Node, Perl or a compiled binary. As long as it is executable and starts with a valid shebang line, Git runs it and then reads one thing: the exit code.

The contract between Git and a hook

The contract is tiny. An exit code of 0 means proceed. Any non-zero code means abort the operation. Whatever the hook prints is passed through to your terminal, which is how a hook explains why it said no.

What Git does after a hook runs
Hook resultGit's reactionWhat you see
Exit 0Carries on with the commit, push or mergeUsually nothing
Exit non-zeroStops the operationThe text the hook printed

Fixed names, local scope, two families

The names of hooks are decided by Git, not by you. A file called pre-commit fires before a commit, but a file called before-lunch never fires, because Git only looks for the documented set of names. You can add logic inside a hook, but you cannot invent new trigger points.

Hooks also have a sharing problem. The .git/ folder is never committed, so hooks are not versioned. A fresh git clone starts with only the inert .sample files and zero active hooks. Your teammates don't get your checks unless you give them another route. Later sections in this chapter cover how to do that.

Client-side and server-side

Hooks come in two families, depending on where they run. Client-side hooks run on your machine and fire per operation, such as a commit or a push. Server-side hooks run on the remote when it receives pushed work.

Client-sideServer-side
Runs onYour machineThe remote repository
Fires onYour own commit, push, merge, rebaseReceiving a push
Who controls itYou, and you can skip itWhoever runs the remote
Typical examplespre-commit, commit-msg, pre-pushpre-receive, update, post-receive

What people use hooks for

  • Lint code and auto-format it before a commit lands
  • Run a quick test suite before a push
  • Validate that commit messages follow a team convention
  • Reject large files before they enter history
  • Block merges or pushes to protected branches

A minimal hook, tried live

The smallest useful hook is a shell script that always fails. Put it at .git/hooks/pre-commit, mark it executable, and Git aborts every commit in that repository. The shebang line tells the system which interpreter runs the file.

bash
#!/bin/sh
echo "pre-commit: commits are blocked"
exit 1

Saved as .git/hooks/pre-commit, then chmod +x

output
$ git commit -m "try it"
pre-commit: commits are blocked
$ echo $?
1

The message came from the hook's own echo, and Git stopped because of the exit 1. Change that last line to exit 0 and the commit goes through. The same rule can be shown without a repository by running a hook body as a child process and reading its result the way Git does.

python
import subprocess, sys

hook = "import sys\nprint('lint failed: 2 errors')\nsys.exit(1)"
r = subprocess.run([sys.executable, '-c', hook], capture_output=True, text=True)

print('hook said:', r.stdout.strip())
print('exit code:', r.returncode)
print('git would', 'proceed' if r.returncode == 0 else 'abort')

A hook is just a program plus its exit code

output
hook said: lint failed: 2 errors
exit code: 1
git would abort
Hooks only fire through Git

A hook runs when a Git operation happens in that repository. GUI clients and wrapper tools usually call Git underneath, so they honor hooks. A bare script that edits files or writes objects without going through git does not trigger anything, and git commit --no-verify skips the commit hooks on purpose.

Seeding hooks with a template directory

There is one built-in way to spread hooks. When git init or git clone creates a repository, it copies a template directory into the new .git/. That is where the .sample files come from. If you point Git at your own template folder that contains a hooks/ directory, every new repository starts with your hooks already in place.

bash
mkdir -p ~/.git-templates/hooks
cp my-pre-commit ~/.git-templates/hooks/pre-commit
git config --global init.templateDir ~/.git-templates

Every future git init or git clone copies these hooks

How a template reaches a new repo
  1. 1Template folderyour hooks/ directory
  2. 2git init or clonereads init.templateDir
  3. 3New .git/hooks/copy, not a link

Two limits apply. The copy happens only at creation time, so repositories that already exist don't pick it up and later edits to the template don't reach old copies. Systems can also ship a system-wide template that applies to everyone without any config, but it has the same copy-once behavior.

Summary

Hooks are local quality gates with zero dependencies: just an executable and an exit code. Because they live outside version control and each developer can skip or never install them, the guarantee is weak. They are opt-out, not enforced.

Part 3 · Hook Mechanics & Internals

How Git Finds and Runs a Hook

A hook is just an executable file that Git looks up by name at a fixed moment in a command. By default Git looks in .git/hooks/, but that folder is never committed, so nobody else on the team gets your hooks. The setting core.hooksPath fixes this: it tells Git to look in a different directory instead, and that directory can live inside the tracked working tree.

bash
git config core.hooksPath .githooks
git config --global core.hooksPath ~/.git-hooks
git config --get core.hooksPath

Per-repo (default scope) and global forms. A relative path is resolved from the repo root.

The path replaces .git/hooks entirely; Git does not search both. A global value therefore applies to every repository on the machine that has no local override. This one setting is the basis of husky and most hook-sharing tools: they commit a folder of scripts and point core.hooksPath at it during install.

When Git fires a hook it runs the file as a child process. For most hooks the working directory is the repo root, so relative paths like src/ or package.json work. Server-side hooks in a bare repository are the exception: there the working directory is the repository directory itself. Git passes no arguments unless the hook type defines some, and that is where the differences between hooks start.

HookArgumentsstdinTypical use
pre-commitnonenoneLint or test the staged changes
commit-msg$1 = path to the message filenoneValidate or rewrite the message
pre-push$1 = remote name, $2 = remote URLone line per ref being pushedLast check before the network
post-commitnonenoneNotify, log, refresh a build

Two details trip people up. First, commit-msg gets a file path, not the message text, so the hook reads that file with cat or head, and it may edit the file to change the message. Second, pre-push receives its ref information on stdin, one line per ref in the form <local ref> <local sha> <remote ref> <remote sha>. A remote sha made only of zeros means the branch does not exist on the remote yet.

python
sample = """refs/heads/main aaa111 refs/heads/main bbb222
refs/heads/fix ccc333 refs/heads/fix 0000000
"""

for line in sample.splitlines():
    local_ref, local_sha, remote_ref, remote_sha = line.split()
    kind = "new branch" if set(remote_sha) == {"0"} else "update"
    print(f"{local_ref} -> {remote_ref}: {kind}")

Parsing the stdin lines a pre-push hook receives (sample text stands in for real stdin).

output
refs/heads/main -> refs/heads/main: update
refs/heads/fix -> refs/heads/fix: new branch
Common mistake

Reading stdin in a pre-commit hook, or expecting $1 in a post-commit hook. Check what the specific hook type supplies; a hook that waits on stdin nobody fills will hang.

Environment, Shebang and Failure Output

A hook inherits the environment of the process that ran git, plus variables Git adds itself, such as GIT_DIR and, during a commit, GIT_INDEX_FILE. This is why a hook that works in your terminal can fail somewhere else. A commit made from an editor, a GUI client or a CI runner often starts with a different PATH, so node, npx or a Python virtualenv you rely on is simply not found.

Where the hook runsWhat usually differsTypical symptom
Your terminalShell profile has loaded nvm, pyenv, etc.Works
GUI client or editorProfile may not be loaded, shorter PATHcommand not found
CI runnerMinimal image, tools not installedcommand not found or wrong version

The defence is to make a hook state its own assumptions. Check that each tool exists, call project-local binaries (such as node_modules/.bin) rather than global ones, and print the PATH when something is missing so the failure explains itself.

The first line of the file, the shebang, decides which interpreter runs the script. Use #!/bin/sh when you stick to plain POSIX syntax and want it to work almost everywhere. Use #!/usr/bin/env bash only if you need bash features such as arrays or [[ ]], and env finds bash wherever it is installed. A hook with no shebang behaves differently depending on the system: some fall back to a shell, others fail without a useful message. The file must also be executable (chmod +x), or Git skips it.

ShebangGood forWatch out for
#!/bin/shPortable, small scriptsNo bash-only syntax
#!/usr/bin/env bashScripts that need bash featuresBash must be on PATH
(none)Nothing; avoidBehaviour varies by system

Whatever a hook prints to stdout or stderr is shown to the person running Git, and a non-zero exit status is what aborts the operation. A hook that exits 1 without a word leaves the developer staring at a failed commit with no clue why. Print what failed and how to fix it, send the message to stderr, then exit 1.

bash
#!/bin/sh
# .githooks/pre-commit
if ! command -v npx >/dev/null 2>&1; then
  echo "pre-commit: npx not found on PATH ($PATH)" >&2
  exit 1
fi

npx --no-install eslint --max-warnings 0 . || {
  echo "pre-commit: lint failed. Fix the errors above or run: npm run lint -- --fix" >&2
  exit 1
}

Checks its own tools, names the problem and the fix, and exits 1.

Common mistake

Forgetting chmod +x on a hook you copied or generated. Git ignores a non-executable hook, so it looks as if the hook never fires. On Windows, set the bit with git update-index --chmod=+x <file> before committing the script.

Where a Hook Can Abort: The Commit Timeline

Each hook sits at a particular point in git commit, and that point decides what has already happened when the hook says no. Read the sequence below left to right. The first two aborts are the ones client-side hooks can cause; post-commit comes too late to stop anything.

git commit, step by step
  1. 1git commityou run it
  2. 2pre-commitabort point 1
  3. 3snapshotindex becomes a tree
  4. 4commit-msgabort point 2
  5. 5commit createdbranch ref moves
  6. 6post-commitcannot undo

pre-commit runs first, before the commit exists at all. If it exits non-zero, nothing is recorded and your staged changes stay exactly as they were, so you fix the problem and run the commit again. commit-msg runs later, after the snapshot of your index has been prepared and the message has been written, but before the commit object is built and before the branch ref moves. Aborting there throws the message away and leaves history untouched, though at most an unreferenced tree object is left behind in the object database, which Git cleans up on its own later.

HookRunsIf it exits non-zeroHistory changed?
pre-commitBefore the snapshot and messageCommit stops, index keptNo
commit-msgAfter the message exists, before the commit objectCommit stops, message discardedNo, the branch ref has not moved
post-commitAfter the ref movedExit status is ignoredYes, the commit stays

The practical rule is simple: use hooks that run before the ref moves to block things, and use post-commit only to react. A commit-msg script can also edit the message file in place, which is how some tools prepend a ticket number automatically.

bash
#!/bin/sh
# .githooks/commit-msg
msg_file="$1"
first_line=$(head -n 1 "$msg_file")

case "$first_line" in
  feat:*|fix:*|docs:*|chore:*) exit 0 ;;
esac

echo "commit-msg: subject must start with feat:, fix:, docs: or chore:" >&2
echo "  got: $first_line" >&2
exit 1

commit-msg reads the message from the file path in $1.

Skipping, Speed and Debugging

Anyone can switch a client-side hook off for a single command with git commit --no-verify (short form -n). It skips both pre-commit and commit-msg, and git push --no-verify skips pre-push. Because the hooks live on the developer's machine, the developer controls them: they can skip them, edit them, or never install them. Client-side hooks are therefore advisory, a convenience that catches honest mistakes early, not a security boundary.

bash
git commit -n -m "wip: skip the hooks this once"
git push --no-verify

Real enforcement has to happen where the client cannot interfere. Server-side hooks such as pre-receive and update run on the machine that receives the push, and they decide whether the push is accepted. A developer's --no-verify has no effect on them. A later section covers them in detail; the point to carry forward is that anything you truly must guarantee belongs on the server or in required CI checks.

Client-side hooksServer-side hooks
Runs onThe developer's machineThe remote that receives the push
Can be bypassed with --no-verifyYesNo
Best forFast feedbackEnforcement

Speed decides whether people keep hooks switched on. A pre-commit that takes thirty seconds is felt on every commit, and developers answer by adding --no-verify as a habit, which defeats the hook. Keep client hooks to a few seconds: check only staged files, run the fast linter instead of the full test suite, and leave the slow work to pre-push or CI.

When a hook misbehaves, take Git out of the picture and run the file yourself. Run it from the repo root, then read its exit status. Add sh -x to trace each line as it executes, which shows exactly which command failed and what the variables held. For commit-msg, pass a file path yourself, since Git normally supplies it.

bash
.git/hooks/pre-commit; echo "exit status: $?"
sh -x .githooks/pre-commit
echo "fix: typo" > /tmp/msg && sh -x .githooks/commit-msg /tmp/msg

Run the hook directly, trace it, and feed commit-msg a test message.

A hook misbehaves
Remember

Hooks are plain scripts with a defined start, arguments and abort point. Make them fast and loud about failures, and treat them as helpers; put the rules you cannot afford to lose on the server.

Part 4 · pre-commit Hook: Gating the Commit

Where pre-commit fires and what it can see

The pre-commit hook is the earliest gate on the client. Git runs it when you type git commit, before the message editor opens and before any commit object exists. If the script exits non-zero, nothing has been recorded yet, so stopping here costs nothing.

Git passes the hook no arguments. It does not hand you a list of files, so the script has to ask Git what is staged. The plumbing flag for that is --cached, which compares the index against HEAD.

bash
git diff --cached --name-only
# src/app.js
# src/styles/main.css
# README.md

Names of every file that is staged for this commit

The classic pattern builds on that list: filter it to the file types you care about and pass only those to the linter. Linting three changed files takes a moment, while linting the whole repository on every commit would make people avoid committing.

bash
#!/bin/sh
# .git/hooks/pre-commit
git diff --cached --name-only | grep '\.js$' | xargs -r eslint

xargs -r skips the run when no .js file is staged

The pipeline's exit status is that of its last command, so a lint failure from eslint becomes the hook's failure and the commit is aborted.

The auto-fix gotcha and what a failure does

Checking is easy. Fixing is where hooks go wrong. A formatter such as prettier --write rewrites files in your working tree, but the commit is built from the index. The fixed text sits in the working tree while the index still holds the old text.

bash
#!/bin/sh
# Broken: formats the working tree, commits the old index content
git diff --cached --name-only | grep -E '\.(js|css)$' | xargs -r npx prettier --write

The commit proceeds with unformatted content

The hook passes, the commit lands with the unformatted version, and afterwards git status shows the formatting changes as fresh unstaged edits. The fix is to stage the fixed files again inside the hook, after the formatter runs.

bash
#!/bin/sh
files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|css)$')
[ -z "$files" ] && exit 0
echo "$files" | xargs npx prettier --write
echo "$files" | xargs git add

Re-stage what the formatter changed

Common mistake: fixing without re-staging

A hook that runs prettier --write and exits 0 looks successful, but the commit contains the old content. Always git add the fixed files inside the hook, or make the hook fail so the developer re-stages.

Even the re-staging version has a hole. If you staged only some hunks of a file, git add stages the whole file, including edits you meant to leave out. Keeping staged and unstaged content apart while auto-fixing is exactly the problem lint-staged solves, and a later section covers it.

A failed pre-commit is safe. It only blocks the commit, and it never discards or reverts your changes, so you fix the problem and commit again.

Testing exactly what is staged

Developers often stage only some hunks with git add -p. Your tests then run against the working tree, which also contains the unstaged edits. The hook can pass on code that is not being committed, or fail on code that is. To test the staged snapshot, use git stash --keep-index inside the hook. It stashes everything and then restores the index content to the working tree, so the files on disk match what will be committed.

bash
#!/bin/sh
# Only stash when there is something unstaged to set aside
if ! git diff --quiet; then
  git stash push --keep-index --quiet --message pre-commit-tmp
  stashed=1
fi

npm test --silent
status=$?

[ -n "$stashed" ] && git stash pop --quiet
exit $status

Run the tests against the staged snapshot, then put the unstaged edits back

The git diff --quiet guard matters. If nothing is unstaged, git stash push creates no entry, and a later pop would remove an unrelated stash that someone saved earlier. The script also saves the test result in status and pops the stash before exiting, so your unstaged work always returns whether the tests pass or not.

The simplest useful gate is a small custom check. This skeleton refuses a commit that adds a TODO to any staged file. It searches the index with git grep --cached, so it reads the staged text and not whatever is on disk.

bash
#!/bin/sh
files=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$files" ] && exit 0

if echo "$files" | xargs git grep --cached -n 'TODO' --; then
  echo "pre-commit: remove the TODO markers above or move them to the tracker." >&2
  exit 1
fi

git grep exits 0 on a match, which means a TODO was found

What belongs in pre-commit, and the framework

Every commit pays the cost of this hook, so only checks that are quick and focused on the staged files belong in it. Anything slow trains developers to bypass it with --no-verify.

CheckFits pre-commit?Why
eslint / stylelint on staged filesYesTakes seconds, only reads changed files
Format check (prettier --check)YesDeterministic and fast
Block debugger and console.logYesOne grep over the staged diff
Secret scan (gitleaks)YesCatches a leaked key before it is ever in history
Fast unit testsYesOnly if they finish in seconds
Slow integration suitesNoBelongs in pre-push or CI
Full end-to-end runsNoBelongs in CI

Writing and sharing these scripts by hand gets tedious. The pre-commit framework, a Python tool, lets you declare hooks in a .pre-commit-config.yaml file. It decides which hooks apply to which files, and it builds and caches an isolated environment for each hook so teammates need no manual setup.

yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: end-of-file-fixer
      - id: check-added-large-files
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks

.pre-commit-config.yaml

bash
pre-commit install              # wires the framework into .git/hooks/pre-commit
pre-commit run --all-files      # run every hook over the whole repo, not just staged files

Run --all-files once when you adopt a new hook

Rule of thumb

pre-commit should take seconds, give the same answer every time, and report only errors the developer can fix and then commit again. Anything slower, flaky, or dependent on the network belongs in pre-push or CI.

Part 5 · commit-msg Hook: Enforcing Message Standards

Where commit-msg sits and what it receives

The commit-msg hook is the gatekeeper for the words of a commit. By the time it runs, pre-commit has already passed, so the content of the change is accepted and only the message is left to judge. Git writes the proposed message to a temporary file, usually .git/COMMIT_EDITMSG, and starts your hook with exactly one argument: the path to that file, available as $1.

Validation is plain file reading. Your hook opens $1, checks the text against whatever rules you care about, and decides the outcome with its exit code. Exit 0 and the commit is created. Exit with any non-zero status and Git aborts the commit, showing whatever your hook printed. Because the message is already in a file, a hook can even rewrite it before the commit is made, though most teams only validate.

bash
#!/bin/sh
msg_file="$1"

if ! grep -qE '^(feat|fix|docs|chore)(\(.+\))?: .+' "$msg_file"; then
  echo "commit-msg: use 'type(scope): description'" >&2
  exit 1
fi

A hand-written .git/hooks/commit-msg: print the reason on stderr, then exit non-zero to reject

The commit-msg hook is one stop in a longer sequence. Two neighbours matter for message handling: one that runs before you ever see the editor, and one that runs after the commit exists.

Hook order for one git commit
  1. 1pre-commitchecks the staged changes
  2. 2prepare-commit-msgbuilds the default message, before the editor opens
  3. 3Editor opensyou write or edit the message
  4. 4commit-msgvalidates the file at $1, can reject
  5. 5post-committhe ref has already moved
HookRunsCan stop the commit?Typical use
prepare-commit-msgBefore the editor opensYes, by exiting non-zeroInject an issue number or a squash hint into the default message
commit-msgAfter the message is writtenYes, this is its jobReject messages that break your format
post-commitAfter the branch ref has movedNo, it is too lateNotifications and metrics only

prepare-commit-msg is the one to reach for when you want to help rather than police. Since it fires before the editor, whatever it writes into the file shows up as the starting text, for example a ticket number taken from the branch name. post-commit receives nothing to veto: the ref has already moved, so it can only tell a chat channel about the commit or log a metric.

Conventional Commits: the format worth enforcing

A hook needs a standard to enforce, and the most common one is Conventional Commits. The header has the shape type(scope): description, where the type says what kind of change this is, the optional scope names the area touched, and the description is a short summary of the change. For example, feat(auth): add OAuth login is a new feature in the auth area.

The point of the rigid shape is that tools can read it. A feat implies a minor version bump, a fix implies a patch bump, and a ! after the type, as in fix(api)!: drop v1 route, marks a breaking change that implies a major bump. Because every message follows the same grammar, release notes can be assembled from history instead of being written by hand.

HeaderMeaningSemantic version effect
fix(cart): stop double-counting couponsBug fixPatch (1.4.2 to 1.4.3)
feat(auth): add OAuth loginNew featureMinor (1.4.2 to 1.5.0)
fix(api)!: drop v1 routeBreaking changeMajor (1.4.2 to 2.0.0)
docs: clarify install stepsDocumentation onlyNo release needed

Before reaching for a tool, it helps to see that the check is just pattern matching. The program below applies a simplified version of the rule to four headers, which is the same decision your shell hook makes with grep.

python
import re

PATTERN = re.compile(r"^(feat|fix|docs|chore|refactor|test)(\([a-z0-9-]+\))?!?: .{1,72}$")

def check(header):
    return "ok" if PATTERN.match(header) else "rejected"

for h in ["feat(auth): add OAuth login", "fixed stuff", "feat:", "fix(api)!: drop v1 route"]:
    print(f"{check(h):9} {h}")
output
ok        feat(auth): add OAuth login
rejected  fixed stuff
rejected  feat:
ok        fix(api)!: drop v1 route

There is a softer alternative: a commit template. Run git commit --template=<file> or set git config commit.template <file> and the editor opens pre-filled with a skeleton, perhaps with the type list written as comments. That is a helpful nudge, but nothing is checked. A developer can delete the skeleton and write anything, and Git will accept it. Templates guide; only a hook enforces.

Common mistake: treating a template as a rule

A commit.template gives people a skeleton, but it never rejects anything. If the standard matters, the commit-msg hook has to do the enforcing, with the template as an optional convenience on top.

commitlint: the ready-made validator

Writing and maintaining regular expressions gets old quickly, so most teams use commitlint, a linter built for exactly this job. You install the CLI plus a shared rule set, then tell it which rule set to extend. The config-conventional package encodes the Conventional Commits rules, so one line of configuration gives you a working standard.

bash
npm i -D @commitlint/cli @commitlint/config-conventional

Install as dev dependencies so every clone gets the same version

javascript
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };

The last step is wiring commitlint into Git. The commit-msg hook can be a single line that hands Git's $1 to commitlint through --edit. The --no-install flag tells npx to use the locally installed copy and fail instead of silently downloading something at commit time.

bash
#!/bin/sh
npx --no-install commitlint --edit $1

The whole commit-msg hook

Once the extended config is in place you can tune individual rules. A handful do most of the work in practice, and each takes a severity, a condition and sometimes a value.

RuleWhat it enforcesExample
type-enumOnly whitelisted types are allowedfeat, fix, docs, chore and nothing else
subject-caseCasing of the descriptionReject an all-caps or Sentence-case subject
subject-emptyA description must existRejects feat(auth): with nothing after it
header-max-lengthCaps the first line, usually 72 to 100 charactersKeeps the line readable in git log and on GitHub
javascript
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', ['feat', 'fix', 'docs', 'chore', 'refactor', 'test']],
    'header-max-length': [2, 'always', 72],
  },
};

Severity 2 means error, which blocks the commit; 1 would only warn

You do not need to make a throwaway commit each time you change the config. Pipe a candidate message straight into commitlint and it reports which rule failed, with no commit created.

bash
echo 'foo' | npx commitlint

Prints the failed rules, such as subject-empty and type-empty, and exits non-zero

Debug the config, not the history

Piping echo 'some message' | commitlint is the fastest loop for tuning rules. Try one good message and one bad message per rule before you commit the hook to the team.

Gotchas and the payoff

Message hooks run on a developer's machine, so they only see commits made there. Merge commits are a special case: Git generates their messages itself, in a form like Merge branch 'x', which does not follow your type grammar. Squash-merges are worse, because a button on a hosting site creates the final commit on the server, where no local hook ever runs. Either path can put a non-conforming message on the main branch.

Will my local commit-msg rules see this commit?

The practical fixes are small. Make your config tolerate messages that start with Merge, or relax the rules for merge commits, and for squash-merges set the hosting site to use the pull request title as the commit message and lint that title in CI.

Common mistake: assuming the hook sees every commit

A strict local hook can still be bypassed by squash-merges, merge commits and --no-verify. Strictness on the laptop does not replace a check on the server.

The effort pays off because clean history becomes input for automation. Tools such as standard-version and release-please read the commit log, work out the next version number from the feat, fix and breaking-change markers, and generate the CHANGELOG without anyone writing it by hand. Each rule you enforce in the hook is a guarantee that those tools can trust.

Remember

commit-msg receives one argument, the message file in $1, and rejects by exiting non-zero. Use commitlint to define the standard, a one-line hook to enforce it, templates to help, and the server to catch what the laptop cannot.

Part 6 · pre-push Hook: The Last Client Line

Where pre-push fires and what it receives

The pre-push hook runs during git push, after Git has talked to the remote and worked out what needs sending, but before a single object is transferred. Your local commits already exist, so the hook can inspect them. The remote has accepted nothing yet. That makes it the last client-side chance to stop a push.

Because the hook runs before the transfer, a non-zero exit rejects the whole push and also saves bandwidth. Nothing is uploaded, so a broken push costs you seconds rather than a slow upload followed by a failure. It is all-or-nothing: if you push three branches and the hook fails, none of them go.

Git passes the hook two arguments and feeds the details of the push on standard input.

InputWhat it holdsExample
$1The remote nameorigin
$2The remote URLgit@github.com:acme/app.git
stdinOne line per ref being pushed: local_ref local_sha remote_ref remote_sharefs/heads/main a1b2... refs/heads/main c3d4...

The four stdin fields tell you which local ref is going to which remote ref, and the commit each side points at right now. The difference between remote_sha and local_sha is exactly what this push would deliver.

Reading stdin safely: loops and zero SHAs

A single git push can update several refs, for example git push --all or git push origin main feat/login. So you must read stdin in a loop and never assume one branch. The standard shell form is while read local_ref local_sha remote_ref remote_sha; do ... done. Each pass handles one ref.

Inside that loop, always guard against all-zero SHAs, forty zeros. Git uses them to mean "no commit here":

Situationlocal_sharemote_shaSafe to run git show?
Normal updatereal commitreal commitYes, on either side
New branchreal commit0000...Only on local_sha
Branch deletion0000...real commitNo: there is nothing local to inspect

On a deletion, local_ref is the literal text (delete) and local_sha is zeros. Run git show $local_sha there and it fails on a hash that does not exist, which can crash the hook or block a legitimate delete. A new branch has no remote history, so a range like remote_sha..local_sha is meaningless for it. Handle each case on purpose.

What to do with each stdin line

The same logic in a runnable form. It classifies three fake stdin lines the way the hook would:

python
ZERO = "0" * 40

def classify(line):
    local_ref, local_sha, remote_ref, remote_sha = line.split()
    if local_sha == ZERO:
        return f"delete {remote_ref}"
    if remote_sha == ZERO:
        return f"new {remote_ref}"
    return f"update {remote_ref}"

lines = [
    f"refs/heads/main {'a1'*20} refs/heads/main {'b2'*20}",
    f"refs/heads/feat/login {'c3'*20} refs/heads/feat/login {ZERO}",
    f"(delete) {ZERO} refs/heads/old {'d4'*20}",
]
for line in lines:
    print(classify(line))
output
update refs/heads/main
new refs/heads/feat/login
delete refs/heads/old
Common mistake: assuming one branch

Reading only the first stdin line, or using read once without a loop, checks one ref and silently lets the others through. Always loop.

Common mistake: no zero-SHA guard

Calling git show $local_sha or git log $remote_sha..$local_sha without checking for zeros breaks on branch deletes and new branches, so the hook fails on pushes that are perfectly valid.

What to check, and how to stay fast

The classic use is running the full test suite before pushing, for example npm test. Broken code then never reaches CI or your teammates. Here is a complete hook that loops over refs, guards the zero SHAs, and prints what each push delivers:

bash
#!/bin/sh
remote="$1"
url="$2"
zero=0000000000000000000000000000000000000000

while read local_ref local_sha remote_ref remote_sha
do
  [ "$local_sha" = "$zero" ] && continue   # deleting a branch

  if [ "$remote_sha" = "$zero" ]; then
    # new branch: only commits the remote does not have yet
    git log --oneline "$local_sha" --not --remotes="$remote"
  else
    git diff --stat "$remote_sha..$local_sha"
  fi
done

npm test || exit 1

.git/hooks/pre-push

The diff range trick is the key line. git diff --stat $remote_sha..$local_sha shows exactly what this push delivers, so you can run targeted checks, such as testing only the packages that changed, instead of everything. The same range works for other jobs: lint every commit message with git log --format=%s $remote_sha..$local_sha piped into commitlint, or scan the changed files for large files before they are uploaded.

The trade-off is time. A full suite can take minutes, and under deadline pressure developers reach for git push --no-verify, which skips the hook entirely. A check people bypass protects nothing. Keep pre-push to the integration-critical checks: the fast subset that catches the failures you really care about.

Common mistake: a slow hook everyone bypasses

Putting a ten-minute test run in pre-push trains the team to use --no-verify by habit. Once it becomes habit, the hook stops working even when the code is genuinely broken.

The server can run the same checks in a pre-receive hook, but pre-push fails faster, before upload, and keeps the developer in flow. The two do different jobs: the client hook is a convenience, and the server hook is the real enforcement. The pre-receive hook gets its own section later.

LayerSpeedScopeRole
pre-commitFast, localStaged files: format, lintCatch typos instantly
pre-pushSlowerPushed commits: tests, integration checks, large filesStop bad pushes before upload
CISlowestEverything, in a clean environmentAuthoritative verdict
The rule of thumb

pre-commit is fast and local, pre-push is slower and integration-focused, and CI is authoritative. Client hooks can be skipped with --no-verify, so CI is the only one you can rely on.

Part 7 · Server-Side Hooks: Real Enforcement

Where server hooks live and the three that matter

Client hooks run on the developer's laptop, so the developer controls them. Server-side hooks run on the machine that receives the push, which is why they can enforce policy. They live in the hooks/ directory of the bare repository on the remote, for example /srv/git/project.git/hooks/. Git runs them as part of handling a push. Like client hooks, each one is a plain executable file with an exact name and no extension.

bash
ls /srv/git/project.git/hooks/
# pre-receive   update   post-receive   (make each one executable)
chmod +x /srv/git/project.git/hooks/pre-receive

Installing a hook on the server is just dropping an executable in the bare repo

Three hooks cover almost every server-side need. They differ in when they run and in how much of the push they see. Think of a push as a package arriving at a warehouse. pre-receive inspects the whole delivery at the door. update inspects each box separately. post-receive runs after everything is shelved and is where you tell people it arrived.

HookRunsSeesExit non-zero means
pre-receiveOnce per push, before any ref movesEvery ref update, as lines on stdinAbort everything: no ref in the push is updated
updateOnce per ref, before that ref movesOne ref, as three argumentsReject only that ref; the others can still succeed
post-receiveOnce per push, after all refs have movedEvery ref update, as lines on stdinNothing: it is too late to undo anything
Life of a push on the server
  1. 1Objects arriveStored in a quarantine area first
  2. 2pre-receiveOne verdict for the whole push
  3. 3update, per refOne verdict per branch or tag
  4. 4Refs moveThe push is now permanent
  5. 5post-receiveNotifications, deploys, CI
Gate hooks versus notification hooks

pre-receive and update are the gates: they can say no. post-receive is the notification hook: by the time it runs, the refs have already moved, so it can only react. Pick the hook by whether you need to block or only to tell someone.

pre-receive and update: one verdict or many

Git feeds pre-receive its input on stdin, one line per ref being updated, in the form old_sha new_sha refname. The hook reads every line, then decides. If it exits non-zero, Git throws away the entire push, even when 9 of the 10 refs were fine. One rejection rejects the whole push. That all-or-nothing behaviour suits rules about the push as a unit, such as total size or a blanket ban on a path.

bash
#!/bin/sh
# hooks/pre-receive
while read old new ref; do
  if [ "$ref" = "refs/heads/main" ]; then
    echo "Direct pushes to main are blocked. Open a pull request." >&2
    exit 1
  fi
done
exit 0

Any single line aimed at main sinks the whole push, including its other branches

The update hook is called once per ref and receives its data as three arguments instead of stdin. The order is easy to mix up: $1 is the refname, $2 is the old sha and $3 is the new sha. Exiting non-zero rejects only that ref. The other refs in the same push still go through, and the client sees a per-branch remote rejected message. This makes update the preferred home for per-branch policies.

bash
#!/bin/sh
# hooks/update  (args: refname old new)
ref="$1"; old="$2"; new="$3"

if [ "$ref" = "refs/heads/main" ]; then
  echo "main is protected: use a pull request" >&2
  exit 1
fi
exit 0

Pushing feature/x and main together: feature/x lands, main is refused

pre-receiveupdate
Inputstdin lines: old new refarguments: ref old new
GranularityWhole pushOne ref
On rejectionNothing is updatedOnly that ref is refused
Best forPush-wide rules (size, banned paths)Per-branch and per-tag policy

The zero SHA tells you what kind of update it is

An all-zero hash, forty 0 characters, stands for "no object". When old_sha is zero, the ref did not exist before, so this is a new branch. When new_sha is zero, the ref is being removed, so this is a deletion. Branch-deletion protection depends on this check, and so does any hook that runs git log old..new, which would fail on a zero sha.

bash
#!/bin/sh
zero=0000000000000000000000000000000000000000
while read old new ref; do
  if [ "$new" = "$zero" ]; then
    echo "Deleting $ref is not allowed" >&2
    exit 1
  fi
  if [ "$old" = "$zero" ]; then
    echo "New branch: $ref"
  fi
done

pre-receive: refuse deletes, notice new branches

Common mistake: running git log on a zero sha

Writing git log $old..$new without checking for zeros breaks on new branches (old is zero) and on deletions (new is zero). Test both values for the zero sha first, and use git rev-list $new --not --all to find the commits of a brand-new branch.

Real enforcement and the policies worth writing

Here is why server hooks matter. A developer can skip a client hook with git commit --no-verify or git push --no-verify, or can simply never install the hook. Nobody can pass --no-verify through a server hook, because the check runs on a machine the pusher does not control. The push is refused no matter what flags the client used. Branch protection actually happens here.

PolicyHookHow the check works
Forbid pushes to mainupdateCompare $1 with refs/heads/main and exit 1
Reject non-fast-forward pushesupdategit merge-base --is-ancestor $old $new fails when history was rewritten
Block large filespre-receiveList the new objects and compare their sizes with a limit such as 100 MB
Required commit-message formatupdateRead each new commit's message with git log --format=%B and match a pattern
Protect against branch deletionpre-receiveReject when new_sha is the zero sha
bash
#!/bin/sh
# hooks/update: reject non-fast-forward, unless it is a new branch
ref="$1"; old="$2"; new="$3"
zero=0000000000000000000000000000000000000000

[ "$old" = "$zero" ] && exit 0

if ! git merge-base --is-ancestor "$old" "$new"; then
  echo "Non-fast-forward push to $ref rejected" >&2
  exit 1
fi

Force pushes that rewrite history get stopped here

For large files, a handy trick is to look at the objects the push just delivered. During pre-receive they sit in a quarantine directory named by $GIT_OBJECT_DIRECTORY, so find "$GIT_OBJECT_DIRECTORY" -type f -size +100M finds oversized loose objects. Packed pushes are better handled with git rev-list --objects piped into git cat-file --batch-check, which reports each object's size. For commit messages, loop over the new commits and run the same pattern you used in the client commit-msg hook, now with no way around it.

Do you control the server?

Everything above needs write access to the bare repo's hooks/ directory. That means a self-hosted server. On GitHub.com you cannot install arbitrary hooks. Use the equivalents it offers instead: branch protection rules or rulesets, required status checks, and the API, or build a GitHub App that reacts to events. Self-hosted platforms do support real hooks.

PlatformServer hooks?What to use
GitHub.comNo arbitrary hooksBranch protection, rulesets, API, GitHub Apps
Plain git over SSHYesScripts in the bare repo's hooks/ directory
GitoliteYesRepo-specific and global hooks it manages
Gitea (self-hosted)YesGit hooks configured per repository by an admin
GitLab (self-hosted)YesCustom hooks directories; push rules in paid tiers
Prefer the platform's own rules when they exist

If branch protection can express your rule, use it. Write a custom hook only for policy the platform cannot state, such as a company-specific commit format or a file-size limit.

post-receive patterns, speed and the split of trust

post-receive: react after the push is done

post-receive runs once the refs have moved, and it reads the same old_sha new_sha refname lines on stdin as pre-receive. Nothing it does can undo the push, so it is the place for notifications and follow-up automation. The usual patterns are a deploy trigger that updates a server when main changes, a chat notification that tells a channel who pushed what, and a CI kick-off that calls the build system's webhook. Its exit status is ignored for acceptance. Exiting non-zero does not reject anything, because it is too late. The script should never rely on that, so end it with exit 0 and treat failures as something to log.

bash
#!/bin/sh
# hooks/post-receive
while read old new ref; do
  [ "$ref" = "refs/heads/main" ] || continue

  # chat notification
  curl -s -m 5 -X POST -d "{\"text\":\"main moved to $(echo $new | cut -c1-7)\"}" \
    "$CHAT_WEBHOOK" >/dev/null || echo "notify failed" >&2

  # CI kick-off and deploy trigger run in the background
  curl -s -m 5 -X POST "$CI_URL/build?ref=$new" >/dev/null &
done
exit 0

Notify chat, start CI, return quickly. A failure is logged, not fatal

Keep server hooks fast and crash-proof

A gate hook sits in the path of every push to that repository. If a pre-receive hangs on a slow network call or loops forever, every developer's push blocks until it finishes, and a crashing script rejects pushes that were perfectly fine. Keep gate hooks to quick local checks, put a timeout on any network call, and push slow work into post-receive or an external job.

Common mistake: slow checks in pre-receive

Running a full test suite or a remote lookup inside pre-receive makes every push wait on it, and a hang blocks the whole team. Gate on cheap checks and let CI run the expensive ones after the push.

The split of trust

Client hooks are convenience and feedback: they catch mistakes early and can be skipped. Server hooks are policy and enforcement: they run where the developer has no control, so they are the rules that actually hold. Use both, and never rely on a client hook alone for anything that matters.

Part 8 · Client vs Server: Comparison & Threat Model

Where each kind of hook runs

A hook is the same mechanism on both sides: Git runs an executable and treats a non-zero exit as a veto. What differs is where and when it runs. Client hooks live in your own clone and fire as you work, before a local operation such as a commit finishes. Server hooks live in the remote repository and fire during receive-pack, the process that accepts a push.

One change travelling from editor to shared branch
  1. 1git commitpre-commit, then commit-msg
  2. 2git pushpre-push, last client check
  3. 3receive-pack startspre-receive sees the whole push
  4. 4Per-ref checkupdate runs once per branch or tag
  5. 5Refs movepost-receive observes only

Everything in the blue steps happens on a machine you do not control. Everything in the orange steps happens on a machine the team does control. That single difference drives every row of the comparison below.

Client hooksServer hooks
Trigger pointBefore a local operation completes (commit, rebase, push)During receive-pack, on the remote, before refs are updated
Bypass--no-verify, or simply never installedCannot be skipped by anyone whose commits arrive via push
AudienceThe individual developer, fast feedback while in flowThe whole team, a guaranteed policy
Latency toleranceMust finish in well under a few secondsA bit more budget, but a slow hook holds the pusher and the server
EnvironmentUnpredictable: any OS, any tool version, maybe nothing installedOne controlled environment, so dependencies stay working
Read the table as a trade

Client hooks buy speed and a friendly place to fail. Server hooks buy certainty. Neither one replaces the other, which is why the rest of this section is about stacking them.

Bypass: why client hooks are advice, not law

A client hook is a file inside the developer's own .git/hooks directory, and .git is never part of what git clone copies. So a fresh clone starts with no hooks at all unless the team arranges installation. Even when a hook is installed, the person who owns the clone can step around it with a single flag.

bash
git commit -m 'wip' --no-verify   # skips pre-commit and commit-msg
git push --no-verify              # skips pre-push
rm .git/hooks/pre-commit          # or just delete the hook

Three ways a client hook stops mattering

A server hook has no such escape for push traffic. The pusher never sees the hook file, cannot pass a flag that disables it, and cannot choose to skip installing it. If the hook exits non-zero, the push is refused and no refs move. This is the core of the threat model: anything a careless or hurried person might skip must be checked where they cannot skip it.

The sketch below lists four layers and whether the person pushing can switch each one off. The point is the shape of the answer, not the code.

python
layers = [
    ('pre-commit', 'client', True),
    ('pre-push', 'client', True),
    ('pre-receive', 'server', False),
    ('CI', 'server', False),
]
for name, side, skippable in layers:
    status = 'skippable' if skippable else 'enforced'
    print(f'{name:<12}{side:<8}{status}')
output
pre-commit  client  skippable
pre-push    client  skippable
pre-receive server  enforced
CI          server  enforced
Common mistake: the only check is in pre-commit

If a rule such as no secrets or passing lint lives only in pre-commit, it is not enforced at all. One --no-verify, or one teammate who never installed the hook, and the bad commit reaches the shared branch. Put the same check in CI and, where it is cheap enough, in a server hook.

Defense in depth: stacking the layers

Because each layer has a different weakness, the sound design is a stack. Early layers are fast and easy to skip. Later layers are slower and cannot be skipped. A mistake that slips past one layer should be caught by the next.

LayerSpeedCan be skipped?Best used for
pre-commitUnder a second or twoYesFormatting, lint on staged files, secret scan
pre-pushA few secondsYesFast unit tests, type check
Server hookSeconds, still blocks the pushNoBranch protection, message format, forbidden files
CIMinutes are fineNoFull test suite, builds, integration, security scans

CI is not a hook, but it plays the same role asynchronously. It runs after the push has already been accepted, so it can afford suites that take many minutes. The price is that it cannot reject the push. When CI finds a problem on a shared branch, the team's remedy is a revert-merge: a new commit that undoes the bad change. History is corrected forward, not by refusing the push.

Duplicate on purpose

Running the same lint in pre-commit, CI and the server looks wasteful. It is the design. The local copy saves a round trip, and the later copies are the ones that actually count.

All the major hooks, and the rule for choosing

The table lists the hooks you are most likely to meet, split by side. The last column matters most: whether a non-zero exit can stop the operation.

HookSideWhen it firesCan veto?
pre-commitClientBefore the commit object is createdYes
prepare-commit-msgClientAfter the default message is built, before the editor opensYes
commit-msgClientAfter the message is writtenYes
pre-rebaseClientBefore a rebase startsYes
pre-pushClientAfter contacting the remote, before objects are sentYes
pre-receiveServerFirst thing in receive-pack, sees every ref in the pushYes, rejects the whole push
updateServerOnce for each ref being updatedYes, rejects just that ref
post-commit, post-mergeClientAfter the commit or merge has happenedNo
post-receiveServerAfter all refs have been updatedNo

The post-* hooks are different in kind. They run after the work is done, so their exit code cannot undo anything. Use them to observe and react: send a notification, trigger a deploy, refresh a cache. Never use them to enforce a rule, because by the time they run the rule has already been broken.

One hook deserves a warning of its own. pre-rebase receives the upstream and the branch being rebased, and the sample Git ships refuses to rebase a branch whose commits have already reached a published branch. The risk it flags is rewriting shared history: rebased commits get new hashes, and everyone who already pulled the old ones is left with diverged copies.

Common mistake: enforcing in a post hook

A post-receive script that rejects bad commits does nothing useful. The refs have already moved and its exit status is ignored for that purpose. Move the check into pre-receive or update.

To decide where a given check belongs, apply one rule: pick the latest hook, the one closest to the truth, that is still cheapest while catching the class of error you care about.

Where should this check live?

Part 9 · Sharing Hooks Across a Team

Why a Clone Delivers No Hooks

Hooks live in .git/hooks, and everything inside .git is local repository data that Git never tracks or pushes. So a git clone hands you the code, the history and the branches, but zero hooks. Whatever checks you wrote on your own machine stay there, and every teammate would have to copy the scripts in by hand and remember to do it again after each change.

What a fresh clone actually gets
  1. 1git clonefetches tracked files and history
  2. 2.git/hooksonly *.sample files, all inactive
  3. 3git commitno checks run at all

The fix is always the same in spirit: keep the hook scripts in a tracked place inside the repository, then make Git look there. The options below differ in how that last step, the wiring, happens.

OptionWhere hooks liveWiring stepBest fit
Tracked dir.githooks/Each dev runs git config core.hooksPath .githooks onceAny project, minimal tooling
husky.husky/Automatic through an npm lifecycle scriptNode and JS projects
pre-commit framework.pre-commit-config.yamlpre-commit install once, environments built on first runPolyglot and Python teams
Template dir~/.git-templates/hooksCopied into .git/hooks on every future clone or initPersonal or company-wide defaults

Option 1: a tracked directory plus core.hooksPath

The simplest approach is to commit your scripts into a folder such as .githooks/ and tell Git to use it instead of .git/hooks. It needs no dependencies at all. The one catch is that the config setting is stored in each developer's local .git/config, so every person must run the command once after cloning.

bash
mkdir .githooks
cp my-pre-commit.sh .githooks/pre-commit
chmod +x .githooks/pre-commit
git add .githooks
git commit -m "Add shared pre-commit hook"

# every teammate, once per clone:
git config core.hooksPath .githooks

The scripts are tracked; the config line is not, so it must be run per clone.

Common mistake

Committing .githooks/ and assuming the job is done. Without the core.hooksPath command on each machine, the folder is just ordinary files and nothing ever runs.

husky, pre-commit and Template Dirs

Option 2: husky

husky is an npm package that closes the wiring gap for JavaScript projects. It sets core.hooksPath to .husky/ and registers itself in a prepare lifecycle script in package.json, so a plain npm install configures Git on every teammate's machine without anyone remembering a step. It is the de facto standard in the JS world and supports all client-side hook types.

Usage is short. You initialise once, then each file in .husky/ is a hook named after the Git event, containing ordinary shell commands. A typical pre-commit simply hands staged files to lint-staged, which the next section covers in depth.

bash
npx husky init

# .husky/pre-commit now contains the line below; edit it as needed:
npx lint-staged

# other hooks are just more files, e.g. .husky/commit-msg, .husky/pre-push

Commit the whole .husky/ folder and the package.json change.

Option 3: the pre-commit framework

The pre-commit framework is a Python tool where the team commits one YAML file, .pre-commit-config.yaml, listing hooks by repository and version. On first run it clones those hook repositories and builds an isolated environment for each, so nobody installs linters globally. Its hooks are language-agnostic, which makes it a natural fit when the project is not purely JavaScript.

bash
pip install pre-commit
pre-commit install     # writes the shim into .git/hooks/pre-commit
git commit -m "try it"  # first run builds the hook environments

Each dev runs the install step once; the YAML config is shared through Git.

Option 4: a Git template directory

Git copies the contents of its template directory into .git whenever you run git init or git clone. If you point init.templateDir at a folder holding a hooks/ subfolder, every future repository starts with those hooks installed. This is handy for personal or organisation-wide defaults, but it is not retroactive: clones that already exist keep their old .git/hooks until you re-run git init inside them.

bash
mkdir -p ~/.git-templates/hooks
cp pre-commit ~/.git-templates/hooks/
chmod +x ~/.git-templates/hooks/pre-commit
git config --global init.templateDir ~/.git-templates

# existing clone: re-copy the template in place (safe, keeps your history)
git init
Common mistake

Expecting init.templateDir to fix repositories you cloned last month. It only seeds new clones and inits, and it lives in one person's global config rather than in the project.

Bootstrap Scripts, Review and Verification

A bootstrap script for the plain approach

If you choose Option 1, remove the reliance on memory by committing a tiny setup script that performs the config step, and mention it in the README's getting-started section. New teammates then run a single documented command, and the same script can later grow to install other tooling.

bash
#!/bin/sh
# setup.sh - run once after cloning
set -e
git config core.hooksPath .githooks
chmod +x .githooks/*
echo "Git hooks enabled from .githooks"

Commit this file and add a line to the README: run ./setup.sh after cloning.

Hooks ship like code

The biggest win of keeping hooks in the repository is versioning. A change to a hook is now a normal diff: it goes through a pull request, gets reviewed, is tied to a commit, and can be reverted. Everyone receives the update on their next git pull, and old branches carry the hooks that matched their era.

Verify the wiring

After any setup, check that Git is really pointed at your folder. The command below should print your directory. If it prints nothing, Git is still using .git/hooks, and everything in .githooks/ is dead weight.

bash
git config core.hooksPath
output
.githooks
Are my shared hooks active?

Gotchas, Security and Choosing a Tool

Path gotcha

When you set core.hooksPath locally, a relative value such as .githooks is resolved from the repository root, which is what you want. Some tool setups, such as GUI clients, IDE integrations or worktrees launched from another directory, behave differently and need a more explicit path. The practical rule is to test after cloning, from the tool people actually use, by making a commit that should be rejected.

Common mistake

Trusting that the hook works because it worked in your terminal. Make a deliberately failing commit from the IDE or Git client as well, and confirm it is blocked.

Security: hooks are code that runs on other people's machines

A hook in a tracked repository is arbitrary code that executes on every contributor's computer, with their permissions, the moment they commit. A malicious or careless change to .githooks/, .husky/ or a hook entry in .pre-commit-config.yaml can read files or send secrets anywhere.

Picking a tool for your project

husky assumes Node and npm, so it makes sense only where npm install is already part of everyone's routine. In a monorepo, or any project mixing languages, prefer the pre-commit framework or plain core.hooksPath, which do not tie Git behaviour to one ecosystem's package manager.

Project shapePreferReason
Pure JS or TS apphuskynpm install wires it automatically
Python or mixed languagespre-commit frameworkLanguage-agnostic hooks with managed environments
Monorepo, many stackspre-commit framework or core.hooksPathNo dependency on Node tooling
Tiny repo, no tooling.githooks/ with setup.shZero dependencies, easy to read
Key idea

Hooks reach teammates only if they are tracked in the repo and Git is pointed at them. Pick a wiring method, verify it with git config core.hooksPath, and review every hook change as carefully as CI.

Part 10 · commitlint + lint-staged Patterns

lint-staged: Check Only What You Staged

A plain pre-commit hook that runs eslint . over the whole repository has two problems. It gets slower as the project grows, and it fails your commit because of files you never touched, such as a colleague's half-finished work or an old module nobody has cleaned up. lint-staged fixes this by asking Git which files are staged and running your tools on just those files.

Full-repo hooklint-staged
Files checkedEverything trackedOnly staged files
SpeedGrows with the repoGrows with the size of the commit
Blocked byAnyone's old or WIP problemsOnly problems in your change
Typical commandeslint .eslint --fix file1.ts file2.ts

Setup takes two steps. Install both tools as dev dependencies, let husky create its folder, then make the pre-commit hook call lint-staged. The hook file is a plain shell script, and husky takes care of pointing Git at it.

bash
npm i -D lint-staged husky
npx husky init

Installs both tools and creates .husky/pre-commit

bash
# .husky/pre-commit
npx lint-staged

The whole hook is one line

The configuration maps a glob to a list of commands. It can live in package.json under a lint-staged key, or in lint-staged.config.js. For every glob that matches at least one staged file, lint-staged runs the commands in order and appends the matching file names to each one.

json
{
  "lint-staged": {
    "*.{js,ts}": ["eslint --fix", "prettier --write"]
  }
}

Staged a.ts and b.js? Runs: eslint --fix a.ts b.js, then prettier --write a.ts b.js

Fixers such as eslint --fix and prettier --write rewrite files after you staged them. Normally that causes the classic pre-commit gotcha: the commit records the old, unfixed version, while the fixed version sits unstaged in your working tree. lint-staged adds the modified files back to the index once the commands finish, so the commit contains the fixed content.

The commands themselves follow a simple contract. Each one must exit 0 on success. If any command exits with a non-zero code, lint-staged stops, the commit is aborted, and the repository is restored from an automatic backup it took before it began. Your staged and unstaged work comes back exactly as it was.

What lint-staged does on each commit

Chaining Patterns and Useful Flags

Real projects use several globs side by side, one per file type. Each glob has its own command list, and the commands inside a list run one after another. Put fixers first and checkers last, so a formatter can repair what a linter would otherwise reject. Languages other than JavaScript work in exactly the same way.

js
// lint-staged.config.js
export default {
  '*.{js,ts}': ['eslint --fix', 'prettier --write'],
  '*.{css,scss}': ['stylelint --fix'],
  '*.{json,md}': ['prettier --write'],
  '*.py': ['black', 'flake8'],
};

black formats first, flake8 then checks the formatted result

Two options matter once your repository is not a typical single-package project. Some tools complain when a glob matches nothing, which happens in sparse repos where a commit may touch only one language. Others need file paths relative to the current directory, because by default lint-staged hands them absolute paths.

OptionWhere it goesUse it when
--no-error-on-unmatched-patternPassed to the tool itself, for example eslint or prettierThe tool fails when none of the given patterns match a file
--relativePassed to lint-stagedA tool needs paths relative to the working directory, not absolute ones
json
{
  "lint-staged": {
    "*.{js,ts}": "eslint --fix --no-error-on-unmatched-pattern"
  }
}
bash
# .husky/pre-commit
npx lint-staged --relative

Relative paths for tools that resolve config from the current directory

Keep commands fast

Anything in a lint-staged list runs on every commit. Put quick fixers and linters here, and leave slow work such as the full test suite for the pre-push hook.

commitlint Rules and the Full Stack

commitlint checks the commit message the way lint-staged checks the code. Its configuration is a set of rules, and every rule is an array of three parts: a level, an applied condition, and a value. The level is 0 (off), 1 (warning) or 2 (error). The condition is 'always' or 'never'. The value depends on the rule, and is often a list.

js
// commitlint.config.js
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [2, 'always', ['feat', 'fix', 'chore', 'docs']],
    'subject-case': [2, 'never', ['upper-case']],
  },
};

[level, applied, value]. In the older string form, 'error' means level 2.

PartAllowed valuesMeaning
level0, 1, 2Off, warning, error. Only errors reject the commit
appliedalways, neverWhether the value must hold or must not hold
valueRule-specificA list, number or word that the rule compares against

In a monorepo, you also want to know which workspace a commit belongs to. The scope-enum rule restricts the scope in feat(api): ... to a list of package names. History then stays filterable, for example with git log --grep='(api)', and a typo like feat(apii) is rejected instead of silently creating a new scope.

js
rules: {
  'scope-enum': [2, 'always', ['api', 'web', 'shared', 'docs']],
  'scope-empty': [2, 'never'],
}

scope-empty makes the scope mandatory, so every commit names a workspace

All the pieces fit together in one npm install. husky owns the three Git hooks, and each hook hands its job to one tool. The commit-msg hook receives the path of the message file as its first argument, which commitlint reads with --edit.

Full stack, wired by husky
  1. 1pre-commitnpx lint-staged: lint and fix staged files
  2. 2commit-msgcommitlint --edit: check the message
  3. 3pre-pushnpm test: run the tests
bash
npm i -D husky lint-staged @commitlint/cli @commitlint/config-conventional

# .husky/pre-commit
npx lint-staged

# .husky/commit-msg
npx --no -- commitlint --edit $1

# .husky/pre-push
npm test

Three hook files, one dependency install

Common Mistakes and the CI Double-Check

Most failures of this setup are not loud errors. They are hooks that quietly do nothing, so a team believes it is protected while bad commits keep going through. The three mistakes below account for most of those cases.

Missing peer packages

extends: ['@commitlint/config-conventional'] needs that package installed next to @commitlint/cli. Install both, or commitlint fails with a module-not-found error and the commit-msg hook never validates anything.

Config in the wrong place

commitlint looks for commitlint.config.js starting from the directory it runs in, and in a monorepo that is not always the root. Keep the file at the repository root and check that its module format matches your package.json type setting, using .cjs when you use require.

Hooks inert because of core.hooksPath

husky works by setting core.hooksPath. If an old install left it pointing at a .husky folder that has since moved or was deleted, or a global setting overrides it, Git looks in the wrong place and runs nothing, with no error. Check the value and reset it.

bash
git config --show-origin core.hooksPath
git config --unset core.hooksPath
npx husky

See where the setting comes from, remove the stale value, let husky set it again

Even a healthy setup can be skipped, because local hooks are only a convenience. Anyone can run git commit --no-verify, and squash merges create messages on the server that never saw your hooks. The fix is to run commitlint again in CI over the range of commits the branch adds, so the check happens where nobody can bypass it.

yaml
# .github/workflows/commitlint.yml (steps)
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
- run: npm ci
- run: npx commitlint --from origin/main --to HEAD --verbose

fetch-depth: 0 gives CI the full history, so origin/main and the range both exist

Two layers, one standard

lint-staged and commitlint give developers quick feedback at commit time. The CI run enforces the same rules where they cannot be bypassed.

Part 11 · Pitfalls, Trade-offs & Closing Summary

Mistakes 1-4: Speed, Staging, Setup and Trust

Most failed hook setups die the same way: the hook is annoying, people route around it, and nobody notices. The first four mistakes are about that slide from enforcement to decoration.

Mistake 1: slow hooks

A pre-commit hook that runs a 30-second test suite fires on every single commit, and commits are meant to be small and frequent. Developers quickly learn git commit --no-verify, then teach it to the next person, and soon the whole team skips the hook. At that point you have zero enforcement plus a false sense of safety. The cure is a time budget per stage, with the heavy work pushed outward to where waiting is acceptable.

StageTime budgetWhat belongs there
commit-msgInstantMessage format check
pre-commitA few secondsFormat and lint staged files only
pre-pushTens of secondsIntegration-critical tests, smoke checks
CIMinutesFull test suite, builds, security scans
Common mistake: the 30-second pre-commit

Running the whole test suite in pre-commit trains everyone to type --no-verify. Keep pre-commit to seconds, give pre-push a slightly longer budget for the few tests that guard integration, and leave the full suite to CI.

Mistake 2: auto-fixing without re-staging

A hook that runs a formatter rewrites files in the working tree, but the commit is built from the index. If the fixed files are never added back, the commit still contains the unformatted version even though the hook proudly reported that Prettier ran. Your working tree looks clean while the history holds the broken code. Either re-stage inside the hook or, better, let lint-staged do it.

sh
#!/usr/bin/env bash
# .githooks/pre-commit
files=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$files" ] && exit 0

npx --no-install prettier --write $files
git add $files   # re-stage the fixed versions (git add -u also works)

Without the last line, the commit keeps the pre-formatting content.

The git add $files line limits re-staging to files that were already staged. A blanket git add -u would also stage unrelated tracked edits you deliberately left out of the commit, which is why lint-staged, which re-adds only the files it touched, is the safer default.

Mistake 3: hooks that never reach teammates

Files in .git/hooks are not versioned, so a fresh clone has no hooks at all. Even a committed .githooks/ directory does nothing until each clone runs git config core.hooksPath .githooks. If that step lives only in someone's memory, half the team is silently unprotected. Put the bootstrap in the onboarding docs and, ideally, in a command that runs automatically.

sh
# README: first-time setup
git config core.hooksPath .githooks

# or automate it, e.g. in package.json:
# "scripts": { "prepare": "git config core.hooksPath .githooks" }

One line in the setup docs, or a script that runs on install.

Mistake 4: relying on client hooks for critical policy

Client hooks run on a machine you do not control. One --no-verify, one fresh clone with no bootstrap, or one edit through the web UI, and the check never runs. Treat client hooks as fast feedback, not as the lock. Any rule that truly matters, such as no secrets, no force-push to main or required tests, must be mirrored in CI or in a server-side hook where it cannot be skipped.

Common mistake: a policy that exists only on laptops

If the only thing stopping a bad commit is a local hook, then the policy is a suggestion. Duplicate every critical check in CI or a pre-receive hook.

Mistakes 5-7: Portability, Edge-Case SHAs and Silent Failures

Mistake 5: hooks that break on other environments

A hook written on one laptop quietly assumes that laptop: a bash installed at a fixed path, macOS-only commands, or a globally installed tool at some particular version. On a teammate's Linux machine, a Windows Git Bash shell or the CI runner, the hook crashes or, worse, behaves differently. Hooks are code that runs on every environment your team uses, so write them for the lowest common denominator, test them on Linux, and pin the tools they call.

Fragile habitPortable version
#!/bin/bash with a hardcoded path#!/usr/bin/env bash, which finds bash wherever it lives
sed -i '' or pbcopy, which only work on macOSAvoid in-place sed and OS-specific tools, or branch on uname
A globally installed prettier of unknown versionnpx --no-install prettier with the version pinned in package.json and the lockfile
Absolute paths such as /Users/ana/projectgit rev-parse --show-toplevel for the repo root
Only ever run on the author's machineRun the same hook script in a Linux CI job
sh
#!/usr/bin/env bash
set -euo pipefail

cd "$(git rev-parse --show-toplevel)"
# prettier is pinned in devDependencies, so every machine runs the same version
npx --no-install prettier --check $(git diff --cached --name-only --diff-filter=ACM)

No fixed bash path, no macOS-only tools, and a pinned formatter.

Mistake 6: crashing on all-zero SHAs

The pre-push and pre-receive hooks read lines of old-sha new-sha ref from stdin. When a branch is created, the old (remote) SHA is forty zeros, because the ref did not exist. When a branch is deleted, the new (local) SHA is forty zeros. Feeding those to git log or git rev-list as a real commit produces a fatal error, and your hook either crashes or blocks a perfectly valid push. Always test for the null SHA first.

sh
#!/usr/bin/env bash
# .githooks/pre-push
zero=0000000000000000000000000000000000000000

while read -r local_ref local_sha remote_ref remote_sha; do
  [ "$local_sha" = "$zero" ] && continue          # branch delete: nothing to check
  if [ "$remote_sha" = "$zero" ]; then
    range="$local_sha --not --remotes"            # new branch: only its new commits
  else
    range="$remote_sha..$local_sha"               # normal update
  fi
  git rev-list $range | while read -r c; do
    # inspect commit $c here
    :
  done
done

Both zero-SHA cases are handled before any range is built.

Common mistake: testing only the happy path

A hook that works on a normal push can still blow up on the very first push of a new branch or on a delete. Try both before you ship the hook.

Mistake 7: rejecting with no message

A hook that exits with status 1 and prints nothing leaves the developer staring at a refused commit with no idea why. Every rejection should answer two questions: what failed and how to fix it. Write to stderr so the text shows up in every client, and name the exact command to run.

sh
if ! npx --no-install prettier --check $files >/dev/null 2>&1; then
  echo "pre-commit: some staged files are not formatted." >&2
  echo "Fix: run 'npx prettier --write .' then 'git add -u' and commit again." >&2
  echo "Urgent and sure it is safe? 'git commit --no-verify' skips this check." >&2
  exit 1
fi

The reason, the fix and the escape hatch, in three lines.

Trade-offs, When to Skip and How Setups Evolve

The trade-offs you are actually choosing between

Hooks are not free safety. Each check adds a little friction, and each shortcut around friction costs a little certainty. The table lays out the three tensions you will balance, and where each design tends to land.

TensionLean one wayLean the other wayTypical balance
Friction vs safetyMore checks catch more problems earlyMore checks mean more waiting and more --no-verifyCheap, high-signal checks on commit; heavier ones later
Instant feedback vs CI authorityLocal hooks answer in seconds, before a pushCI is the one result nobody can skipHooks for speed, CI and server rules for the final word
Per-file speed vs full-repo certaintyChecking only staged files is fastOnly a full run catches cross-file breakageStaged files at commit, a focused set at push, everything in CI

When to skip hooks entirely

Hooks are a tool, not a virtue. In a throwaway repository, a solo experiment or a one-evening prototype, setting up a hook framework costs more than it saves. The same goes for a project where CI plus branch protection already covers the risk cheaply: if every merge must pass the same lint and tests on the server, a local hook mostly duplicates that work and adds friction. Add hooks when fast local feedback is worth the setup, not by reflex.

A quick test

Ask whether a developer would save real time by hearing about this problem before pushing. If CI answers within a minute or two and nobody is hurt by waiting, the hook may not earn its place.

How a team's setup usually grows

Teams rarely start with the final design. They begin with a hand-written script, hit the pain of sharing and portability, adopt a framework, and finally move the rules that matter to the server as the number of contributors grows.

Evolution path
  1. 1Hand-written sh hooksFast to start, awkward to share
  2. 2Frameworkhusky and lint-staged for Node, the pre-commit framework for polyglot repos
  3. 3CI mirrors the checksSame rules, no way to skip
  4. 4Server-side rulesPre-receive hooks and branch protection as the team grows

Closing Summary and Final Checklist

Git hooks are Git's extension points at every moment of the lifecycle: before a commit is made, while its message is written, before it leaves your machine, and as it arrives on the server. Each hook guards a different thing, and only the last one can force the issue.

HookWhat it guardsHow strong it is
pre-commitThe content of the commit: format, lint, secretsAdvisory, skippable with --no-verify
commit-msgThe quality of history: message format and referencesAdvisory, skippable
pre-pushThe remote boundary: integration-critical checks before sharingAdvisory, skippable
Server hooksThe shared repository: the rules that truly matterEnforced for real
The one-sentence version

Use client hooks to give fast, friendly feedback, and use CI and server-side hooks to make the important rules unbreakable.

Final checklist

  • Hooks are versioned in the repo and bootstrapped with core.hooksPath in the onboarding steps
  • Fast: seconds in pre-commit, tens of seconds at most in pre-push, minutes only in CI
  • Every rejection prints why it failed and how to fix it
  • Auto-fixes are re-staged, and zero SHAs are handled in push-time hooks
  • Portable: /usr/bin/env, no macOS-only tools, pinned tool versions, tested on Linux
  • Every important check is duplicated in CI
  • Server-side policy exists for the rules that truly matter
Common mistake: stopping at the client

A repo with beautiful local hooks and no CI or server rule is protected only against honest mistakes. Finish the job on the server.

Part 12 · Check yourself

Quiz

Each question gives you a small situation. Decide what happens before you open the answer, then compare your reasoning with the explanation.

This pre-commit hook is installed. A developer stages app.js, which has messy formatting, and runs git commit -m 'tidy'. The hook exits 0. What does the new commit contain, and what does the working tree look like afterwards?
  • The commit holds the messy version of app.js. The commit is built from the index, and prettier --write changed only the working-tree file.
  • Afterwards the working tree has the formatted file, and git status shows it as an unstaged modification. It looks as if the hook did nothing useful.
  • The fix is to git add "$f" inside the loop after formatting. A better fix is to let lint-staged run the formatter, because it re-stages the fixes for you.
#!/bin/sh
for f in $(git diff --cached --name-only | grep '\.js$'); do
  npx prettier --write "$f"
done
exit 0
A developer runs git push origin :old-feature to delete a remote branch. The pre-push hook below crashes with a Git error. Why, and how do you fix it?
  • On a delete, Git sends a local_sha of all zeros, because there is no local commit. remote_sha..0000... is not a valid range, so git log fails.
  • A brand-new branch has the opposite problem: its remote_sha is all zeros, so the range is also invalid.
  • Guard both cases inside the loop. Skip the iteration when local_sha is all zeros. When remote_sha is all zeros, check only the new commits, for example against the default branch.
  • The while read loop is correct. A push can carry several refs, so never assume a single line on stdin.
#!/bin/sh
while read local_ref local_sha remote_ref remote_sha; do
  git log --format=%s "$remote_sha..$local_sha" | commitlint
done
Your repo commits a .githooks/commit-msg file that runs commitlint. A new teammate clones the repo, runs git commit -m 'stuff', and the commit succeeds. Nothing is wrong with the script. What happened, and what one command tells you?
  • .git/hooks is not versioned, and Git only looks in .githooks/ if core.hooksPath points there. A fresh clone has that setting empty.
  • Run git config core.hooksPath. If it prints nothing, the committed hooks are dead files and Git never runs them.
  • Fix it with git config core.hooksPath .githooks, and put that command in a bootstrap script or the onboarding docs. Husky avoids the manual step by setting the path from an npm lifecycle script.
A developer commits with git commit --no-verify, then runs git push to main using git push --no-verify. The team has a pre-commit hook, a commit-msg hook, a pre-push hook and a server-side pre-receive hook that rejects direct pushes to main. Which of these checks still run, and what is the result?
  • --no-verify skips pre-commit and commit-msg on the commit. On the push it skips pre-push. All three client hooks are bypassed.
  • The pre-receive hook runs on the remote during receive-pack, and the client has no flag to skip it. It rejects the push and the whole push fails.
  • So the client hooks gave fast feedback but no guarantee, and the server hook did the real enforcement. This is why a critical check must also live on the server or in CI.
You add this post-commit hook to block commits that contain the word WIP. A developer commits with the message 'WIP: spike'. What happens?
  • The error message prints, but the commit stays. post-commit runs after the ref has already moved, so its exit code cannot undo anything.
  • Move the check to commit-msg, which receives the message file as $1. There an exit 1 aborts the commit before the ref moves.
  • Rule of thumb: use a post-* hook only to observe or notify, and use a pre-* or commit-msg hook when you need a veto.
#!/bin/sh
if git log -1 --format=%s | grep -q 'WIP'; then
  echo 'No WIP commits allowed' >&2
  exit 1
fi

Summary

  • Hooks are executable scripts at fixed Git lifecycle points: exit 0 proceeds, non-zero aborts, and post-* hooks can only observe.
  • .git/hooks is never cloned, so share hooks through a tracked directory plus core.hooksPath, husky, or the pre-commit framework, and check with git config core.hooksPath.
  • pre-commit guards content and must be fast, and lint-staged formats staged files and re-stages the fixes; commit-msg guards history through the message file in $1, which commitlint checks.
  • pre-push runs after git push but before the remote accepts anything, reads ref lines on stdin, and must guard against all-zero SHAs.
  • Client hooks are advisory because --no-verify skips them; server hooks (pre-receive, update) are the real enforcement, and CI is the authoritative async backup.
  • Print why a hook failed and how to fix it, keep hooks to a few seconds, and pick the cheapest, latest hook that catches the error class.