Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with git 2.54.0, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Where Git runs hooks: pre-commit, commit-msg and pre-push on your computer, then pre-receive, update and post-receive on the server.On your computeronly where installed · skipped by --no-verifyOn the serverruns for every push · the pusher cannot skip itgit commitpre-commitchecks the staged changescommit-msgchecks the message fileThe commit is madegit pushpre-pushchecks the refs on standard inputpre-receiveonce per push: all or nothingupdateonce per refpost-receiveafter the refs moved: notify, deploylaterobjects and refs sent

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-commit runs at git commit, before the message is asked for. It receives nothing and reads the staged changes itself; a non-zero exit stops the commit.
  • commit-msg runs at git commit and git 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-push runs at git 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.

hooks/pre-commit.sh, installed as .git/hooks/pre-commit Shell · hooks/pre-commit.sh
#!/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.

A pre-commit hook: ignored, then stopping a commit, then skipped

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:

Interactive · message by message One pre-commit hook, four commits Step through Asha's four commits and watch when Git runs the hook, what it decides, and when it never asks it.
One pre-commit hook, four commits, step 6 of 6. Messages so far: Asha to git commit: Add the day 1 schedule; git commit to pre-commit hook: run before the commit; pre-commit hook to git commit: exit status 1; pre-commit hook to git commit: exit status 0; Asha to git commit: commit --no-verify.Ashain fest-sitegit commitpre-commit hook.git/hooks/pre-commitAdd the day 1 schedulerun before the commitexit status 1exit status 0commit --no-verifynever run

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Chmod Calculator See what chmod 755 means bit by bit, and why a hook with mode 644 is ignored.

Checkpoint

Check: How Git finds and runs a hook

  1. Question 1 of 2 What does a non-zero exit status from a pre-commit hook do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It stops the commit, so no commit is made

    pre-commit, commit-msg and pre-push are checking hooks: exit status 0 lets the operation go on, any other status stops it. In the session the trailing space made the hook exit with 1, and no commit was made.

  2. Question 2 of 2 A hook runs ./scripts/lint.sh, a path relative to the project. From which folder does Git run the hook?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The top folder of the working tree, so the path is relative to the project's root

    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.

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.

hooks/commit-msg.sh, installed as .git/hooks/commit-msg Shell · hooks/commit-msg.sh
#!/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
Six messages through the hook, then the hook tested on its own JavaScript · commit_msg.mjs
// 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

  1. Question 1 of 2 What does Git give a commit-msg hook to work with?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The path of the file that holds the proposed message, as its only argument

    git commit writes the proposed message to a file and runs commit-msg with that file's path. A non-zero exit refuses the commit, and nothing is recorded.

  2. Question 2 of 2 Why does the lesson's hook pass the message file through git stripspace --strip-comments before it checks it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An editor leaves the comment lines in the file, and Git removes them only after the hook has run

    git stripspace --strip-comments cleans the file 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 showed.

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.

The same hook, with and without --cached, on a password that is staged but no longer in the folder

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:

Interactive · step by step Which copy of settings.ini the hook reads Step through the failure and its fix, and watch which copy each hook searches and which copy the commit records.
Which copy of settings.ini the hook reads, step 6 of 6. Showing Working tree (password = ${DB_PASSWORD}), Index (password = ${DB_PASSWORD}), The new commit (password = ${DB_PASSWORD}), pre-commit hook. In focus: The new commit.git grep --cachedgit commitWorking treepassword = ${DB_PASSWORD}Indexpassword = ${DB_PASSWORD}The new commitpassword = ${DB_PASSWORD}pre-commit hookgit grep for passwords

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Secret & Credential Leak Scanner Paste a file or a log and see which lines look like keys, tokens or passwords, with the rule that matched.

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

  1. Question 1 of 2 Which copy of a file does git commit record?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The staged copy, in the index

    git show HEAD:settings.ini printed the password in the session, although the folder no longer had it: the commit recorded the staged copy.

  2. Question 2 of 2 Inside a hook, which command prints the staged copy of settings.ini?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: git show :settings.ini

    git diff --cached shows the staged changes, git show :<path> one file's staged copy and git grep --cached searches all of them.

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.

A pre-push hook that guards main, and the server hook that cannot be skipped

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

  1. Question 1 of 2 A line on a pre-push hook's standard input ends with a remote object name of 40 zeros. What does that mean?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The branch does not exist on the remote yet

    Each line is <local ref> <local object name> <remote ref> <remote object name>. In the session the push of the new branch add-menu arrived with 40 zeros as its remote object name.

  2. Question 2 of 2 git push origin main add-menu runs the hook, which refuses because one line names main. What does Git push?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing; a non-zero exit stops the whole push

    In the session the first push named two refs, the hook printed both lines and refused because of main, and Git pushed neither. Pushing add-menu alone then worked.

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; core.hooksPath turns on the shared ones JavaScript · share_hooks.mjs
// 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.

hooks.ini, committed with the project INI · hooks.ini
# 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
Two commit-msg hooks from the configuration, turned off one at a time

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:

Timeline · 4 events in order How sharing hooks changed, version by version Walk through the versions in order and check which ones the Git on your team's computers already has.
  1. 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.

  2. 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.

  3. 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.

  4. 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

  1. Question 1 of 2 Ravi sets core.hooksPath to .githooks. He also had a personal pre-push hook in .git/hooks. What happens to it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It stops running, because Git no longer looks in .git/hooks at all

    With core.hooksPath set, Git ignores .git/hooks entirely, so any personal hooks there stop running. Move them into the shared folder, or into configured hooks, to keep them.

  2. Question 2 of 2 On Git 2.54, git -c core.hooksPath=/dev/null commit still ran the configured summary hook and refused the message. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /dev/null switches off only the hooks folder; hooks named in the configuration still run

    The documented trick of pointing core.hooksPath at /dev/null disables the hooks folder, while configured hooks keep running. --no-verify skipped both kinds.

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: whitespace, conflict markers, large files and Gitleaks YAML · pre-commit-config.yaml
# .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: the same kind of checks with Lefthook YAML · lefthook.yml
# 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:

Three hook managers
Criterion pre-commitLefthookHusky
Written in PythonGo (one binary)JavaScript
Configuration .pre-commit-config.yamllefthook.ymlone script per hook in .husky/
Set up in a clone pre-commit installlefthook installnpm install (the prepare script)
Where checks come from hook repositories pinned by rev, each in its own environmentcommands you write, any languagecommands you write
Staged files only yes, unstaged changes are stashed during the runwith {staged_files}only if your command limits itself
When to choose Many languages and ready-made checksFast parallel checks in any projectA 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

  1. Question 1 of 2 How does the pre-commit framework avoid the trap of the password session?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It runs the hooks on the staged content only, stashing unstaged changes while they run

    The trap was a hook that read the folder while the commit recorded the index. pre-commit stashes the unstaged changes during the run, so the checks see the staged content.

  2. Question 2 of 2 A teammate clones a project that uses Lefthook. What must they do before its hooks run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Run lefthook install, which writes the small hooks into .git/hooks

    All three managers still produce ordinary hooks in each clone, so each person runs the install step once: pre-commit install, lefthook install, or npm install for Husky's prepare script.

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

  1. Question 1 of 2 Which of these can the person pushing never get past?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An update hook on the server that receives the push

    Server-side hooks run while the server receives the push, so the pusher cannot skip them. Rulesets and push protection are strong too, but each has a bypass that the repository's settings decide.

  2. Question 2 of 2 Why does a change to a project's shared .githooks folder deserve as much care as any code?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Whatever it says runs on every teammate's computer, with their permissions

    A hook runs with your permissions every time you commit or push, which is what makes it useful and what makes it a risk. Never point core.hooksPath at a folder from a repository you do not 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 that git diff --cached -U0 --no-color prints: for each staged file a diff --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 +5 is 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 (what git cat-file -s :<path> prints).

Report two kinds of problem:

  1. An added line that contains the word TODO (in capitals) but no issue number, a # followed by digits on the same line, such as TODO(#42): report <path>:<line>: TODO without an issue number. Removed lines are fine.
  2. 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 a deleted file mode line) 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.

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.

  1. Question 1 of 18 Which file does Git run as the pre-commit hook of a repository with no core.hooksPath set?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: .git/hooks/pre-commit, executable, with no extension

    git init copies disabled samples into .git/hooks. To turn one into a hook, name it exactly after its event, with no extension, and make it executable.

  2. Question 2 of 18 You save a script as .git/hooks/pre-commit but forget to make it executable. What happens at the next git commit?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: The commit is made without running the hook, and Git prints a hint that the hook was ignored

    Git ignores a hook that lacks the executable bit. Git 2.54 says so in a hint (advice.ignoredHook) and goes on with the commit, which is how the first run in this lesson committed a line with a trailing space.

  3. Question 3 of 18 How do you test a commit-msg hook without making any commit?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: Run it with a message file, for example sh .git/hooks/commit-msg message.txt, and read its exit status

    The session tested the hook on message.txt and rupee.txt with both shells; every run ended with exit status 0, the sign of an accepted message.

  4. Question 4 of 18 Why does the lesson's hook accept the summary fixup! Add the day 1 schedule without checking it?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: A fixup! commit is only a pointer to the commit it will be folded into, so its summary is never kept

    fixup!, squash! and amend! commits are folded into another commit later, by an interactive rebase, so the hook lets them through at once.

  5. Question 5 of 18 Why does the lesson's commit-msg hook measure the summary with wc -m instead of ${#summary}?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: In dash, ${#summary} counts bytes, so each ₹ counts as 3 and a summary of 72 characters can be refused

    The run measured the same string as 22 characters with sh on macOS and 24 with dash, which counts bytes. wc -m counts characters in a UTF-8 locale in both shells, so the 72-character summary with a ₹ sign passed in both.

  6. Question 6 of 18 In the failure drill, why did the first hook let through a commit that contained a password?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: It searched the files in the folder, where the password was already gone, not the staged copy that the commit records

    The password was staged, then removed from the file without staging that change. git grep without --cached searched the folder and found nothing; git grep --cached searches the staged copies, which are what git commit records, and refused the commit.

  7. Question 7 of 18 Which command lists only the files that the commit adds or changes, for a linter in a pre-commit hook?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: git diff --cached --name-only --diff-filter=ACMR

    Checking only the staged files keeps a pre-commit hook fast, which is what stops people from skipping it; slow suites belong in pre-push or CI.

  8. Question 8 of 18 A hook runs the lesson's password pattern over the whole output of git diff --cached instead of over the staged files. Which staged change could it refuse although the commit makes things safer?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: A change that removes the line password = hunter2-not-real

    git diff --cached shows removed lines as well as added ones. A check meant for what a commit adds reads only the lines that start with +, as the exercise of this lesson does for TODOs.

  9. Question 9 of 18 What does Git give a pre-push hook?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: The remote's name and address as arguments, and one line per ref on standard input

    Each input line is <local ref> <local object name> <remote ref> <remote object name>, and a non-zero exit stops the whole push before anything is sent.

  10. Question 10 of 18 Which of these can a developer skip with --no-verify? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 10 of 18

    Answer:

    • A commit-msg hook named in their configuration (hook.<name>.event)
    • The pre-push hook in their clone
    • The pre-commit hook in their clone

    git commit --no-verify bypasses pre-commit and commit-msg, configured hooks included, and git push --no-verify bypasses pre-push. Hooks on the server run inside the server's own Git while it receives the push, so nothing the person pushing types can skip them; the drill's update hook refused main even with --no-verify.

  11. Question 11 of 18 Asha committed .githooks/commit-msg. Why did it arrive in Ravi's clone ready to run?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: Git stores the executable bit with the file, as mode 100755

    A committed hook keeps its mode, so after git config set core.hooksPath .githooks, git hook list found it and the same mistake was refused.

  12. Question 12 of 18 Asha has a commit-msg hook in her .git/hooks folder. Ravi clones her repository. Which hooks does Ravi's clone run?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: None. A clone fills .git/hooks from the template folder of Ravi's own Git, which holds only disabled samples

    A clone is a new repository. Its .git/hooks comes from the template folder, as with git init, and nothing in another repository's .git/hooks or .git/config is transferred. A team shares hooks by committing them and pointing core.hooksPath (or an included hook configuration) at them in each clone.

  13. Question 13 of 18 Two commit-msg hooks are named in the configuration, and .git/hooks/commit-msg also exists. In which order do they run on Git 2.54?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: The configured hooks in the order Git read their settings, then the file in the hooks folder

    git hook list commit-msg shows the order: configured hooks as Git reads their hook.<name>.event settings, and the hook from the hooks folder last. Running hooks in parallel came in Git 2.55, and commit-msg hooks always run one after another anyway.

  14. Question 14 of 18 What does pre-commit install do in a clone?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: It writes .git/hooks/pre-commit, a small hook that calls the framework

    pre-commit install writes the hook; the first run then builds each hook's own environment, which takes a while once and is reused afterwards.

  15. Question 15 of 18 How do you run a pre-commit framework project's checks over the whole repository in CI?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: pre-commit run --all-files

    The same configuration then serves both places: fast checks on staged files at each commit, and every file in CI, where nobody can skip them.

  16. Question 16 of 18 Push protection blocks a push that contains a recognised secret. Can a developer with write access still push it?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: Yes, by giving a bypass reason, unless the repository delegates bypass to chosen people

    Push protection catches recognised secrets before they are stored, and it can be bypassed with a reason unless bypass is delegated. Treat a secret that reached the remote as exposed either way.

  17. Question 17 of 18 Which server-side hook runs once for each ref a push wants to change, and can refuse that ref alone?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: update

    pre-receive decides for the whole push, update for each ref, and post-receive only reacts once the refs have moved, for notifications or deployments.

  18. Question 18 of 18 Why can merely entering the folder of a cloned project start hooks you never turned on, and what does Git plan to change about it?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: An embedded bare repository with hooks configured can be found by a prompt's git status; Git 3.0 plans safe.bareRepository explicit

    A project can hide a bare repository in a subfolder with hooks configured in it, and git status, which many prompts run, can start the fsmonitor hook. safe.bareRepository explicit makes Git refuse a bare repository it finds by walking up from the current folder.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A hook is an executable file named after its event in .git/hooks, or in the folder core.hooksPath names, and a non-zero exit from pre-commit, commit-msg or pre-push stops the operation.
  • commit-msg gets the message file’s path, reads it through git stripspace --strip-comments, and counts characters with wc -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> and git grep --cached.
  • pre-push reads one line per ref and stops the whole push, while the server’s update hook 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-verify skips 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.