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
- What a hook is and where it lives
- The factory
.samplefiles - Anatomy of a hook: input, output and return code
- Table of the client-side hooks
- A real
pre-commitfortask-manager - A
commit-msgthat validates the shape of the message - More useful hooks:
post-checkout,post-merge,pre-push --no-verify: why client-side hooks are not a security control- The problem with
.git/hooks: it is not version-controlled core.hooksPathand hook managers- Server-side hooks, in one page
- 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.
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, notpre-commit.shnorprecommit. - 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 usebash, but a#!/usr/bin/env nodeworks just as well.
- The factory
.sample files
.sample filesWhen 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-commitThe 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
.samplefiles 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 withinit.templateDir, and that is one way — crude, but real — of making all your new repositories be born with the same hooks.
- 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.exitother 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.
- 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-commitdoes not see the message, because it does not exist yet. If you need the message, your hook iscommit-msg.pre-commitsees 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-msgis rarely used to validate and often used to fill in: inserting the issue number derived from the branch name, for instance.post-checkoutalso fires when cloning, which is useful for warning about dependencies that need installing.pre-rebaseis the standard mechanism for protecting branches: "mainis 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.
- A real
pre-commit for task-manager
pre-commit for task-managerAna writes the hook the team needs. Requirements, taken straight from the two incidents:
- No
console.login the JavaScript code being committed. - 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 0Installation:
What matters in this script, line by line for whatever is not obvious:
-
set -euo pipefail.-eaborts if a command fails,-uif an undefined variable is used,-o pipefailpropagates 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.--cachedcompares the index withHEAD, that is, exactly what is about to be committed. If you used the working tree, agit add -p(lesson 02-04) that had staged only half a line would give false positives from what was left out.--diff-filter=ACMleaves 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. -
|| trueafter theconsole.loggrep.grepreturns 1 when it finds nothing, and withset -ethat would kill the script. The|| trueneutralises 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:
✗ 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-verifyThe 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-commitruns on every commit. If it takes forty seconds because it launches the whole test suite, the team will start using--no-verifyout of habit and you will have lost the game. Practical rule: inpre-commit, checks of under two seconds on the modified files. The heavy stuff goes inpre-push(section 7) or in continuous integration (lesson 07-06).
- A
commit-msg that validates the shape of the message
commit-msg that validates the shape of the messageThe 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 0With it installed:
✗ 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!andsquash!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--autosquashinto 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-msgis 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"
fiCarla 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.
- More useful hooks:
post-checkout, post-merge, pre-push
post-checkout, post-merge, pre-pushpost-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'."
fiHEAD@{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 0This 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.
--no-verify: why client-side hooks are not a security control
--no-verify: why client-side hooks are not a security controlEverything above is skipped with one option:
--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.
- The problem with
.git/hooks: it is not version-controlled
.git/hooks: it is not version-controlledAna 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:
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.
core.hooksPath and hook managers
core.hooksPath and hook managerscore.hooksPath
This option tells Git to look for the hooks in a different directory, one that can indeed be version-controlled:
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-pushThe 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:
100755versus100644). If you add a hook withoutchmod +x, do it afterwards withgit update-index --chmod=+x tools/hooks/pre-commit. core.hooksPathis local configuration, and configuration is not cloned (lesson 01-05). Each person still has to run thegit configonce. The difference is that now it is a single command instead of maintaining synchronised copies: it goes in theREADME.mdor in atools/install.shscript.
#!/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
--globalit will affect all your repositories and will break the ones that depend on their own hooks.core.hooksPathshould 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:
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.
- 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:
- Rejects the commit if any staged
.jsfile contains the stringTODO:. - Shows the file and the line number of each match.
- Analyses the content of the index, not that of the working tree.
- Demonstrate that it works: create a file with a
TODO:, stage it and try to commit it. Then demonstrate that--no-verifyskips 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:
- Reject
Update the READMEand acceptdocs: update the README. - Let through messages starting with
Merge,Revert,fixup!orsquash!. - Show the list of valid prefixes when it rejects.
Exercise 3: version-controlled hooks with core.hooksPath
Starting from the two previous hooks:
- Move them to
tools/hooks/inside the repository and commit them. - Configure
core.hooksPathso that Git uses them. - Verify that they still work and that
.git/hooks/pre-commitno longer runs (leave it with a differentechoso you can check). - Clone the repository into another directory and check what happens: do the hooks run in the clone? What is missing?
Solutions
Solution 1:
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-commitprintf 'function save() {\n // TODO: validate the input\n}\n' > app.js
git add app.js
git commit -m "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✗ The subject must start with a valid prefix. Prefixes: feat: fix: docs: refactor: Received: 'Update the README'
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'"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"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.hooksPathThe 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:
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
.samplefiles are disabled by the suffix and serve as documentation; removing the.sampleenables them. - The communication is simple: arguments or
stdinin, exit code out. Inpre-*hooks, a non-zero code aborts the operation; inpost-*ones it only informs. - The eight client-side hooks in the table cover almost everything:
pre-commitandcommit-msgfor validating what comes in,prepare-commit-msgfor filling in,pre-pushfor the expensive stuff, andpost-checkout/post-merge/post-commitfor warning. - A
pre-commitmust analyse the index (git diff --cached,git show :<path>), not the working tree, and it must be fast. --no-verifyskips 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 iscore.hooksPathpointing 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 thatcommit-msgvalidates, 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
