The previous module ended with Ana reverting in production a commit that broke task storage. In the team's retrospective a sentence came up that gets repeated in every team in the world: "we would have caught this if somebody had looked". And two days later, Bruno pushed a forgotten console.log('HERE!!') in app.js that made it all the way to main.

Neither problem is Git's. They are problems of discipline, and human discipline fails at seven in the evening on a Friday. What Git can do is run checks automatically at specific moments: right before creating a commit, right after merging, right before sending to the server.

Those attachment points are called hooks, and they are the first of this module's tools. With them, task-manager will stop accepting commits with console.log, with unresolved conflict markers or with one-word messages. And along the way you will understand why a client-side hook is never a security measure, however much many people would like it to be.

Contents

  1. What a hook is and where it lives
  2. The factory .sample files
  3. Anatomy of a hook: input, output and return code
  4. Table of the client-side hooks
  5. A real pre-commit for task-manager
  6. A commit-msg that validates the shape of the message
  7. More useful hooks: post-checkout, post-merge, pre-push
  8. --no-verify: why client-side hooks are not a security control
  9. The problem with .git/hooks: it is not version-controlled
  10. core.hooksPath and hook managers
  11. Server-side hooks, in one page

  1. What a hook is and where it lives

A hook is simply an executable file with a specific name inside .git/hooks/. There is no registry, no configuration, no plugin: if the file exists, has the exact name Git expects and has execute permission, Git launches it at the corresponding moment.

cd ~/projects/task-manager
ls .git/hooks/
applypatch-msg.sample      pre-merge-commit.sample
commit-msg.sample          pre-push.sample
fsmonitor-watchman.sample  pre-rebase.sample
post-update.sample         pre-receive.sample
prepare-commit-msg.sample  push-to-checkout.sample
pre-applypatch.sample      update.sample
pre-commit.sample          sendemail-validate.sample

Remember from lesson 01-04 that .git/ is the real repository: the objects, the references and the configuration. hooks/ is one more subdirectory of that administrative space, and that has an important consequence we shall see in section 9: it is not part of the version-controlled content.

Three rules govern everything else:

  • The name is exact and has no extension. pre-commit, not pre-commit.sh nor precommit.
  • It has to be executable. chmod +x .git/hooks/pre-commit. This is mistake number one.
  • It can be written in any language. Git only executes it; the #! line (shebang) decides the interpreter. We shall use bash, but a #!/usr/bin/env node works just as well.

  1. The factory .sample files

When you run git init (or git clone), Git copies a set of examples into .git/hooks/. They all end in .sample precisely so that they do not run: since the name does not match the one Git looks for, they sit there as living documentation.

Enabling one means removing the suffix:

cd ~/projects/task-manager
mv .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

The pre-commit.sample that ships with Git is worth reading, because it does something genuinely useful: it detects non-ASCII file names and trailing whitespace, using git diff-index --check. It is a good starting point.

Where the .sample files come from. They are copied from a template, which by default lives in /usr/share/git-core/templates/ (Linux) or inside the Git installation on macOS/Windows. It can be changed with init.templateDir, and that is one way — crude, but real — of making all your new repositories be born with the same hooks.

  1. Anatomy of a hook: input, output and return code

A hook receives information in three possible ways, depending on which one it is:

Route Example
Command-line arguments commit-msg receives the path of the file with the message
Standard input (stdin) pre-push receives one line for each reference about to be sent
The repository itself Any hook can run git diff, git log, etc.

And it returns information in one single way: its exit code.

  • exit 0 → all correct, Git carries on.
  • exit other than 0 → in the pre hooks (pre-*), Git aborts the operation. In the post ones (post-*), Git has already done the work and the exit code is ignored in practice.

That distinction is the one to internalise:

flowchart LR
    A["git commit"] --> B{"pre-commit"}
    B -- "exit 0" --> C["prepare-commit-msg"]
    B -- "exit != 0" --> X["Commit ABORTED"]
    C --> D["Message editor"]
    D --> E{"commit-msg"}
    E -- "exit 0" --> F["The commit is created"]
    E -- "exit != 0" --> X
    F --> G["post-commit (informational)"]

Everything the hook writes to standard output or standard error is seen by the user in the terminal. That is why a well-written hook does not merely fail: it explains what failed and how to fix it.

  1. Table of the client-side hooks

These are the ones that run on Ana's, Bruno's or Carla's machine. The server-side ones are another family and we look at them in section 11.

Hook When it runs What it receives If it returns != 0
pre-commit Before asking for the message, with the index already staged Nothing Aborts the commit
prepare-commit-msg After generating the default message, before opening the editor 1) path of the message file 2) source (message, template, merge, squash, commit) 3) SHA if applicable Aborts the commit
commit-msg With the message already written, before creating the object 1) path of the file with the final message Aborts the commit
post-commit Just after creating the commit Nothing Ignored (it is already done)
pre-rebase Before starting a rebase 1) base branch 2) branch being rebased (empty if it is the current one) Prevents the rebase
post-checkout After a git checkout/switch/clone that changes the tree 1) previous SHA 2) new SHA 3) flag: 1 branch change, 0 file change Ignored (except that it can prevent the clone)
post-merge After successfully completing a merge 1) flag: 1 if it was a squash merge Ignored
pre-push Before transferring objects to the remote Args: 1) remote name 2) URL. Via stdin: <local-ref> <local-sha> <remote-ref> <remote-sha> for each ref Aborts the push

A few nuances that save surprises:

  • pre-commit does not see the message, because it does not exist yet. If you need the message, your hook is commit-msg.
  • pre-commit sees the index, not the working tree. It is the three-zones distinction from lesson 01-03, and in section 5 we shall see why it is critical.
  • prepare-commit-msg is rarely used to validate and often used to fill in: inserting the issue number derived from the branch name, for instance.
  • post-checkout also fires when cloning, which is useful for warning about dependencies that need installing.
  • pre-rebase is the standard mechanism for protecting branches: "main is never rebased" (the golden rule from lesson 05-01, turned into code).

There are more client-side hooks that we do not cover here (applypatch-msg, pre-applypatch, post-rewrite, pre-auto-gc, post-index-change…). git help hooks lists them all precisely; the eight in the table cover 95% of real uses.

  1. A real pre-commit for task-manager

Ana writes the hook the team needs. Requirements, taken straight from the two incidents:

  1. No console.log in the JavaScript code being committed.
  2. No unresolved conflict markers (<<<<<<<, =======, >>>>>>>), which is what happens when somebody commits in the middle of a badly finished merge (lesson 03-05).
#!/usr/bin/env bash
#
# .git/hooks/pre-commit — task-manager
# Rejects console.log and unresolved conflict markers.

set -euo pipefail

# Colours only if the output is a terminal
if [ -t 1 ]; then
  RED=$'\033[31m'; YELLOW=$'\033[33m'; PLAIN=$'\033[0m'
else
  RED=''; YELLOW=''; PLAIN=''
fi

failures=0

# ADDED, COPIED or MODIFIED files that are in the index.
# --cached: looks at the index, not the working tree.
# --diff-filter=ACM: ignores deletions (there is no point analysing them).
# -z + read -d '': supports names with spaces.
mapfile -d '' files < <(git diff --cached --name-only --diff-filter=ACM -z)

if [ ${#files[@]} -eq 0 ]; then
  exit 0
fi

for f in "${files[@]}"; do
  # The CONTENT OF THE INDEX is analysed, not the content on disk.
  content=$(git show ":$f" 2>/dev/null) || continue

  # 1. Conflict markers, in any text file
  if printf '%s\n' "$content" | grep -qE '^(<{7}|={7}|>{7})( |$)'; then
    echo "${RED}✗ $f contains unresolved conflict markers.${PLAIN}"
    failures=1
  fi

  # 2. console.log, only in JavaScript
  case "$f" in
    *.js)
      matches=$(printf '%s\n' "$content" | grep -nE 'console\.(log|debug)\(' || true)
      if [ -n "$matches" ]; then
        echo "${RED}✗ $f contains calls to console.log:${PLAIN}"
        printf '%s\n' "$matches" | sed 's/^/    /'
        failures=1
      fi
      ;;
  esac
done

if [ "$failures" -ne 0 ]; then
  echo
  echo "${YELLOW}Commit aborted by the pre-commit hook.${PLAIN}"
  echo "Fix the above, run 'git add' again and repeat the commit."
  echo "If you are absolutely sure: git commit --no-verify"
  exit 1
fi

exit 0

Installation:

chmod +x .git/hooks/pre-commit

What matters in this script, line by line for whatever is not obvious:

  • set -euo pipefail. -e aborts if a command fails, -u if an undefined variable is used, -o pipefail propagates the failure of any pipeline. Without this, a typo inside the hook makes it finish successfully and validate nothing — a broken hook that says "all good" is worse than having no hook at all.

  • git diff --cached --name-only --diff-filter=ACM. This is the key to the hook. --cached compares the index with HEAD, that is, exactly what is about to be committed. If you used the working tree, a git add -p (lesson 02-04) that had staged only half a line would give false positives from what was left out. --diff-filter=ACM leaves deleted files out.

  • git show ":$f". The syntax :<path> means "that path in the index". It is the same notation as the conflict stages from lesson 03-05 (:1:, :2:, :3:), with stage 0 implied. It is the same principle again: what gets validated is what gets committed, not what is on disk.

  • grep -qE '^(<{7}|={7}|>{7})( |$)'. Conflict markers are seven characters at the start of a line, followed by a space or by the end of the line. That precision stops a decorative line of dashes from triggering a false alarm.

  • || true after the console.log grep. grep returns 1 when it finds nothing, and with set -e that would kill the script. The || true neutralises that exit code.

  • The error message mentions --no-verify. That is deliberate: a hook that blocks without offering a way out ends up uninstalled by somebody who has had enough. Better for the emergency exit to be explicit and deliberate.

Testing the hook:

echo "console.log('HERE!!');" >> app.js
git add app.js
git commit -m "Add the pending task counter"
✗ app.js contains calls to console.log:
    142:  console.log('HERE!!');

Commit aborted by the pre-commit hook.
Fix the above, run 'git add' again and repeat the commit.
If you are absolutely sure: git commit --no-verify

The commit does not exist. git log has not changed, the index is still staged and all you need to do is fix and repeat.

A slow hook is a hook that gets uninstalled. The pre-commit runs on every commit. If it takes forty seconds because it launches the whole test suite, the team will start using --no-verify out of habit and you will have lost the game. Practical rule: in pre-commit, checks of under two seconds on the modified files. The heavy stuff goes in pre-push (section 7) or in continuous integration (lesson 07-06).

  1. A commit-msg that validates the shape of the message

The team's second hook checks the message. It receives the path of a temporary file with the message already written, and it can read it, modify it or reject it.

#!/usr/bin/env bash
#
# .git/hooks/commit-msg — task-manager
# Checks the SHAPE of the message, not its content.

set -euo pipefail

message_file="$1"

# First line that is neither a comment nor empty
subject=$(grep -v '^#' "$message_file" | sed '/^[[:space:]]*$/d' | head -n 1)

# Merges and automatic reverts are let through
case "$subject" in
  "Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;;
esac

if [ -z "$subject" ]; then
  echo "✗ The commit message is empty."
  exit 1
fi

if [ ${#subject} -lt 15 ]; then
  echo "✗ The subject is too short (${#subject} characters, minimum 15)."
  echo "  Subject: '$subject'"
  echo "  Describe WHAT changes, not 'fixes' or 'changes'."
  exit 1
fi

if [ ${#subject} -gt 72 ]; then
  echo "✗ The subject is too long (${#subject} characters, maximum 72)."
  echo "  Summarise on the first line and expand in the body, leaving"
  echo "  a blank line between the two."
  exit 1
fi

if printf '%s' "$subject" | grep -qE '\.$'; then
  echo "✗ The subject must not end with a full stop."
  exit 1
fi

exit 0

With it installed:

git commit -m "fixes"
✗ The subject is too short (5 characters, minimum 15).
  Subject: 'fixes'
  Describe WHAT changes, not 'fixes' or 'changes'.

Two observations about the design of this hook:

  • It validates form, not substance. A script can check length, punctuation or that the subject starts with a verb if you follow a template. It cannot check that the message is useful. That is what code review does (lesson 07-02).
  • It lets Merge, Revert, fixup! and squash! through. They are messages generated by Git (lessons 03-03, 05-06 and 05-02). A hook that rejects them turns every merge and every --autosquash into a fight.

What a good commit message should say — the subject, the body, the imperative, formats such as Conventional Commits — is the entire subject of lesson 08-01: Writing Good Commit Messages. Here we are only interested in the mechanism: commit-msg is the point where those conventions, whatever they are, can be enforced automatically. By the time we get to 08-01 you will have the rules; you already know how to write the hook that applies them.

An alternative, and very practical, use is to modify the message rather than reject it. With prepare-commit-msg you can add the issue identifier deduced from the branch name:

#!/usr/bin/env bash
# .git/hooks/prepare-commit-msg
# If the branch is 'feature/GT-123-whatever', add [GT-123] at the end.

message_file="$1"
source="${2:-}"

# Do not touch merge or squash messages, nor a reused commit (-C)
case "$source" in
  merge|squash|commit) exit 0 ;;
esac

branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
issue=$(printf '%s' "$branch" | grep -oE 'GT-[0-9]+' || true)

if [ -n "$issue" ] && ! grep -q "$issue" "$message_file"; then
  printf '\nRefs: %s\n' "$issue" >> "$message_file"
fi

Carla works on feature/GT-214-multiple-delete and her commits come out with Refs: GT-214 without her having to remember. Note git symbolic-ref --short HEAD: it is the same command from lesson 03-01 that returns the name of the current branch, and here it solves the problem in one stroke.

  1. More useful hooks: post-checkout, post-merge, pre-push

post-merge and post-checkout: warning that dependencies need reinstalling.

#!/usr/bin/env bash
# .git/hooks/post-merge

if git diff-tree -r --name-only HEAD@{1} HEAD | grep -q '^package-lock.json$'; then
  echo "⚠  package-lock.json has changed. Run 'npm install'."
fi

HEAD@{1} is the reflog: where HEAD was before the merge. git diff-tree -r --name-only between those two points lists the files that have changed. If the lock file is among them, there are new dependencies. It is a warning, not a block: post-* hooks cannot abort anything.

pre-push: the heavy stuff goes here.

pre-push is where the checks that take time belong, because it runs once every many commits. It receives one line per reference via stdin:

<local-ref> <local-sha> <remote-ref> <remote-sha>
refs/heads/main a1b2c3d... refs/heads/main e5f6a7b...
#!/usr/bin/env bash
#
# .git/hooks/pre-push — task-manager
# Prevents pushing to main commits marked as WIP or fixup!.

set -euo pipefail

remote="$1"
url="$2"
empty_sha="0000000000000000000000000000000000000000"

while read -r local_ref local_sha remote_ref remote_sha; do
  # Branch deletion: nothing to check
  [ "$local_sha" = "$empty_sha" ] && continue

  # We only care about main
  [ "$remote_ref" = "refs/heads/main" ] || continue

  if [ "$remote_sha" = "$empty_sha" ]; then
    range="$local_sha"          # new branch on the remote
  else
    range="$remote_sha..$local_sha"
  fi

  suspects=$(git rev-list --grep='^\(WIP\|fixup!\|squash!\)' "$range")
  if [ -n "$suspects" ]; then
    echo "✗ There are unconsolidated WIP/fixup! commits in what you are about to push to main:"
    git log --oneline --grep='^\(WIP\|fixup!\|squash!\)' "$range" | sed 's/^/    /'
    echo
    echo "  Consolidate them with: git rebase -i --autosquash origin/main"
    exit 1
  fi
done

exit 0

This hook uses everything from module 5: the A..B ranges (lesson 02-06), the fixup! commits (lesson 05-02) and --autosquash as the suggested solution. And it respects the case of a branch that does not yet exist on the remote, in which remote_sha comes through as zeros.

  1. --no-verify: why client-side hooks are not a security control

Everything above is skipped with one option:

git commit --no-verify -m "this does not pass the hook and I do not care"
git push --no-verify

--no-verify (or -n in git commit) disables pre-commit, commit-msg and prepare-commit-msg on the commit, and pre-push on the push. And it is not even necessary: since the hook is a file on the user's disk, anybody can delete it, edit it or take away its execute permission.

Hence the conclusion that has to be burnt in:

Client-side hooks are an aid, not a barrier. They protect against oversight, not against intent. Any rule that must be complied with is enforced on the server, not on anybody's laptop.

Client-side hook Server / CI check
Where it runs The developer's machine Git server or CI platform
Can it be skipped? Yes, with --no-verify or by deleting the file No
Is it distributed on cloning? No (section 9) Yes, it is single and central
Perceived speed Must be immediate Can take minutes
When the warning arrives Before creating the commit: cheap to fix After pushing: more expensive
Correct role Catching the oversight early Guaranteeing the rule is complied with

The two are complementary, and that is the healthy way to frame it: the local hook saves you the round trip to the server; the server is the one that really decides. An occasional, deliberate --no-verify — to save half-finished work on a personal branch, for example — is perfectly legitimate. What is not legitimate is for it to be the habit.

  1. The problem with .git/hooks: it is not version-controlled

Ana has a splendid pre-commit. Bruno runs git clone and… he does not have it. Neither does Carla.

.git/hooks/ is inside .git/, and .git/ is not cloned as content. Cloning transfers objects and references (lesson 04-01), not somebody else's administrative directory. Every repository is born with its factory .sample files and nothing more.

flowchart TD
    subgraph Ana
      A1["Working tree<br/>app.js, index.html…"]
      A2[".git/hooks/pre-commit ✅"]
    end
    subgraph Server["git.example.com"]
      S1["Objects and references"]
    end
    subgraph Bruno
      B1["Working tree<br/>app.js, index.html…"]
      B2[".git/hooks/ only .sample ❌"]
    end
    A1 -->|push| S1
    S1 -->|clone| B1
    A2 -.->|"Does NOT travel"| B2

There are three ways of solving it, from worst to best.

A. By hand. Keep the hooks in a version-controlled directory (tools/hooks/) and have everybody copy them:

cp tools/hooks/* .git/hooks/ && chmod +x .git/hooks/*

It works, it gets documented in the README.md and it gets forgotten on day one. It is good for teams of two people and not much more.

B. core.hooksPath. It is Git's native solution since version 2.9, and the best one if you do not want external dependencies.

C. A hook manager. It is the standard in projects with an ecosystem behind them (npm, Python…). Both are covered in the next section.

  1. core.hooksPath and hook managers

core.hooksPath

This option tells Git to look for the hooks in a different directory, one that can indeed be version-controlled:

# In the repository, with the hooks already in tools/hooks/
git config core.hooksPath tools/hooks

From then on, .git/hooks/ is ignored entirely and Git runs tools/hooks/pre-commit, tools/hooks/commit-msg, and so on.

task-manager's structure ends up like this:

task-manager/
├── app.js
├── styles.css
├── index.html
├── README.md
└── tools/
    └── hooks/
        ├── commit-msg
        ├── pre-commit
        ├── prepare-commit-msg
        └── pre-push

The hooks are edited, reviewed in a pull request and evolve with the project, like any other file. With two caveats:

  • Execute permissions are version-controlled. Git stores the executable bit in the mode of the tree entry (lesson 01-04: 100755 versus 100644). If you add a hook without chmod +x, do it afterwards with git update-index --chmod=+x tools/hooks/pre-commit.
  • core.hooksPath is local configuration, and configuration is not cloned (lesson 01-05). Each person still has to run the git config once. The difference is that now it is a single command instead of maintaining synchronised copies: it goes in the README.md or in a tools/install.sh script.
#!/usr/bin/env bash
# tools/install.sh — run once after cloning
git config core.hooksPath tools/hooks
chmod +x tools/hooks/*
echo "task-manager hooks installed."

Mind the scope. If you set it with --global it will affect all your repositories and will break the ones that depend on their own hooks. core.hooksPath should almost always be local to the repository.

Hook managers

In projects with a package ecosystem behind them, the usual thing is to delegate to a tool:

Tool Ecosystem How it works When it fits
Husky Node.js / npm Sets core.hooksPath to .husky/ in an install script; each hook is a short file in that directory JS/TS projects, like task-manager
pre-commit (Python framework) Multi-language A .pre-commit-config.yaml declares which checks to run; the tool installs the hook and manages the environments Teams with many linters or several languages
Lefthook Multi-language (Go binary) YAML configuration, runs tasks in parallel Large repositories where speed matters
Plain core.hooksPath None Your own version-controlled scripts No dependencies, total control, more manual work

With Husky, task-manager's pre-commit would live in .husky/pre-commit and would only have to invoke the check:

#!/usr/bin/env sh
npx lint-staged

lint-staged applies the linter only to the staged files, which is the same idea as the git diff --cached from section 5, solved by somebody else. That is the underlying argument for using a manager: not reinventing the logic of "which files are in the index", "how to restore if it fails" or "how to cache environments".

And the argument against, equally real: you are adding a dependency and a layer of indirection for something that is, at bottom, a twenty-line script. For task-manager, core.hooksPath is enough and the team understands exactly what is being run.

  1. Server-side hooks, in one page

As became clear in section 8, whatever must always be complied with gets checked on the server side. There is another family of hooks there, living in the hooks/ of the bare repository (lesson 04-01):

Hook When What it receives If it returns != 0
pre-receive Once, before accepting anything from the push Via stdin: <old-sha> <new-sha> <ref> for each ref Rejects the entire push
update Once for each reference Args: <ref> <old-sha> <new-sha> Rejects that reference; the others can go through
post-receive After accepting, with everything already updated The same as pre-receive, via stdin Ignored

Typical uses: pre-receive for rejecting force pushes onto main or commits over X megabytes; update for allowing only certain people to create tags; post-receive for notifying a chat, triggering a deployment or informing a ticketing system.

Two important clarifications:

  • They require access to the server. On your own server you edit them directly; on a hosted platform (GitHub, GitLab, Bitbucket) you have no access to hooks/, and the equivalent is their own mechanisms: branch protection rules, push rules, required status checks and webhooks.
  • That is precisely the territory of continuous integration. How to configure a pipeline that runs the tests on every push and on every pull request, what a required status check is and how all of that connects with the team's workflow is the content of lesson 07-06: Continuous Integration with Git. Here it is enough to know that they exist, where they live and why they are the right place for the non-negotiable rules.

Common Mistakes and Tips

Mistake 1: forgetting chmod +x. Git does not warn you: it simply does not run the hook, and you believe your validation is working. Check with ls -l .git/hooks/ that the x is there. It is by far the most frequent failure.

Mistake 2: leaving the extension on it. pre-commit.sh never runs. The name must be exactly the one Git expects.

Mistake 3: validating the working tree instead of the index. If your pre-commit reads the files from disk, a partial git add -p will give false positives or — worse — false negatives. Use git diff --cached and git show :<path>.

Mistake 4: assuming that hooks are cloned. They are not. Without core.hooksPath or a manager, you have the validation and your colleagues do not.

Mistake 5: slow hooks in pre-commit. The full test suite on every commit guarantees that the team will end up using --no-verify as a matter of course. The quick stuff in pre-commit, the slow stuff in pre-push, the definitive stuff in CI.

Mistake 6: relying on client-side hooks as a security control. They are skipped with one option. Mandatory rules go on the server.

Mistake 7: a hook that fails without explaining why. A silent exit 1 is the worst possible experience. Say which file, which line and how it is fixed.

Mistake 8: blocking the automatic messages. A commit-msg that rejects Merge branch … or fixup! makes it impossible to merge and to use --autosquash. Give it an explicit exception.

Tip 1: test the hook before trusting it. You can run it by hand with the index staged: .git/hooks/pre-commit; echo "exit: $?".

Tip 2: write defensive hooks. set -euo pipefail, handling of names with spaces (-z + mapfile -d '') and || true wherever a command may legitimately return a non-zero code.

Tip 3: record the exceptions. If somebody uses --no-verify, let it be a decision, not a habit. A post-commit can keep a local record of when a check was skipped.

Tip 4: start with just one. A pre-commit that prevents console.log and conflict markers already justifies the tool. Adding six hooks on day one is the fast track to rejection.

Tip 5: git help hooks is the definitive reference. It is installed on your machine, it is exact for your version of Git and it documents every argument of every hook.

Exercises

Exercise 1: a pre-commit that prevents committing TODO:

In a practice repository, write a pre-commit hook that:

  1. Rejects the commit if any staged .js file contains the string TODO:.
  2. Shows the file and the line number of each match.
  3. Analyses the content of the index, not that of the working tree.
  4. Demonstrate that it works: create a file with a TODO:, stage it and try to commit it. Then demonstrate that --no-verify skips it.

Exercise 2: commit-msg with a mandatory prefix

Write a commit-msg hook that requires the subject to start with one of these prefixes: feat:, fix:, docs: or refactor:. It must:

  1. Reject Update the README and accept docs: update the README.
  2. Let through messages starting with Merge , Revert , fixup! or squash!.
  3. Show the list of valid prefixes when it rejects.

Exercise 3: version-controlled hooks with core.hooksPath

Starting from the two previous hooks:

  1. Move them to tools/hooks/ inside the repository and commit them.
  2. Configure core.hooksPath so that Git uses them.
  3. Verify that they still work and that .git/hooks/pre-commit no longer runs (leave it with a different echo so you can check).
  4. Clone the repository into another directory and check what happens: do the hooks run in the clone? What is missing?

Solutions

Solution 1:

mkdir /tmp/practice-hooks && cd /tmp/practice-hooks
git init -b main
cat > .git/hooks/pre-commit <<'END'
#!/usr/bin/env bash
set -euo pipefail

failures=0
mapfile -d '' files < <(git diff --cached --name-only --diff-filter=ACM -z)

for f in "${files[@]:-}"; do
  case "$f" in
    *.js)
      found=$(git show ":$f" | grep -nE 'TODO:' || true)
      if [ -n "$found" ]; then
        echo "✗ $f contains an unresolved TODO:"
        printf '%s\n' "$found" | sed 's/^/    line /'
        failures=1
      fi
      ;;
  esac
done

[ "$failures" -eq 0 ] || { echo "Commit aborted."; exit 1; }
exit 0
END
chmod +x .git/hooks/pre-commit
printf 'function save() {\n  // TODO: validate the input\n}\n' > app.js
git add app.js
git commit -m "Add the save function"
✗ app.js contains an unresolved TODO:
    line 2:  // TODO: validate the input
Commit aborted.
git commit --no-verify -m "Add the save function"
git log --oneline
[main (root-commit) 7c2a9f1] Add the save function
 1 file changed, 3 insertions(+)
7c2a9f1 Add the save function

The hook did not intervene: --no-verify skipped it entirely. That is exactly the demonstration from section 8.

Solution 2:

cat > .git/hooks/commit-msg <<'END'
#!/usr/bin/env bash
set -euo pipefail

subject=$(grep -v '^#' "$1" | sed '/^[[:space:]]*$/d' | head -n 1)

case "$subject" in
  "Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;;
esac

if ! printf '%s' "$subject" | grep -qE '^(feat|fix|docs|refactor): .+'; then
  echo "✗ The subject must start with a valid prefix."
  echo "  Prefixes: feat:  fix:  docs:  refactor:"
  echo "  Received: '$subject'"
  exit 1
fi
exit 0
END
chmod +x .git/hooks/commit-msg
echo "# task-manager" > README.md && git add README.md
git commit -m "Update the README"
✗ The subject must start with a valid prefix.
  Prefixes: feat:  fix:  docs:  refactor:
  Received: 'Update the README'
git commit -m "docs: update the README with the start-up instructions"
[main 3f8b1c4] docs: update the README with the start-up instructions

And the merge exception:

git switch -c test-branch
echo "x" > x.txt && git add . && git commit -m "feat: add the x file"
git switch main
git merge --no-ff test-branch -m "Merge branch 'test-branch'"
Merge made by the 'ort' strategy.

The Merge branch … message does not match the prefix pattern, but the exception lets it through.

Solution 3:

mkdir -p tools/hooks
git mv .git/hooks/pre-commit tools/hooks/pre-commit 2>/dev/null \
  || cp .git/hooks/pre-commit tools/hooks/pre-commit
cp .git/hooks/commit-msg tools/hooks/commit-msg
chmod +x tools/hooks/*

# We mark the old hook so we can tell it apart
printf '#!/usr/bin/env bash\necho "THIS IS THE OLD HOOK FROM .git/hooks"\nexit 1\n' \
  > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

git add tools
git commit --no-verify -m "feat: add the project's version-controlled hooks"
git config core.hooksPath tools/hooks
printf 'const a = 1; // TODO: review\n' > other.js
git add other.js
git commit -m "feat: add the other module"
✗ other.js contains an unresolved TODO:
    line 1:const a = 1; // TODO: review
Commit aborted.

The message is the one from the version-controlled hook, not the "THIS IS THE OLD HOOK" one: core.hooksPath has diverted the search entirely.

# The clone
cd /tmp
git clone /tmp/practice-hooks practice-hooks-clone
cd practice-hooks-clone
ls tools/hooks/
git config --get core.hooksPath
commit-msg  pre-commit
(no output)

The hook files did travel, because they are version-controlled. What did not travel is the core.hooksPath configuration, which is local (lesson 01-05). That is why the installation step is needed:

git config core.hooksPath tools/hooks

And that is the reason tools/install.sh exists: turning "copy these four files and give them permissions" into a single command that is run once after cloning.

Conclusion

Hooks turn Git into something more than a history store: into a place to attach automation. The essentials:

  • A hook is an executable file with an exact name in .git/hooks/. No registry, no plugins: if it is there and it is executable, it runs.
  • The factory .sample files are disabled by the suffix and serve as documentation; removing the .sample enables them.
  • The communication is simple: arguments or stdin in, exit code out. In pre-* hooks, a non-zero code aborts the operation; in post-* ones it only informs.
  • The eight client-side hooks in the table cover almost everything: pre-commit and commit-msg for validating what comes in, prepare-commit-msg for filling in, pre-push for the expensive stuff, and post-checkout/post-merge/post-commit for warning.
  • A pre-commit must analyse the index (git diff --cached, git show :<path>), not the working tree, and it must be fast.
  • --no-verify skips the lot, and the file is on the user's disk: client-side hooks help against oversight, they are not a security control. What is mandatory gets checked on the server.
  • .git/hooks/ is not version-controlled. The native solution is core.hooksPath pointing at a directory in the repository; the ecosystem's solution is a manager such as Husky, pre-commit or Lefthook.
  • Server-side hooks (pre-receive, update, post-receive) are the ones that do enforce; their practical development, along with automatic checking pipelines, is in lesson 07-06. The specific conventions of the messages that commit-msg validates, in 08-01.

With hooks, the task-manager team already prevents certain faults from entering the history. But there remain the ones that got in before they were installed, and that is a different problem: somebody reports that deleting tasks has stopped working, it definitely worked in version 1.0, and between that tag and today there are over two hundred commits. Nobody knows which one broke it.

Reviewing them one by one means two hundred tests. But if the history is an ordered sequence in which something went from working to not working, there is a technique that solves that in eight tests instead of two hundred. We look at it in lesson 06-02: Git Bisect.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved