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.
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.
ls .git/hooks
# pre-commit.sample commit-msg.sample pre-push.sample ...
mv .git/hooks/pre-commit.sample .git/hooks/pre-commitDropping .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.
| Hook result | Git's reaction | What you see |
|---|---|---|
| Exit 0 | Carries on with the commit, push or merge | Usually nothing |
| Exit non-zero | Stops the operation | The 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-side | Server-side | |
|---|---|---|
| Runs on | Your machine | The remote repository |
| Fires on | Your own commit, push, merge, rebase | Receiving a push |
| Who controls it | You, and you can skip it | Whoever runs the remote |
| Typical examples | pre-commit, commit-msg, pre-push | pre-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.
#!/bin/sh echo "pre-commit: commits are blocked" exit 1
Saved as .git/hooks/pre-commit, then chmod +x
$ 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.
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
hook said: lint failed: 2 errors exit code: 1 git would abort
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.
mkdir -p ~/.git-templates/hooks
cp my-pre-commit ~/.git-templates/hooks/pre-commit
git config --global init.templateDir ~/.git-templatesEvery future git init or git clone copies these hooks
- 1Template folderyour hooks/ directory
- 2git init or clonereads init.templateDir
- 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.
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.
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.
| Hook | Arguments | stdin | Typical use |
|---|---|---|---|
| pre-commit | none | none | Lint or test the staged changes |
| commit-msg | $1 = path to the message file | none | Validate or rewrite the message |
| pre-push | $1 = remote name, $2 = remote URL | one line per ref being pushed | Last check before the network |
| post-commit | none | none | Notify, 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.
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).
refs/heads/main -> refs/heads/main: update refs/heads/fix -> refs/heads/fix: new branch
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 runs | What usually differs | Typical symptom |
|---|---|---|
| Your terminal | Shell profile has loaded nvm, pyenv, etc. | Works |
| GUI client or editor | Profile may not be loaded, shorter PATH | command not found |
| CI runner | Minimal image, tools not installed | command 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.
| Shebang | Good for | Watch out for |
|---|---|---|
| #!/bin/sh | Portable, small scripts | No bash-only syntax |
| #!/usr/bin/env bash | Scripts that need bash features | Bash must be on PATH |
| (none) | Nothing; avoid | Behaviour 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.
#!/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.
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.
- 1git commityou run it
- 2pre-commitabort point 1
- 3snapshotindex becomes a tree
- 4commit-msgabort point 2
- 5commit createdbranch ref moves
- 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.
| Hook | Runs | If it exits non-zero | History changed? |
|---|---|---|---|
| pre-commit | Before the snapshot and message | Commit stops, index kept | No |
| commit-msg | After the message exists, before the commit object | Commit stops, message discarded | No, the branch ref has not moved |
| post-commit | After the ref moved | Exit status is ignored | Yes, 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.
#!/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.
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 hooks | Server-side hooks | |
|---|---|---|
| Runs on | The developer's machine | The remote that receives the push |
| Can be bypassed with --no-verify | Yes | No |
| Best for | Fast feedback | Enforcement |
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.
.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.
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.
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.
#!/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.
#!/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.
#!/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
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.
#!/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.
#!/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.
| Check | Fits pre-commit? | Why |
|---|---|---|
| eslint / stylelint on staged files | Yes | Takes seconds, only reads changed files |
| Format check (prettier --check) | Yes | Deterministic and fast |
Block debugger and console.log | Yes | One grep over the staged diff |
| Secret scan (gitleaks) | Yes | Catches a leaked key before it is ever in history |
| Fast unit tests | Yes | Only if they finish in seconds |
| Slow integration suites | No | Belongs in pre-push or CI |
| Full end-to-end runs | No | Belongs 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.
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
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
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.
#!/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.
- 1pre-commitchecks the staged changes
- 2prepare-commit-msgbuilds the default message, before the editor opens
- 3Editor opensyou write or edit the message
- 4commit-msgvalidates the file at $1, can reject
- 5post-committhe ref has already moved
| Hook | Runs | Can stop the commit? | Typical use |
|---|---|---|---|
| prepare-commit-msg | Before the editor opens | Yes, by exiting non-zero | Inject an issue number or a squash hint into the default message |
| commit-msg | After the message is written | Yes, this is its job | Reject messages that break your format |
| post-commit | After the branch ref has moved | No, it is too late | Notifications 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.
| Header | Meaning | Semantic version effect |
|---|---|---|
| fix(cart): stop double-counting coupons | Bug fix | Patch (1.4.2 to 1.4.3) |
| feat(auth): add OAuth login | New feature | Minor (1.4.2 to 1.5.0) |
| fix(api)!: drop v1 route | Breaking change | Major (1.4.2 to 2.0.0) |
| docs: clarify install steps | Documentation only | No 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.
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}")
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.
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.
npm i -D @commitlint/cli @commitlint/config-conventional
Install as dev dependencies so every clone gets the same version
// 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.
#!/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.
| Rule | What it enforces | Example |
|---|---|---|
| type-enum | Only whitelisted types are allowed | feat, fix, docs, chore and nothing else |
| subject-case | Casing of the description | Reject an all-caps or Sentence-case subject |
| subject-empty | A description must exist | Rejects feat(auth): with nothing after it |
| header-max-length | Caps the first line, usually 72 to 100 characters | Keeps the line readable in git log and on GitHub |
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.
echo 'foo' | npx commitlintPrints the failed rules, such as subject-empty and type-empty, and exits non-zero
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.
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.
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.
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.
| Input | What it holds | Example |
|---|---|---|
$1 | The remote name | origin |
$2 | The remote URL | git@github.com:acme/app.git |
| stdin | One line per ref being pushed: local_ref local_sha remote_ref remote_sha | refs/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":
| Situation | local_sha | remote_sha | Safe to run git show? |
|---|---|---|---|
| Normal update | real commit | real commit | Yes, on either side |
| New branch | real commit | 0000... | Only on local_sha |
| Branch deletion | 0000... | real commit | No: 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.
The same logic in a runnable form. It classifies three fake stdin lines the way the hook would:
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))
update refs/heads/main new refs/heads/feat/login delete refs/heads/old
Reading only the first stdin line, or using read once without a loop, checks one ref and silently lets the others through. Always loop.
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:
#!/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.
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.
| Layer | Speed | Scope | Role |
|---|---|---|---|
| pre-commit | Fast, local | Staged files: format, lint | Catch typos instantly |
| pre-push | Slower | Pushed commits: tests, integration checks, large files | Stop bad pushes before upload |
| CI | Slowest | Everything, in a clean environment | Authoritative verdict |
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.
ls /srv/git/project.git/hooks/
# pre-receive update post-receive (make each one executable)
chmod +x /srv/git/project.git/hooks/pre-receiveInstalling 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.
| Hook | Runs | Sees | Exit non-zero means |
|---|---|---|---|
| pre-receive | Once per push, before any ref moves | Every ref update, as lines on stdin | Abort everything: no ref in the push is updated |
| update | Once per ref, before that ref moves | One ref, as three arguments | Reject only that ref; the others can still succeed |
| post-receive | Once per push, after all refs have moved | Every ref update, as lines on stdin | Nothing: it is too late to undo anything |
- 1Objects arriveStored in a quarantine area first
- 2pre-receiveOne verdict for the whole push
- 3update, per refOne verdict per branch or tag
- 4Refs moveThe push is now permanent
- 5post-receiveNotifications, deploys, CI
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.
#!/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.
#!/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-receive | update | |
|---|---|---|
| Input | stdin lines: old new ref | arguments: ref old new |
| Granularity | Whole push | One ref |
| On rejection | Nothing is updated | Only that ref is refused |
| Best for | Push-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.
#!/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
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.
| Policy | Hook | How the check works |
|---|---|---|
| Forbid pushes to main | update | Compare $1 with refs/heads/main and exit 1 |
| Reject non-fast-forward pushes | update | git merge-base --is-ancestor $old $new fails when history was rewritten |
| Block large files | pre-receive | List the new objects and compare their sizes with a limit such as 100 MB |
| Required commit-message format | update | Read each new commit's message with git log --format=%B and match a pattern |
| Protect against branch deletion | pre-receive | Reject when new_sha is the zero sha |
#!/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.
| Platform | Server hooks? | What to use |
|---|---|---|
| GitHub.com | No arbitrary hooks | Branch protection, rulesets, API, GitHub Apps |
| Plain git over SSH | Yes | Scripts in the bare repo's hooks/ directory |
| Gitolite | Yes | Repo-specific and global hooks it manages |
| Gitea (self-hosted) | Yes | Git hooks configured per repository by an admin |
| GitLab (self-hosted) | Yes | Custom hooks directories; push rules in paid tiers |
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.
#!/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.
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.
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.
- 1git commitpre-commit, then commit-msg
- 2git pushpre-push, last client check
- 3receive-pack startspre-receive sees the whole push
- 4Per-ref checkupdate runs once per branch or tag
- 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 hooks | Server hooks | |
|---|---|---|
| Trigger point | Before a local operation completes (commit, rebase, push) | During receive-pack, on the remote, before refs are updated |
| Bypass | --no-verify, or simply never installed | Cannot be skipped by anyone whose commits arrive via push |
| Audience | The individual developer, fast feedback while in flow | The whole team, a guaranteed policy |
| Latency tolerance | Must finish in well under a few seconds | A bit more budget, but a slow hook holds the pusher and the server |
| Environment | Unpredictable: any OS, any tool version, maybe nothing installed | One controlled environment, so dependencies stay working |
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.
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.
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}')pre-commit client skippable pre-push client skippable pre-receive server enforced CI server enforced
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.
| Layer | Speed | Can be skipped? | Best used for |
|---|---|---|---|
| pre-commit | Under a second or two | Yes | Formatting, lint on staged files, secret scan |
| pre-push | A few seconds | Yes | Fast unit tests, type check |
| Server hook | Seconds, still blocks the push | No | Branch protection, message format, forbidden files |
| CI | Minutes are fine | No | Full 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.
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.
| Hook | Side | When it fires | Can veto? |
|---|---|---|---|
| pre-commit | Client | Before the commit object is created | Yes |
| prepare-commit-msg | Client | After the default message is built, before the editor opens | Yes |
| commit-msg | Client | After the message is written | Yes |
| pre-rebase | Client | Before a rebase starts | Yes |
| pre-push | Client | After contacting the remote, before objects are sent | Yes |
| pre-receive | Server | First thing in receive-pack, sees every ref in the push | Yes, rejects the whole push |
| update | Server | Once for each ref being updated | Yes, rejects just that ref |
| post-commit, post-merge | Client | After the commit or merge has happened | No |
| post-receive | Server | After all refs have been updated | No |
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.
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.
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.
- 1git clonefetches tracked files and history
- 2.git/hooksonly *.sample files, all inactive
- 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.
| Option | Where hooks live | Wiring step | Best fit |
|---|---|---|---|
| Tracked dir | .githooks/ | Each dev runs git config core.hooksPath .githooks once | Any project, minimal tooling |
| husky | .husky/ | Automatic through an npm lifecycle script | Node and JS projects |
| pre-commit framework | .pre-commit-config.yaml | pre-commit install once, environments built on first run | Polyglot and Python teams |
| Template dir | ~/.git-templates/hooks | Copied into .git/hooks on every future clone or init | Personal 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.
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.
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.
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.
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.
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
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.
#!/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.
git config core.hooksPath
.githooks
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.
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 shape | Prefer | Reason |
|---|---|---|
| Pure JS or TS app | husky | npm install wires it automatically |
| Python or mixed languages | pre-commit framework | Language-agnostic hooks with managed environments |
| Monorepo, many stacks | pre-commit framework or core.hooksPath | No dependency on Node tooling |
| Tiny repo, no tooling | .githooks/ with setup.sh | Zero dependencies, easy to read |
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 hook | lint-staged | |
|---|---|---|
| Files checked | Everything tracked | Only staged files |
| Speed | Grows with the repo | Grows with the size of the commit |
| Blocked by | Anyone's old or WIP problems | Only problems in your change |
| Typical command | eslint . | 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.
npm i -D lint-staged husky npx husky init
Installs both tools and creates .husky/pre-commit
# .husky/pre-commit
npx lint-stagedThe 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.
{
"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.
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.
// 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.
| Option | Where it goes | Use it when |
|---|---|---|
--no-error-on-unmatched-pattern | Passed to the tool itself, for example eslint or prettier | The tool fails when none of the given patterns match a file |
--relative | Passed to lint-staged | A tool needs paths relative to the working directory, not absolute ones |
{
"lint-staged": {
"*.{js,ts}": "eslint --fix --no-error-on-unmatched-pattern"
}
}# .husky/pre-commit npx lint-staged --relative
Relative paths for tools that resolve config from the current directory
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.
// 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.
| Part | Allowed values | Meaning |
|---|---|---|
| level | 0, 1, 2 | Off, warning, error. Only errors reject the commit |
| applied | always, never | Whether the value must hold or must not hold |
| value | Rule-specific | A 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.
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.
- 1pre-commitnpx lint-staged: lint and fix staged files
- 2commit-msgcommitlint --edit: check the message
- 3pre-pushnpm test: run the tests
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.
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.
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.
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.
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.
# .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
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.
| Stage | Time budget | What belongs there |
|---|---|---|
| commit-msg | Instant | Message format check |
| pre-commit | A few seconds | Format and lint staged files only |
| pre-push | Tens of seconds | Integration-critical tests, smoke checks |
| CI | Minutes | Full test suite, builds, security scans |
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.
#!/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.
# 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.
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 habit | Portable 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 macOS | Avoid in-place sed and OS-specific tools, or branch on uname |
A globally installed prettier of unknown version | npx --no-install prettier with the version pinned in package.json and the lockfile |
Absolute paths such as /Users/ana/project | git rev-parse --show-toplevel for the repo root |
| Only ever run on the author's machine | Run the same hook script in a Linux CI job |
#!/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.
#!/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.
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.
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.
| Tension | Lean one way | Lean the other way | Typical balance |
|---|---|---|---|
| Friction vs safety | More checks catch more problems early | More checks mean more waiting and more --no-verify | Cheap, high-signal checks on commit; heavier ones later |
| Instant feedback vs CI authority | Local hooks answer in seconds, before a push | CI is the one result nobody can skip | Hooks for speed, CI and server rules for the final word |
| Per-file speed vs full-repo certainty | Checking only staged files is fast | Only a full run catches cross-file breakage | Staged 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.
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.
- 1Hand-written sh hooksFast to start, awkward to share
- 2Frameworkhusky and lint-staged for Node, the pre-commit framework for polyglot repos
- 3CI mirrors the checksSame rules, no way to skip
- 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.
| Hook | What it guards | How strong it is |
|---|---|---|
| pre-commit | The content of the commit: format, lint, secrets | Advisory, skippable with --no-verify |
| commit-msg | The quality of history: message format and references | Advisory, skippable |
| pre-push | The remote boundary: integration-critical checks before sharing | Advisory, skippable |
| Server hooks | The shared repository: the rules that truly matter | Enforced for real |
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.hooksPathin 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
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, andprettier --writechanged only the working-tree file. - Afterwards the working tree has the formatted file, and
git statusshows 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_shaof all zeros, because there is no local commit.remote_sha..0000...is not a valid range, sogit logfails. - A brand-new branch has the opposite problem: its
remote_shais all zeros, so the range is also invalid. - Guard both cases inside the loop. Skip the iteration when
local_shais all zeros. Whenremote_shais all zeros, check only the new commits, for example against the default branch. - The
while readloop 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/hooksis not versioned, and Git only looks in.githooks/ifcore.hooksPathpoints 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-verifyskips 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-commitruns 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 anexit 1aborts the commit before the ref moves. - Rule of thumb: use a
post-*hook only to observe or notify, and use apre-*orcommit-msghook 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/hooksis never cloned, so share hooks through a tracked directory pluscore.hooksPath, husky, or the pre-commit framework, and check withgit 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 pushbut before the remote accepts anything, reads ref lines on stdin, and must guard against all-zero SHAs. - Client hooks are advisory because
--no-verifyskips 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.