Git & GitHub Module 12 – Hooks and CI
Automate checks with Git hooks
Write pre-commit, commit-msg and pre-push hooks, share them with core.hooksPath or configured hooks, and learn why only the server can enforce a rule.
What you will learn
- Write pre-commit, commit-msg and pre-push hooks and make them executable
- Check the staged copy of a file in a hook, not the copy in the working tree
- Set up shared hooks for a team with core.hooksPath or configuration-based hooks
- Manage hook tools with the pre-commit framework or lefthook
- Explain why hooks are not cloned and cannot be relied on for enforcement
Before you start
What you’ll learn · Before you start
What you will learn
- Write pre-commit, commit-msg and pre-push hooks and make them executable
- Check the staged copy of a file in a hook, not the copy in the working tree
- Set up shared hooks for a team with core.hooksPath or configuration-based hooks
- Manage hook tools with the pre-commit framework or lefthook
- Explain why hooks are not cloned and cannot be relied on for enforcement
Before you start
On this page
A pre-commit hook that exits with status 1 stops the commit, seconds after the mistake it caught. A
Git hook: A program that Git runs at a fixed point of its work, such as pre-commit before a commit or pre-push before a push. A non-zero exit from a checking hook stops the operation. Hooks are not cloned, and --no-verify skips the ones on your computer. is a program that Git runs at a fixed point of its own work: pre-commit before a
commit is made, commit-msg once the message is written, pre-push before a push sends anything. Git looks for an
executable file named after the event in the repository’s .git/hooks folder, and when a checking hook such as these
three exits with a non-zero status, Git stops. Hooks have one big limit: they live in each clone, git clone does not
copy them, and --no-verify skips them, so a rule that must hold is enforced on the server and in CI.
You will build each hook with real Git and run it on good and bad input: a whitespace check, a commit-message check, a password check that reads the right copy of each file, and a pre-push guard. Then you share them with a team, compare three hook managers, and see where enforcement belongs.
Git runs a hook by its name, and its exit status decides
Hooks run at two places, on your computer during git commit and git push, and on the server while it receives a
push. The diagram puts the hooks of this lesson in the order Git runs them:
The hooks of this lesson, in the order Git runs them
Text description of the diagram
The diagram has two boxes, one above the other.
The upper box is your computer. Hooks run there only where someone installed them, and --no-verify skips them. Inside it, git commit runs the pre-commit hook, which checks the staged changes, then the commit-msg hook, which checks the file that holds the message; then the commit is made. Later, git push runs the pre-push hook, which reads the refs about to be pushed on its standard input.
An arrow labelled "objects and refs sent" leads from pre-push to the lower box, the server. Hooks there run for every push, and the person pushing cannot skip them. pre-receive runs once per push and accepts or refuses all of it; update runs once for each ref and can refuse that ref alone; post-receive runs after the refs have moved, for notifications or deployments.
A non-zero exit status from pre-commit, commit-msg, pre-push, pre-receive or update stops the step it belongs to.
A new repository already has a hooks folder: git init copies sample files such as pre-commit.sample into
.git/hooks from Git’s template folder. The samples are switched off; a hook counts only when its name is exactly
the event’s name, with no extension, and the file is executable. A hook can be written in any language, because Git
simply runs the file, and its first line (#!/bin/sh) picks the interpreter. Before it runs a hook, Git changes to
the top folder of the working tree, so paths in the hook are relative to the project’s root.
pre-commitruns atgit commit, before the message is asked for. It receives nothing and reads the staged changes itself; a non-zero exit stops the commit.commit-msgruns atgit commitandgit merge, after the message is written. It receives the path of the file that holds the message; a non-zero exit stops the commit.pre-pushruns atgit push, before anything is sent. It receives the remote’s name and address, and one line per ref on standard input; a non-zero exit stops the whole push.
Here is a first pre-commit hook. git diff --cached --check reports whitespace errors and leftover conflict
markers in the staged changes and exits with a non-zero status when it finds any, which the hook passes on.
#!/bin/sh
# pre-commit hook: refuses a commit whose staged changes add whitespace errors
# (spaces at the end of a line, a space before a tab in the indent) or leave
# conflict markers behind. Git runs it with no arguments, from the top folder
# of the working tree, before it asks for the commit message.
if ! git diff --cached --check; then
echo 'pre-commit: fix the lines above, stage them again, then commit' >&2
exit 1
fi The session below installs it twice, first without and then with the executable bit, and makes four commits.
first_hook.mjs
// A pre-commit hook from start to finish: Git ignores it until it is executable, then a non-zero exit stops the
// commit, and --no-verify skips it. The hook is hooks/pre-commit.sh, next to this file.
import { asha, freshFolder, git, installHook, newRepo, show, write, commit } from './git-kit.mjs';
const root = freshFolder('first-hook');
const repo = newRepo(root, asha, 'fest-site');
commit(repo, asha, 'README.md', '# Fest site\n', 'Add a README');
console.log(git(repo, asha, 'version').out);
console.log('\n# A new repository has sample hooks only (*.sample), so no hook runs yet');
show(repo, asha, 'hook', 'list', 'pre-commit');
console.log('\n# 1. The hook is in place but not executable: Git skips it and says so');
installHook(repo, 'pre-commit.sh', '.git/hooks/pre-commit', { executable: false });
write(repo, 'schedule.txt', 'Day 1, 10:00: opening \n');
show(repo, asha, 'add', 'schedule.txt');
show(repo, asha, 'commit', '-m', 'Add the day 1 schedule');
console.log('\n# 2. Executable now: the trailing space in the next change stops the commit');
installHook(repo, 'pre-commit.sh', '.git/hooks/pre-commit');
write(repo, 'schedule.txt', 'Day 1, 10:00: opening \nDay 1, 11:30: coding contest \n');
show(repo, asha, 'add', 'schedule.txt');
show(repo, asha, 'commit', '-m', 'Add the coding contest');
console.log('\n# 3. Fix both lines, stage them again, and the hook lets the commit through');
write(repo, 'schedule.txt', 'Day 1, 10:00: opening\nDay 1, 11:30: coding contest\n');
show(repo, asha, 'add', 'schedule.txt');
show(repo, asha, 'commit', '-m', 'Add the coding contest');
console.log('\n# 4. --no-verify skips the hook: a check on your computer is a convenience, not a rule');
write(repo, 'schedule.txt', 'Day 1, 10:00: opening\nDay 1, 11:30: coding contest\nDay 1, 15:00: quiz \n');
show(repo, asha, 'add', 'schedule.txt');
show(repo, asha, 'commit', '--no-verify', '-m', 'Add the quiz');
show(repo, asha, 'log', '--oneline'); Output
git version 2.54.0 (Apple Git-157) # A new repository has sample hooks only (*.sample), so no hook runs yet $ git hook list pre-commit warning: no hooks found for event 'pre-commit' (exit status 1) # 1. The hook is in place but not executable: Git skips it and says so $ cp hooks/pre-commit.sh .git/hooks/pre-commit $ git add schedule.txt $ git commit -m 'Add the day 1 schedule' hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable. hint: You can disable this warning with `git config set advice.ignoredHook false`. [main 4efffec] Add the day 1 schedule 1 file changed, 1 insertion(+) create mode 100644 schedule.txt # 2. Executable now: the trailing space in the next change stops the commit $ cp hooks/pre-commit.sh .git/hooks/pre-commit && chmod 755 .git/hooks/pre-commit $ git add schedule.txt $ git commit -m 'Add the coding contest' schedule.txt:2: trailing whitespace. +Day 1, 11:30: coding contest pre-commit: fix the lines above, stage them again, then commit (exit status 1) # 3. Fix both lines, stage them again, and the hook lets the commit through $ git add schedule.txt $ git commit -m 'Add the coding contest' [main c614608] Add the coding contest 1 file changed, 2 insertions(+), 1 deletion(-) # 4. --no-verify skips the hook: a check on your computer is a convenience, not a rule $ git add schedule.txt $ git commit --no-verify -m 'Add the quiz' [main a2766f9] Add the quiz 1 file changed, 1 insertion(+) $ git log --oneline a2766f9 Add the quiz c614608 Add the coding contest 4efffec Add the day 1 schedule 42bbef8 Add a README
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node first_hook.mjs
git-kit.mjs (helpers) · git-kit.mjs
// The helpers this lesson's examples share. Each example builds throwaway repositories in a folder of its own and
// runs real Git on them, with Git's system and personal settings switched off, fixed names and e-mail addresses, and
// a fixed clock that moves one minute per command, so the commit IDs it prints are the ones you get when you run it.
import { spawnSync } from 'node:child_process';
import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { delimiter, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
// On macOS, /usr/bin/git is a small launcher that looks Git up again on every call. Ask once where Git keeps its own
// programs and run the git found there (anywhere else that is the same git as on your PATH).
const own = spawnSync('git', ['--exec-path'], { encoding: 'utf8' }).stdout?.trim() ?? '';
const GIT = [join(own, 'git'), join(own, 'git.exe')].find((file) => own && existsSync(file)) ?? 'git';
const HERE = dirname(fileURLToPath(import.meta.url));
export const asha = { name: 'Asha Rao', email: 'asha@example.com' };
export const ravi = { name: 'Ravi Menon', email: 'ravi@example.com' };
let clock = 1767600000; // seconds since 1970; Git's own date format is "<seconds> <time zone>"
let emptyConfig = '';
let outputFile = '';
/**
* A new, empty folder for this run's repositories, next to the script (made again on every run). It also holds the
* empty settings file that stands in for your own, so the repositories go in folders inside it (newRepo, git clone).
*/
export function freshFolder(name) {
const dir = join(process.cwd(), name);
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
emptyConfig = join(dir, 'empty.gitconfig');
writeFileSync(emptyConfig, '');
outputFile = join(dir, 'last-output.txt');
return dir;
}
/**
* Runs a program and returns its exit code and what it printed. Both streams go to one file, so messages come out in
* the order a terminal shows them (a hint written to the error stream before the commit summary stays before it).
*/
function run(cmd, args, cwd, env, stdinFile = null) {
const fd = openSync(outputFile, 'w');
const input = stdinFile ? openSync(join(cwd, stdinFile), 'r') : 'ignore';
const r = spawnSync(cmd, args, { cwd, env, stdio: [input, fd, fd] });
closeSync(fd);
if (typeof input === 'number') closeSync(input);
return { code: r.status, out: readFileSync(outputFile, 'utf8').trimEnd() };
}
/** Creates a repository with the branch main in a new folder inside `folder`, and returns its path. */
export function newRepo(folder, who, name) {
git(folder, who, 'init', '-q', '-b', 'main', name);
return join(folder, name);
}
/** The environment Git runs in: only a PATH, a home, your settings swapped for an empty file, and a fixed author. */
function envFor(who, dir, extra = {}) {
clock += 60;
const when = `${clock} +0530`;
return {
// Git's own folder first, so that a hook started outside Git (runHook) finds the same git.
PATH: [own, process.env.PATH ?? ''].filter(Boolean).join(delimiter),
HOME: process.env.HOME ?? dir,
LC_ALL: process.env.LC_ALL ?? 'en_US.UTF-8',
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: emptyConfig,
GIT_AUTHOR_NAME: who.name,
GIT_AUTHOR_EMAIL: who.email,
GIT_COMMITTER_NAME: who.name,
GIT_COMMITTER_EMAIL: who.email,
GIT_AUTHOR_DATE: when,
GIT_COMMITTER_DATE: when,
...extra,
};
}
/** Runs git in `dir` as `who` and returns its exit code and what it printed (both streams, as a terminal shows them). */
export function git(dir, who, ...args) {
return run(GIT, args, dir, envFor(who, dir));
}
/** An argument as you would type it in a shell ("--format='%h %s'"). */
function quote(arg) {
if (/^[\w@%+=:,./^~-]+$/.test(arg)) return arg;
const option = /^(--[\w-]+=)(.*)$/.exec(arg);
return option ? `${option[1]}'${option[2]}'` : `'${arg.replace(/'/g, `'\\''`)}'`;
}
/** Prints a command's output the way a terminal session reads (a progress line keeps only its last state). */
function print(out) {
const shown = out.split('\n').map((line) => line.split('\r').filter(Boolean).at(-1) ?? '').join('\n');
if (shown) console.log(shown);
}
/** Like git(), and prints the command and its output, then its exit status when it is not 0. */
export function show(dir, who, ...args) {
const r = git(dir, who, ...args);
console.log(`$ git ${args.map(quote).join(' ')}`);
print(r.out);
if (r.code !== 0) console.log(`(exit status ${r.code})`);
return r;
}
/** Like show(), with a file of the repository on standard input: git stripspace < message.txt */
export function showWithInput(dir, who, file, ...args) {
const r = run(GIT, args, dir, envFor(who, dir), file);
console.log(`$ git ${args.map(quote).join(' ')} < ${file}`);
print(r.out);
if (r.code !== 0) console.log(`(exit status ${r.code})`);
return r;
}
/** Writes a file inside a repository (folders are made as needed). */
export function write(dir, file, text) {
mkdirSync(dirname(join(dir, file)), { recursive: true });
writeFileSync(join(dir, file), text);
}
/** Writes a file and commits it as `who`. */
export function commit(dir, who, file, text, message) {
write(dir, file, text);
git(dir, who, 'add', file);
return git(dir, who, 'commit', '-q', '-m', message);
}
/**
* Copies one of the hook scripts next to this file (hooks/<source>) to `target` inside the repository, for example
* .git/hooks/commit-msg, and makes it executable (chmod 755) unless `executable` is false.
*/
export function installHook(repo, source, target, { executable = true } = {}) {
const to = join(repo, target);
mkdirSync(dirname(to), { recursive: true });
copyFileSync(join(HERE, 'hooks', source), to);
chmodSync(to, executable ? 0o755 : 0o644);
console.log(`$ cp hooks/${source} ${target}${executable ? ` && chmod 755 ${target}` : ''}`);
}
/** Runs a hook script with a given shell (sh, dash) outside Git, the way you test a hook; prints what it said. */
export function runHook(repo, who, shell, script, ...args) {
const r = run(shell, [script, ...args], repo, envFor(who, repo));
console.log(`$ ${shell} ${[script, ...args].map(quote).join(' ')}`);
print(r.out);
console.log(`(exit status ${r.code})`);
return r.code;
} Step through the same four commits and watch when Git asks the hook, and what its exit status decides:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Only samples. A new repository has sample hooks only, files ending in .sample, so git hook list pre-commit finds no hook for the event at all.
- Not executable. The hook is copied in without the executable bit. git commit prints a hint that the hook was ignored and makes the commit anyway, 4efffec, trailing space included.
- Executable now. After chmod 755, git commit runs the hook before it asks for a message. The hook runs git diff --cached --check on the staged changes and finds a trailing space on line 2.
- Exit status 1. The hook prints schedule.txt:2 trailing whitespace and exits with status 1, so no commit is made. Line 1 was not reported, because it was already committed and the check reads only staged changes.
- Fixed and staged. Asha removes both trailing spaces and stages the file again. This time the hook exits with 0, and the commit goes through as c614608, Add the coding contest.
- Skipped with --no-verify. git commit --no-verify never asks the hook, so a2766f9 Add the quiz goes in with a trailing space. A check on your own computer is a convenience, not a rule.
Each step showed one rule. Without the executable bit the hook was ignored, with a hint. After chmod 755 the hook
found the trailing space on line 2 of the next change, exited with 1, and no commit was made; once the lines were
fixed and staged again, the same commit went through. git commit --no-verify bypassed the hook completely, and the
last commit holds a trailing space again. Every session in this lesson uses the helpers in git-kit.mjs, which fix
names, e-mail addresses and the clock, so the commit IDs you see are the ones a rerun produces.
Checkpoint
Check: How Git finds and runs a hook
commit-msg reads the message file and can refuse it
git commit writes the proposed message to a file and runs commit-msg with that file’s path as its only argument.
The hook can read it, refuse it, or even rewrite it in place. This one applies the summary rules from
the lesson on commit messages: a summary of at most 72 characters, no full stop
at its end, and a blank second line before the body. It lets fixup!, squash! and amend! commits through,
because their summary is only a pointer to the commit they will be folded into.
#!/bin/sh
# commit-msg hook: checks the summary line of every commit message.
# Git runs it with one argument, the file that holds the proposed message.
# Exit status 0 accepts the commit; any other status aborts it.
max=72
# Read the message as Git will store it. git stripspace drops the comment lines
# (an editor leaves them in the file, and Git removes them only after this hook
# has run), blank lines at the start and spaces at the ends of lines.
message=$(git stripspace --strip-comments < "$1")
summary=$(printf '%s\n' "$message" | sed -n '1p')
second=$(printf '%s\n' "$message" | sed -n '2p')
reject() {
echo "commit-msg: $1" >&2
echo " summary: $summary" >&2
exit 1
}
case $summary in
'fixup! '* | 'squash! '* | 'amend! '*) exit 0 ;; # folded into another commit later
esac
# Count characters with wc -m: dash's ${#summary} counts bytes, so "₹" is 3.
length=$(($(printf '%s' "$summary" | wc -m)))
[ -n "$summary" ] || reject 'the message has no summary line'
[ "$length" -le "$max" ] || reject "the summary has $length characters; keep it to $max or fewer"
case $summary in
*.) reject 'end the summary without a full stop' ;;
esac
[ -z "$second" ] || reject 'leave the second line blank, between the summary and the body'
exit 0 // The commit-msg hook of hooks/commit-msg.sh against good and bad messages: first through git commit, then run
// directly with two shells, the way you test a hook before you share it.
import { asha, freshFolder, git, installHook, newRepo, runHook, show, showWithInput, write, commit } from './git-kit.mjs';
const root = freshFolder('commit-msg');
const repo = newRepo(root, asha, 'fest-site');
commit(repo, asha, 'README.md', '# Fest site\n', 'Add a README');
installHook(repo, 'commit-msg.sh', '.git/hooks/commit-msg');
const tries = [
'Add the day 1 schedule',
'Fix bug.',
'Show the results of every event on one page, sorted by points and then by team',
'Add the sponsors page\nList each sponsor with a link to its website.',
'fixup! Add the day 1 schedule',
'Show fest budgets in ₹ with a total for each committee and a grand total',
];
let n = 0;
for (const message of tries) {
n += 1;
console.log(`\n# Message ${n}`);
write(repo, 'notes.txt', `change ${n}\n`);
git(repo, asha, 'add', 'notes.txt');
show(repo, asha, 'commit', '-q', '-m', message);
}
show(repo, asha, 'log', '--format=%s');
console.log('\n# A message written in an editor holds comment lines; the hook reads it as Git will store it');
write(repo, 'message.txt', '# Summary: what the commit does, at most 72 characters\nAdd the bus timings\n\n# Body: why\nThe buses now leave from gate 2.\n');
showWithInput(repo, asha, 'message.txt', 'stripspace', '--strip-comments');
console.log('\n# Test the hook without committing: give it a message file, with sh and with dash');
write(repo, 'rupee.txt', `${tries[5]}\n`);
for (const file of ['message.txt', 'rupee.txt']) {
for (const shell of ['sh', 'dash']) runHook(repo, asha, shell, '.git/hooks/commit-msg', file);
}
console.log('\n# Why the hook counts with wc -m: the two shells measure "₹" differently');
installHook(repo, 'length.sh', 'length.sh');
for (const shell of ['sh', 'dash']) runHook(repo, asha, shell, 'length.sh'); Output
$ cp hooks/commit-msg.sh .git/hooks/commit-msg && chmod 755 .git/hooks/commit-msg
# Message 1
$ git commit -q -m 'Add the day 1 schedule'
# Message 2
$ git commit -q -m 'Fix bug.'
commit-msg: end the summary without a full stop
summary: Fix bug.
(exit status 1)
# Message 3
$ git commit -q -m 'Show the results of every event on one page, sorted by points and then by team'
commit-msg: the summary has 78 characters; keep it to 72 or fewer
summary: Show the results of every event on one page, sorted by points and then by team
(exit status 1)
# Message 4
$ git commit -q -m 'Add the sponsors page
List each sponsor with a link to its website.'
commit-msg: leave the second line blank, between the summary and the body
summary: Add the sponsors page
(exit status 1)
# Message 5
$ git commit -q -m 'fixup! Add the day 1 schedule'
# Message 6
$ git commit -q -m 'Show fest budgets in ₹ with a total for each committee and a grand total'
$ git log --format=%s
Show fest budgets in ₹ with a total for each committee and a grand total
fixup! Add the day 1 schedule
Add the day 1 schedule
Add a README
# A message written in an editor holds comment lines; the hook reads it as Git will store it
$ git stripspace --strip-comments < message.txt
Add the bus timings
The buses now leave from gate 2.
# Test the hook without committing: give it a message file, with sh and with dash
$ sh .git/hooks/commit-msg message.txt
(exit status 0)
$ dash .git/hooks/commit-msg message.txt
(exit status 0)
$ sh .git/hooks/commit-msg rupee.txt
(exit status 0)
$ dash .git/hooks/commit-msg rupee.txt
(exit status 0)
# Why the hook counts with wc -m: the two shells measure "₹" differently
$ cp hooks/length.sh length.sh && chmod 755 length.sh
$ sh length.sh
${#summary} says 22; wc -m says 22
(exit status 0)
$ dash length.sh
${#summary} says 24; wc -m says 22
(exit status 0)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node commit_msg.mjs
The six messages, and what the hook made of each:
| Message | Summary | Result |
|---|---|---|
| 1. Add the day 1 schedule | 22 characters | accepted |
| 2. Fix bug. | 8 | refused: a full stop at the end |
| 3. Show the results of every event … | 78 | refused: over 72 characters |
| 4. Add the sponsors page, a body on line 2 | 21 | refused: no blank second line |
| 5. fixup! Add the day 1 schedule | 29 | accepted: folded in later |
| 6. The fest budget summary with a rupee sign | 72 (74 bytes) | accepted |
Each refusal came with a reason on the error stream, and the log holds only the accepted messages. Two details in the
session matter in practice. When you write a message in an editor, the file the hook reads still holds the comment
lines that start with #, because Git removes them only after the hook has run, and a commit template
(commit.template) can put its instructions above the summary. So the hook first passes the file through
git stripspace --strip-comments, which cleans it the way Git will store it: comment lines, blank lines at the start
and spaces at the ends of lines go, as the message.txt run shows. And you can test a hook without committing
anything: run it with a message file, as the session did with sh and with dash. Run ShellCheck, a linter for shell
scripts, on your hooks before you share them too: quoting mistakes in a hook tend to show up only on unusual file
names.
₹ and Indian scripts in commit messages
The last two runs explain why the hook measures the summary with wc -m. The same summary measured 22 with sh and
24 with dash: dash, which Debian uses as /bin/sh, counts bytes in ${#summary}, and ₹ takes 3 bytes in UTF-8.
Hindi, Tamil and the other Indian scripts use 3 bytes for each character, so a byte count would refuse summaries that
are well within 72 characters. wc -m counts characters in a UTF-8 locale in both shells, so the 72-character summary
with a ₹ passed in both.
Checkpoint
Check: The commit-msg hook
A hook must read the staged copy, which is what the commit records
A commit records the staged copy of each file, the one in the index, not the file in your folder. Most of the time the two are the same. When they differ, a hook that checks the folder checks the wrong thing. The session below sets up the situation twice: a file with a password is staged, then the password is replaced in the editor by a variable, but that change is not staged.
staged_only.mjs
// A failure reproduced: a password is staged, then removed from the file in the folder but not from the staging area.
// The same situation is set up twice. The hook that searches the folder approves the commit, and the password is in
// it; the hook that searches the staged copies (git grep --cached) refuses it. Both hooks are in hooks/.
import { asha, freshFolder, installHook, newRepo, show, write, commit } from './git-kit.mjs';
const root = freshFolder('staged-only');
function setUp(name) {
const repo = newRepo(root, asha, name);
commit(repo, asha, 'settings.ini', 'host = db.example.com\n', 'Add the database settings');
write(repo, 'settings.ini', 'host = db.example.com\npassword = hunter2-not-real\n');
show(repo, asha, 'add', 'settings.ini');
write(repo, 'settings.ini', 'host = db.example.com\npassword = ${DB_PASSWORD}\n');
return repo;
}
console.log('# Stage a password, then take it out of the file, but forget to stage that');
const first = setUp('fest-api');
show(first, asha, 'status', '--short');
show(first, asha, 'diff', '--cached');
console.log('\n# The hook that searches the folder approves the commit');
installHook(first, 'no-password-worktree.sh', '.git/hooks/pre-commit');
show(first, asha, 'commit', '-q', '-m', 'Read the password from the environment');
show(first, asha, 'show', 'HEAD:settings.ini');
console.log('\n# The same situation in a second repository, with the hook that searches the staged copies');
const second = setUp('fest-api-2');
installHook(second, 'no-password-staged.sh', '.git/hooks/pre-commit');
show(second, asha, 'commit', '-q', '-m', 'Read the password from the environment');
console.log('\n# Stage the fixed file, and the same hook lets the commit through');
show(second, asha, 'add', 'settings.ini');
show(second, asha, 'commit', '-q', '-m', 'Read the password from the environment');
show(second, asha, 'show', 'HEAD:settings.ini'); Output
# Stage a password, then take it out of the file, but forget to stage that
$ git add settings.ini
$ git status --short
MM settings.ini
$ git diff --cached
diff --git a/settings.ini b/settings.ini
index 7aa1b3a..4f04daf 100644
--- a/settings.ini
+++ b/settings.ini
@@ -1 +1,2 @@
host = db.example.com
+password = hunter2-not-real
# The hook that searches the folder approves the commit
$ cp hooks/no-password-worktree.sh .git/hooks/pre-commit && chmod 755 .git/hooks/pre-commit
$ git commit -q -m 'Read the password from the environment'
$ git show HEAD:settings.ini
host = db.example.com
password = hunter2-not-real
# The same situation in a second repository, with the hook that searches the staged copies
$ git add settings.ini
$ cp hooks/no-password-staged.sh .git/hooks/pre-commit && chmod 755 .git/hooks/pre-commit
$ git commit -q -m 'Read the password from the environment'
settings.ini:2:password = hunter2-not-real
pre-commit: read passwords from the environment, never write them in a file
(exit status 1)
# Stage the fixed file, and the same hook lets the commit through
$ git add settings.ini
$ git commit -q -m 'Read the password from the environment'
$ git show HEAD:settings.ini
host = db.example.com
password = ${DB_PASSWORD}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node staged_only.mjs
hooks/no-password-worktree.sh
#!/bin/sh
# The same hook with one flag missing. Without --cached, git grep reads the
# files in your folder, not the staged copies, so it checks something other
# than what the commit will contain.
if git grep -n -E 'password *= *[^$ ]'; then
echo 'pre-commit: read passwords from the environment, never write them in a file' >&2
exit 1
fi hooks/no-password-staged.sh
#!/bin/sh
# pre-commit hook: refuses a commit whose staged files contain a line such as
# "password = hunter2", a value written into a file instead of read from the
# environment ("password = ${DB_PASSWORD}" is fine). git grep --cached reads
# the staged copy of every tracked file: exactly what the commit will contain.
if git grep --cached -n -E 'password *= *[^$ ]'; then
echo 'pre-commit: read passwords from the environment, never write them in a file' >&2
exit 1
fi Step through it and watch which copy of settings.ini each hook searches, and which copy the commit records:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A password is staged. settings.ini gets the line password = hunter2-not-real, and git add stages it. The index now holds the password, which is what the next commit will record.
- Fixed in the folder only. In the editor the line becomes password = ${DB_PASSWORD}, read from the environment, but that change is not staged. git status --short shows MM settings.ini.
- The first hook reads the folder. The first hook runs git grep without --cached, so it searches the files in the working tree. The password is gone from there, the search finds nothing, and the hook exits with 0.
- The commit records the index. git commit records the staged copy, not the file in the folder. git show HEAD:settings.ini prints the password: the hook let through a commit it never looked at.
- The same hook with --cached. In a second repository set up the same way, the hook runs git grep --cached, which searches the staged copies. It finds settings.ini:2, prints its reason and exits with 1, so no commit is made.
- Stage the fix. git add stages the fixed file, the same hook finds nothing in the index and exits with 0, and the commit holds password = ${DB_PASSWORD}.
git status --short printed MM settings.ini: one change staged, another one not. The first hook ran git grep
without --cached, searched the folder, found no password and let the commit through, and
git show HEAD:settings.ini proves that the password is in the commit. The second hook differs by one flag:
git grep --cached searches the staged copies, found settings.ini:2, and refused. Once the fixed file was staged,
the same hook accepted the commit. Three commands read the staged side and belong in hooks: git diff --cached for
the changes, git show :settings.ini for one file’s staged copy, and git grep --cached to search them all.
Keep pre-commit hooks fast, too, because a hook that takes half a minute tempts people to skip it.
git diff --cached --name-only --diff-filter=ACMR lists only the files the commit adds or changes, which is what a
linter needs; run slow test suites at pre-push or in CI instead. The password pattern here is only an illustration:
real secret scanners such as Gitleaks recognise many kinds of keys and tokens by their format.
Key idea
A hook checks what the commit will record only when it reads the index: git diff --cached, git show :<path> and
git grep --cached, never the files in the folder.
Checkpoint
Check: Checking the staged copy
pre-push sees every ref a push would update, and the server has the last word
git push runs pre-push once it knows what the remote has, before it sends anything. The hook gets the remote’s
name and address as its two arguments and, on standard input, one line per ref:
<local ref> <local object name> <remote ref> <remote object name>. A remote object name of 40 zeros means the
branch does not exist on the remote yet. This hook refuses any push that would update main directly; the remote is a
bare repository: A repository without a working tree: what sits inside .git elsewhere is at its top level. Servers keep bare repositories, because nobody edits files there and a push cannot leave checked-out files out of step. More in “Remotes and cloning: origin and tracking branches” next to the clone, standing in for a hosted one.
pre_push.mjs
// A pre-push hook (hooks/pre-push.sh) that refuses to update main on the remote directly. The remote is a bare
// repository in the same folder, standing in for a hosted one.
import { join } from 'node:path';
import { asha, freshFolder, git, installHook, show, commit } from './git-kit.mjs';
const root = freshFolder('pre-push');
git(root, asha, 'init', '-q', '--bare', '-b', 'main', 'origin.git');
git(root, asha, 'clone', '-q', 'origin.git', 'fest-site');
const repo = join(root, 'fest-site');
commit(repo, asha, 'README.md', '# Fest site\n', 'Add a README');
git(repo, asha, 'push', '-q', 'origin', 'main');
installHook(repo, 'pre-push.sh', '.git/hooks/pre-push');
console.log('\n# A new commit on main, and a branch with the same commit');
commit(repo, asha, 'menu.txt', 'Day 1 lunch: veg biryani\n', 'Add the day 1 menu');
show(repo, asha, 'branch', 'add-menu');
show(repo, asha, 'push', 'origin', 'main', 'add-menu');
console.log('\n# The branch alone is allowed; the hook sees all zeros because origin has no add-menu yet');
show(repo, asha, 'push', '-q', 'origin', 'add-menu');
show(repo, asha, 'ls-remote', '--heads', 'origin');
console.log('\n# --no-verify skips the hook, and main is updated anyway');
show(repo, asha, 'push', '-q', '--no-verify', 'origin', 'main');
show(repo, asha, 'ls-remote', '--heads', 'origin');
console.log('\n# On the server, an update hook refuses the same push, --no-verify or not');
installHook(root, 'update.sh', 'origin.git/hooks/update');
commit(repo, asha, 'menu.txt', 'Day 1 lunch: veg biryani\nDay 1 dinner: dosa\n', 'Add the day 1 dinner');
show(repo, asha, 'push', '-q', '--no-verify', 'origin', 'main');
show(repo, asha, 'ls-remote', '--heads', 'origin'); Output
$ cp hooks/pre-push.sh .git/hooks/pre-push && chmod 755 .git/hooks/pre-push # A new commit on main, and a branch with the same commit $ git branch add-menu $ git push origin main add-menu pre-push: origin got refs/heads/main 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/main d883980868328c0012004ad28ebdf4d50a2e0e47 pre-push: main is updated by pull requests; push a branch instead pre-push: origin got refs/heads/add-menu 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/add-menu 0000000000000000000000000000000000000000 error: failed to push some refs to 'pre-push/origin.git' (exit status 1) # The branch alone is allowed; the hook sees all zeros because origin has no add-menu yet $ git push -q origin add-menu pre-push: origin got refs/heads/add-menu 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/add-menu 0000000000000000000000000000000000000000 $ git ls-remote --heads origin 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/add-menu d883980868328c0012004ad28ebdf4d50a2e0e47 refs/heads/main # --no-verify skips the hook, and main is updated anyway $ git push -q --no-verify origin main $ git ls-remote --heads origin 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/add-menu 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/main # On the server, an update hook refuses the same push, --no-verify or not $ cp hooks/update.sh origin.git/hooks/update && chmod 755 origin.git/hooks/update $ git push -q --no-verify origin main remote: update: main changes only through pull requests remote: error: hook declined to update refs/heads/main To pre-push/origin.git ! [remote rejected] main -> main (hook declined) error: failed to push some refs to 'pre-push/origin.git' (exit status 1) $ git ls-remote --heads origin 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/add-menu 843362613f0f8a2f57fde07066dbeb0dcd3061f3 refs/heads/main
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pre_push.mjs
hooks/pre-push.sh
#!/bin/sh
# pre-push hook: refuses to update main on the remote directly; push a branch
# and open a pull request instead. Git runs it with two arguments, the remote's
# name and its address, and writes one line to its standard input for each ref
# it is about to push:
# <local ref> <local object name> <remote ref> <remote object name>
status=0
while read -r local_ref local_oid remote_ref remote_oid; do
echo "pre-push: $1 got $local_ref $local_oid $remote_ref $remote_oid"
if [ "$remote_ref" = refs/heads/main ]; then
echo "pre-push: main is updated by pull requests; push a branch instead" >&2
status=1
fi
done
exit $status update.sh (server) · hooks/update.sh
#!/bin/sh
# update hook, on the server: Git runs it once for every ref a push wants to
# change, with three arguments: the ref's name, its old object name and its new
# one. A non-zero exit refuses that ref, and nothing the pusher types skips it.
if [ "$1" = refs/heads/main ]; then
echo "update: main changes only through pull requests" >&2
exit 1
fi The first push named two refs. The hook printed both lines it received, refused because of main, and Git pushed
neither: a non-zero exit stops the whole push, not one ref. Pushing the branch alone worked, and the hook saw zeros
because add-menu was new on the remote. Then --no-verify pushed main anyway, and git ls-remote shows the
remote’s main moved. The last step installed an update hook in the bare repository itself. That hook runs on the
server, inside the Git that receives the push, so --no-verify on the laptop made no difference:
! [remote rejected] main -> main (hook declined).
Checkpoint
Check: pre-push and server hooks
Hooks are not cloned, so a team shares them on purpose
Hooks are not part of what a clone copies. A clone is a new repository whose .git/hooks comes from the cloning
person’s own Git template folder, so it holds only disabled samples, and settings in .git/config stay behind too.
The usual way to share hooks is to commit them in an ordinary folder, for example .githooks, and have each person
point core.hooksPath at it once.
// Hooks are not cloned. Asha's clone has a commit-msg hook in .git/hooks; Ravi's fresh clone has none, until he points
// core.hooksPath at the .githooks folder that the project commits.
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { asha, ravi, freshFolder, git, installHook, newRepo, show, write, commit } from './git-kit.mjs';
const root = freshFolder('share-hooks');
const site = newRepo(root, asha, 'fest-site');
commit(site, asha, 'README.md', '# Fest site\n', 'Add a README');
installHook(site, 'commit-msg.sh', '.git/hooks/commit-msg');
console.log('# Asha also commits the hook to the project, in .githooks/');
installHook(site, 'commit-msg.sh', '.githooks/commit-msg');
show(site, asha, 'add', '.githooks/commit-msg');
show(site, asha, 'commit', '-q', '-m', 'Share the commit message check');
show(site, asha, 'ls-files', '--stage', '.githooks');
console.log('\n# Ravi clones the project');
show(root, ravi, 'clone', '-q', 'fest-site', 'ravi-site');
const mine = join(root, 'ravi-site');
const own = readdirSync(join(mine, '.git', 'hooks')).filter((f) => !f.endsWith('.sample'));
console.log(`# Files in Ravi's .git/hooks that are not samples: ${own.length ? own.join(', ') : 'none'}`);
show(mine, ravi, 'hook', 'list', 'commit-msg');
write(mine, 'README.md', '# Fest site\n\nRun it with any static file server.\n');
git(mine, ravi, 'add', 'README.md');
show(mine, ravi, 'commit', '-q', '-m', 'Update readme.');
console.log('\n# One setting per clone turns the shared hooks on (a relative path starts at the top folder)');
show(mine, ravi, 'config', 'set', 'core.hooksPath', '.githooks');
show(mine, ravi, 'hook', 'list', 'commit-msg');
write(mine, 'README.md', '# Fest site\n\nRun it with any static file server, for example python3 -m http.server.\n');
git(mine, ravi, 'add', 'README.md');
show(mine, ravi, 'commit', '-q', '-m', 'Explain how to run the site.');
show(mine, ravi, 'commit', '-q', '-m', 'Explain how to run the site');
show(mine, ravi, 'log', '--format=%s'); Output
$ cp hooks/commit-msg.sh .git/hooks/commit-msg && chmod 755 .git/hooks/commit-msg # Asha also commits the hook to the project, in .githooks/ $ cp hooks/commit-msg.sh .githooks/commit-msg && chmod 755 .githooks/commit-msg $ git add .githooks/commit-msg $ git commit -q -m 'Share the commit message check' $ git ls-files --stage .githooks 100755 dc12ddae3b986b9b7b1155e2292e8cf0b7ff77c9 0 .githooks/commit-msg # Ravi clones the project $ git clone -q fest-site ravi-site # Files in Ravi's .git/hooks that are not samples: none $ git hook list commit-msg warning: no hooks found for event 'commit-msg' (exit status 1) $ git commit -q -m 'Update readme.' # One setting per clone turns the shared hooks on (a relative path starts at the top folder) $ git config set core.hooksPath .githooks $ git hook list commit-msg hook from hookdir $ git commit -q -m 'Explain how to run the site.' commit-msg: end the summary without a full stop summary: Explain how to run the site. (exit status 1) $ git commit -q -m 'Explain how to run the site' $ git log --format=%s Explain how to run the site Update readme. Share the commit message check Add a README
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node share_hooks.mjs
Ravi’s fresh clone had no hooks, so his Update readme. commit, full stop and all, went straight in. Git does store
the executable bit with the file, as the mode 100755 that git ls-files --stage printed, so the committed hook
arrived ready to run. After git config set core.hooksPath .githooks, git hook list commit-msg found it, and the
same mistake was refused. A relative core.hooksPath is resolved from the folder where hooks run, which is the top of
the working tree. One side effect to know: with core.hooksPath set, Git ignores .git/hooks entirely, so any
personal hooks there stop running.
Configuration-based hooks (Git 2.54 and later)
Since Git 2.54, a hook can also be named in Git’s configuration instead of being a file named after the event. Each
hook has a name of your choice, a command (a path or a one-line shell command, which receives the hook’s
arguments) and one or more event lines. Several hooks can then run for one event, which a single
.git/hooks/commit-msg file cannot do. The project below commits two scripts and a file in Git’s configuration format
that names them; each clone includes that file once with include.path, whose relative path is read from the
location of .git/config, so ../hooks.ini is the file at the project’s top.
# Configuration-based hooks (Git 2.54 and later), committed with the project.
# Each person turns them on once in their clone:
# git config set include.path ../hooks.ini
[hook "summary"]
command = scripts/check-summary.sh
event = commit-msg
[hook "sign-off"]
command = scripts/require-signoff.sh
event = commit-msg config_hooks.mjs
// Configuration-based hooks (Git 2.54 and later): hooks named in Git's settings instead of files named after the event,
// so one event can run several of them. The project commits the scripts and hooks.ini; each clone includes the file.
import { copyFileSync } from 'node:fs';
import { join } from 'node:path';
import { asha, ravi, freshFolder, git, installHook, newRepo, show, write, commit } from './git-kit.mjs';
const root = freshFolder('config-hooks');
const site = newRepo(root, asha, 'fest-site');
commit(site, asha, 'README.md', '# Fest site\n', 'Add a README');
console.log('# Asha commits two hook scripts and hooks.ini, which names them');
installHook(site, 'commit-msg.sh', 'scripts/check-summary.sh');
installHook(site, 'require-signoff.sh', 'scripts/require-signoff.sh');
copyFileSync(join(import.meta.dirname, 'hooks.ini'), join(site, 'hooks.ini'));
show(site, asha, 'add', 'scripts', 'hooks.ini');
show(site, asha, 'commit', '-q', '-s', '-m', 'Share the commit message hooks');
console.log('\n# Ravi clones the project and includes its hook settings, once');
show(root, ravi, 'clone', '-q', 'fest-site', 'ravi-site');
const mine = join(root, 'ravi-site');
show(mine, ravi, 'hook', 'list', 'commit-msg');
show(mine, ravi, 'config', 'set', 'include.path', '../hooks.ini');
show(mine, ravi, 'hook', 'list', '--show-scope', 'commit-msg');
console.log('\n# Both hooks run, in the order of the settings, and the commit needs both to pass');
write(mine, 'README.md', '# Fest site\n\nThe schedule is in schedule.txt.\n');
git(mine, ravi, 'add', 'README.md');
show(mine, ravi, 'commit', '-q', '-m', 'Point to the schedule');
show(mine, ravi, 'commit', '-q', '-s', '-m', 'Point to the schedule');
show(mine, ravi, 'log', '-1', '--format=%s%n%(trailers)');
console.log('\n# Turn one hook off in this clone only');
show(mine, ravi, 'config', 'set', 'hook.sign-off.enabled', 'false');
show(mine, ravi, 'hook', 'list', 'commit-msg');
console.log('\n# core.hooksPath=/dev/null switches off the hooks folder, not the configured hooks; --no-verify skips both');
write(mine, 'README.md', '# Fest site\n\nThe schedule is in schedule.txt.\nThe menu is in menu.txt.\n');
git(mine, ravi, 'add', 'README.md');
show(mine, ravi, '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'Point to the menu.');
show(mine, ravi, 'commit', '-q', '--no-verify', '-m', 'Point to the menu.');
show(mine, ravi, 'log', '--format=%s'); Output
# Asha commits two hook scripts and hooks.ini, which names them $ cp hooks/commit-msg.sh scripts/check-summary.sh && chmod 755 scripts/check-summary.sh $ cp hooks/require-signoff.sh scripts/require-signoff.sh && chmod 755 scripts/require-signoff.sh $ git add scripts hooks.ini $ git commit -q -s -m 'Share the commit message hooks' # Ravi clones the project and includes its hook settings, once $ git clone -q fest-site ravi-site $ git hook list commit-msg warning: no hooks found for event 'commit-msg' (exit status 1) $ git config set include.path ../hooks.ini $ git hook list --show-scope commit-msg local summary local sign-off # Both hooks run, in the order of the settings, and the commit needs both to pass $ git commit -q -m 'Point to the schedule' commit-msg: add a Signed-off-by trailer (git commit -s) (exit status 1) $ git commit -q -s -m 'Point to the schedule' $ git log -1 --format='%s%n%(trailers)' Point to the schedule Signed-off-by: Ravi Menon <ravi@example.com> # Turn one hook off in this clone only $ git config set hook.sign-off.enabled false $ git hook list commit-msg summary disabled sign-off # core.hooksPath=/dev/null switches off the hooks folder, not the configured hooks; --no-verify skips both $ git -c core.hooksPath=/dev/null commit -q -m 'Point to the menu.' commit-msg: end the summary without a full stop summary: Point to the menu. (exit status 1) $ git commit -q --no-verify -m 'Point to the menu.' $ git log --format=%s Point to the menu. Point to the schedule Share the commit message hooks Add a README
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node config_hooks.mjs
hooks/require-signoff.sh
#!/bin/sh
# commit-msg hook for a project that asks every commit for a Developer
# Certificate of Origin sign-off: a "Signed-off-by:" trailer (git commit -s).
if grep -q '^Signed-off-by: ' "$1"; then
exit 0
fi
echo 'commit-msg: add a Signed-off-by trailer (git commit -s)' >&2
exit 1 git hook list --show-scope commit-msg listed both hooks in the order of their settings, from the local scope,
because the included file is read as part of .git/config. The first commit passed the summary check but had no
Signed-off-by trailer, so the second hook refused it; git commit -s added the trailer and both passed. Setting
hook.sign-off.enabled to false turned one hook off in this clone without touching the shared file. If a file in
the hooks folder also exists for the same event, it runs after the configured hooks.
The last two commands show a detail that is easy to get wrong on Git 2.54. Setting core.hooksPath to /dev/null,
which Git’s documentation describes as a way to switch hooks off, disabled only the hooks folder: the configured
summary hook still ran and refused Point to the menu.. --no-verify skipped both kinds. On Git older than 2.54 the
hook.* settings are ignored, so a team that uses them should check git --version first. The versions behind each
way of sharing hooks:
- Git 2.9
core.hooksPath setting
A setting names the folder Git looks in for hooks, so a team can commit its hooks in an ordinary folder such as .githooks and point each clone at it once. With it set, Git no longer looks in .git/hooks at all.
- Git 2.54
Hooks named in the configuration new settings
A hook can be a hook.<name>.command with one or more hook.<name>.event lines, so several hooks can run for one event. git hook list shows them, and hook.<name>.enabled false turns one off in a clone. A file in the hooks folder for the same event runs after them.
- Git 2.55
Configured hooks in parallel
New settings let configured hooks run in parallel. commit-msg and pre-commit hooks still run one after another, because they read and may change the same files.
- Git 3.0, planned
safe.bareRepository becomes explicit planned
Git plans to refuse a bare repository that it finds by walking up from the current folder, because a cloned project can hide one with hooks configured inside. You can set it today; the release has no date yet.
The timeline has 4 events, from Git 2.9 to Git 3.0, planned.
Checkpoint
Check: Sharing hooks with a team
Hook managers install one hook that runs many checks
Hand-written hooks are fine for one or two checks. Projects that run linters, formatters and scanners in several languages usually use a hook manager: a tool that reads one configuration file, installs a small hook that calls it, and can hand each check just the files that are staged.
The pre-commit framework, written in Python, reads .pre-commit-config.yaml, in which each entry names a
repository of hook definitions, the rev to use and the hook ids. pre-commit install writes .git/hooks/pre-commit,
and the first run downloads and builds each hook’s own environment, which can take minutes and is reused afterwards.
It runs the hooks on the staged content only, stashing unstaged changes while they run, which avoids the trap of the
password session. pre-commit run --all-files runs the same checks over the whole repository, which is also how you
run them in CI. Because rev is treated as an immutable version, pre-commit autoupdate moves it forward deliberately,
and --freeze pins commit hashes instead of tag names.
# .pre-commit-config.yaml: the checks the pre-commit framework runs on the staged files before each commit.
# Run `pre-commit install` once in each clone; it writes .git/hooks/pre-commit. In CI: pre-commit run --all-files
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=1024']
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks Lefthook, written in Go and shipped as a single binary without dependencies, works with any language and reads
lefthook.yml. lefthook install writes small wrappers into .git/hooks that run lefthook run <hook>; jobs can
run in parallel, and placeholders such as {staged_files} and {1}, the hook’s first argument, are filled in for
each run.
# lefthook.yml: Lefthook runs these jobs from the small hooks that `lefthook install` writes into .git/hooks.
pre-commit:
parallel: true
jobs:
- name: whitespace and conflict markers
run: git diff --cached --check
- name: secrets
run: gitleaks git --pre-commit --redact --staged --verbose
commit-msg:
jobs:
- name: summary line
run: sh scripts/check-summary.sh {1} Husky, for Node.js projects, keeps one shell script per hook in a .husky folder. npx husky init creates
.husky/pre-commit and updates the prepare script in package.json, so the hooks are set up again after every
npm install. Side by side, the three differ most in where their checks come from and how a clone gets them:
| Criterion | pre-commit | Lefthook | Husky |
|---|---|---|---|
| Written in | Python | Go (one binary) | JavaScript |
| Configuration | .pre-commit-config.yaml | lefthook.yml | one script per hook in .husky/ |
| Set up in a clone | pre-commit install | lefthook install | npm install (the prepare script) |
| Where checks come from | hook repositories pinned by rev, each in its own environment | commands you write, any language | commands you write |
| Staged files only | yes, unstaged changes are stashed during the run | with {staged_files} | only if your command limits itself |
| When to choose | Many languages and ready-made checks | Fast parallel checks in any project | A Node.js project that already uses npm scripts |
All three still produce ordinary hooks in each clone, so everything this lesson says about cloning and
--no-verify applies to them as well.
Not affiliated
MySmartCoPilot is not affiliated with GitHub, the Git project, pre-commit, Evil Martians (Lefthook), Husky or Gitleaks. Their tools and features are described from their public documentation, linked under References.
Checkpoint
Check: Hook managers
Only the server and CI enforce a rule, and every hook runs code
A hook runs with your permissions, on your computer, every time you commit or push. That is what makes it useful and
what makes it a risk. A shared .githooks folder or hooks.ini means that whoever can merge a change to those files
decides what runs on every teammate’s computer, so check changes to them as carefully as any code you would run.
Never point core.hooksPath at a folder from a repository you do not trust.
Security
Git’s plan for version 3.0 changes the default of safe.bareRepository from all to explicit for this reason. A
cloned project can contain a bare repository in one of its folders, with hooks configured in it; with the old default,
Git finds that inner repository as soon as you run a Git command inside the folder, and its hooks fire. Many shell
prompts run git status to show the branch, and git status can start the fsmonitor hook, so entering the folder can
be enough. You can set git config set --global safe.bareRepository explicit today.
server-side hook: A hook that runs in the repository receiving a push, such as pre-receive, update or post-receive. The person pushing cannot skip it, so pre-receive and update can enforce a rule. run while it receives a push. pre-receive runs once and can refuse the whole push; update
runs once per ref and refuses that ref alone, as in the pre-push session; post-receive runs after the refs have
moved, for notifications or deployments, and cannot refuse anything. The person pushing cannot skip any of them.
GitHub Enterprise Server, which an organisation runs itself, supports pre-receive hooks that check every push. On
GitHub.com, the same job is done by settings: rulesets can require a pull request before merging, block force
pushes to main and require required status check: A check, usually a CI job, that a branch rule or ruleset says must pass before changes can be merged into a protected branch. Only people the rule lets bypass it can merge without it. to pass, and push protection refuses a push that contains a recognised
secret before it is stored, and is on by default for your own pushes to public repositories. Each layer, and who can
get past it:
| Where the check runs | Who can get past it | What it is for |
|---|---|---|
| A hook in your clone | anyone: --no-verify, or never installing it |
feedback seconds after a mistake |
| A required status check from CI | only people the rule lets bypass it | the hooks’ checks, where they cannot be skipped |
| A ruleset on GitHub.com | only roles, teams or apps on its bypass list | pull requests before merging, no force pushes |
| Push protection | anyone with write access, by giving a reason, unless bypass is delegated | recognised secrets, before they are stored |
pre-receive and update on your own server |
nobody who pushes | any rule the server can check |
A required status check is the result of CI, which runs your checks on every push and pull request on a machine that no developer controls. That is what the next lesson of this module builds with GitHub Actions: the same checks as your hooks, run where they cannot be skipped.
Key idea
A rule that must hold lives where the person pushing cannot switch it off, in required checks, rulesets and the server’s own hooks; a hook in a clone gives fast feedback, and runs only code you trust.
Checkpoint
Check: Enforcement and trust
Practise: decide what a pre-commit hook should refuse
A hook usually has two parts: commands that collect what is staged, and a decision about what to refuse. Write the
decision for two new checks, a TODO without an issue number in an added line and a staged file over 1 MiB, from the
output of git diff --cached -U0 and the staged files’ sizes:
Exercise · Medium · JavaScript
Decide what a pre-commit hook should refuse
The fest website's pre-commit hook needs two new checks. Write the part that decides, stagedProblems(diff, sizes) in checks.mjs, which returns the problems as a list of messages; the hook would print them and exit with status 1 when the list is not empty. It receives:
diff, the text thatgit diff --cached -U0 --no-colorprints: for each staged file adiff --git a/<path> b/<path>line, a few header lines, then hunks. Each hunk starts with a line such as@@ -4,0 +5,2 @@, whose+5is the line number, in the new version of the file, of the hunk's first added line. Added lines start with+, removed lines with-. Paths contain no spaces.sizes, the size in bytes of each staged file, by path (whatgit cat-file -s :<path>prints).
Report two kinds of problem:
- An added line that contains the word
TODO(in capitals) but no issue number, a#followed by digits on the same line, such asTODO(#42): report<path>:<line>: TODO without an issue number. Removed lines are fine. - A staged file larger than 1 MiB (1,048,576 bytes): report
<path>: <size> bytes, over the 1 MiB limit, with the size as a plain number. A file the commit deletes (its header has adeleted file modeline) is not checked.
List the files in the order the diff gives them, and inside a file its size problem first, then its TODO problems from top to bottom. Return an empty list when the staged changes are fine.
Starter code · checks.mjs
/**
* The problems a pre-commit hook should report for the staged changes, as a list of messages.
* diff: what `git diff --cached -U0 --no-color` printed
* sizes: the size in bytes of each staged file, by path
*/
export function stagedProblems(diff, sizes) {
// Replace this line with your code.
return [];
} The sample tests · checks.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { stagedProblems } from './checks.mjs';
// Staged changes as `git diff --cached -U0 --no-color` printed them in a real repository, with the sizes that
// `git cat-file -s :<path>` reported for the staged files.
const MENU = `diff --git a/src/menu.js b/src/menu.js
index 9f87525..2441612 100644
--- a/src/menu.js
+++ b/src/menu.js
@@ -2 +2,3 @@ const menu = [];
-// TODO remove the old menu
+const todoList = [];
+// TODO(#42) add the dinner menu
+// TODO add prices
`;
const LIST = `diff --git a/src/list.txt b/src/list.txt
index 92dfa21..e3061e4 100644
--- a/src/list.txt
+++ b/src/list.txt
@@ -2,0 +3 @@ b
+TODO: see #7
@@ -10,0 +12,2 @@ j
+k TODO
+l
`;
const PHOTOS = `diff --git a/data/photos.zip b/data/photos.zip
new file mode 100644
index 0000000..561f00b
Binary files /dev/null and b/data/photos.zip differ
`;
const DELETED = `diff --git a/data/old.csv b/data/old.csv
deleted file mode 100644
index 3367afd..0000000
--- a/data/old.csv
+++ /dev/null
@@ -1 +0,0 @@
-old
`;
const MiB = 1048576;
test('clean changes give an empty list', () => {
const clean = `diff --git a/src/menu.js b/src/menu.js
index 9f87525..5d2c7a0 100644
--- a/src/menu.js
+++ b/src/menu.js
@@ -1,0 +2 @@ const menu = [];
+const prices = { lunch: 120 };
`;
assert.deepEqual(stagedProblems(clean, { 'src/menu.js': 64 }), []);
});
test('an added TODO without an issue number is reported with its line in the new file', () => {
assert.deepEqual(stagedProblems(MENU, { 'src/menu.js': 111 }), ['src/menu.js:4: TODO without an issue number']);
});
test('TODO with #<digits> is fine, and line numbers follow every hunk header', () => {
assert.deepEqual(stagedProblems(LIST, { 'src/list.txt': 42 }), ['src/list.txt:12: TODO without an issue number']);
});
test('a staged file over 1 MiB is reported with its size', () => {
assert.deepEqual(stagedProblems(PHOTOS, { 'data/photos.zip': 2 * MiB }), ['data/photos.zip: 2097152 bytes, over the 1 MiB limit']);
});
test('exactly 1 MiB is still allowed', () => {
assert.deepEqual(stagedProblems(PHOTOS, { 'data/photos.zip': MiB }), []);
assert.deepEqual(stagedProblems(PHOTOS, { 'data/photos.zip': MiB + 1 }), ['data/photos.zip: 1048577 bytes, over the 1 MiB limit']);
});
test('a deleted file is not checked, and files keep the order of the diff', () => {
const sizes = { 'data/old.csv': 3 * MiB, 'data/photos.zip': 2 * MiB, 'src/list.txt': 42, 'src/menu.js': 111 };
assert.deepEqual(stagedProblems(DELETED + PHOTOS + LIST + MENU, sizes), [
'data/photos.zip: 2097152 bytes, over the 1 MiB limit',
'src/list.txt:12: TODO without an issue number',
'src/menu.js:4: TODO without an issue number',
]);
});
test('inside one file, the size problem comes before its TODOs', () => {
assert.deepEqual(stagedProblems(MENU, { 'src/menu.js': 1500000 }), [
'src/menu.js: 1500000 bytes, over the 1 MiB limit',
'src/menu.js:4: TODO without an issue number',
]);
});
test('header lines are not added lines, even when the path says TODO', () => {
const notes = `diff --git a/docs/TODO.md b/docs/TODO.md
new file mode 100644
index 0000000..8cb86ed
--- /dev/null
+++ b/docs/TODO.md
@@ -0,0 +1 @@
+- Book the auditorium (#12)
`;
assert.deepEqual(stagedProblems(notes, { 'docs/TODO.md': 28 }), []);
}); A hint
Read the diff line by line and keep three things as you go: the current file's path (from each diff --git line, the part after b/), whether that file is being deleted, and the next line number in the new file. A hunk header sets that number (/^@@ -\S+ \+(\d+)/ captures it); every added line uses it and then increases it by one, and removed lines leave it alone.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
To replay a session of this lesson, copy its example files, git-kit.mjs and the hooks folder into one folder and
run the session with Node.js, for example node first_hook.mjs. It builds its repositories in a folder named after
the session, made anew on every run in the folder you start it from, and switches your own Git settings off for its
commands.
Then work through the self-check on the seven ideas of this lesson, from a hook’s exit status to where rules are enforced. Each answer comes with its reason, and the next question picks the idea that needs it most:
Practice that adapts to you
Practise this lesson
18 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is a Git hook, and where does Git look for one?
A hook is a program that Git runs at a fixed point of its own work, such as before a commit is made (pre-commit),
after the message is written (commit-msg) or before a push (pre-push). Git looks for an executable file named
after the event in the repository’s .git/hooks folder, or in the folder that core.hooksPath names; since Git 2.54
a hook can also be named in Git’s configuration with hook.<name>.command and hook.<name>.event. A file without
the executable bit is ignored. For the hooks that check something, a non-zero exit status stops the operation, so a
pre-commit hook that exits with 1 means no commit. A new repository has only disabled samples, ending in .sample,
copied from Git’s template folder.
Warm-up (fresher to 2–5 years): Why can a team not rely on Git hooks alone to enforce its rules?
Because hooks on a developer’s computer are optional in three ways. They are not cloned: a new clone gets only Git’s
disabled samples, so every person has to install or switch on the team’s hooks. They can be skipped:
git commit --no-verify and git push --no-verify bypass pre-commit, commit-msg and pre-push. And anyone can
edit or delete them in their own clone. Hooks are still worth having, because they catch mistakes seconds after they
are made. Rules that must hold are enforced where the developer cannot switch them off: on the server, with
pre-receive and update hooks on a self-hosted Git server, or on a hosting service with rulesets and required status
checks from CI. Push protection blocks recognised secrets as well, but anyone with write access can bypass it with a
reason unless bypass is delegated to chosen people.
Two harder questions start from the sessions of this lesson: hooks that people skip when time is short, and a password that a hook let through although it was gone from the folder.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your team wants every commit message checked and no passwords committed. The repository's hooks exist, but developers skip them with --no-verify when they are in a hurry, and a password reached main last month. Design the setup across hooks, CI and the hosting service, and say what each layer catches.
-
Mid to senior
A pre-commit hook greps the changed files for passwords. A developer staged a file containing a password, deleted the password in the editor without staging that change, and committed. The hook passed and the password was committed. Explain why, and fix the hook.
Key takeaways
- A hook is an executable file named after its event in
.git/hooks, or in the foldercore.hooksPathnames, and a non-zero exit frompre-commit,commit-msgorpre-pushstops the operation. commit-msggets the message file’s path, reads it throughgit stripspace --strip-comments, and counts characters withwc -m, not bytes.- A hook that reads the folder can let through a commit it never saw, so checks read the staged copy with
git diff --cached,git show :<path>andgit grep --cached. pre-pushreads one line per ref and stops the whole push, while the server’supdatehook refuses a ref whatever the pusher types.- Hooks are not cloned: a team shares them with a committed folder and
core.hooksPath, or, on Git 2.54 and later, with configured hooks in an included file, and hook managers produce ordinary hooks too. --no-verifyskips every client hook, so a rule that must hold belongs in required checks, rulesets or the server’s hooks, and every hook you turn on runs code with your permissions.
Terms in this lesson
- Git hook
- A program that Git runs at a fixed point of its work, such as pre-commit before a commit or pre-push before a push. A non-zero exit from a checking hook stops the operation. Hooks are not cloned, and --no-verify skips the ones on your computer.
- required status check
- A check, usually a CI job, that a branch rule or ruleset says must pass before changes can be merged into a protected branch. Only people the rule lets bypass it can merge without it.
- server-side hook
- A hook that runs in the repository receiving a push, such as pre-receive, update or post-receive. The person pushing cannot skip it, so pre-receive and update can enforce a rule.
References
- githooks, hooks used by Git (Git)
- git-hook documentation (configuration-based hooks) (Git)
- git-hook documentation, version 2.54.0 (Git)
- git-config documentation (core.hooksPath, include.path) (Git)
- git-commit documentation (--no-verify) (Git)
- git-push documentation (--no-verify) (Git)
- git-init documentation (template directory) (Git)
- git-grep documentation (--cached) (Git)
- git-diff documentation (--check) (Git)
- git-stripspace documentation (--strip-comments) (Git)
- Git 2.9 release notes (core.hooksPath) (Git)
- Git 2.54 release notes (hooks defined in the configuration) (Git)
- Git 2.55 release notes (configured hooks in parallel) (Git)
- Upcoming breaking changes (Git 3.0, safe.bareRepository) (Git)
- pre-commit, a framework for managing pre-commit hooks (pre-commit)
- pre-commit-hooks (README of version 6.0.0) (pre-commit)
- Gitleaks (Gitleaks)
- Lefthook documentation (Evil Martians)
- Lefthook configuration, run (Evil Martians)
- Lefthook (README, written in Go, a single binary) (Evil Martians)
- Husky, get started (Husky)
- About rulesets (GitHub)
- Available rules for rulesets (GitHub)
- About push protection (GitHub)
- About pre-receive hooks (GitHub Enterprise Server) (GitHub)
- Shell (Debian Wiki) (Debian)
- ShellCheck (ShellCheck)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress