Guide 05 · Harnesses
Git hooks: stop a commit or a push
Three client-side hooks cover most checks before a commit or a push: pre-commit, commit-msg and pre-push.
What it is for
A git hook runs for every commit or push in the repository, whether a person, a script or an agent starts it. When the hook exits with anything other than 0, git stops the commit or the push.
What you need
- Git. We use 2.52.
sh. On Windows, Git Bash.
No other tools.
Steps
-
Keep the hooks in the repo
By default git looks for hooks in
.git/hooks, and that folder is not committed. We keep ahooks/folder in the repo instead and point git at it in step 5. A relative path is read from the root of the working tree. -
Stop a commit: pre-commit
Git runs it with no arguments, before it asks for the message. The change to check is the staged one in the index. The
hooksfolder is left out, otherwise the hook would find its own marker when you commit it.illustrative · hooks/pre-commit · written for this guide, run on Nov 4, 2026 #!/bin/sh # pre-commit: stop the commit if an added line carries the marker below. # Git runs it with no arguments; the staged change is in the index. # The hooks folder is skipped, so this file does not stop its own commit. marker='DO NOT COMMIT' if git diff --cached --no-color -U0 -- . ':(exclude)hooks' | grep -q "^+.*$marker"; then echo "pre-commit: an added line contains '$marker', commit stopped" >&2 exit 1 fi exit 0 -
Check the message: commit-msg
Git passes the path to the file with the proposed message, and the hook may edit that file.
git mergeruns it too, so messages that git writes itself are let through.illustrative · hooks/commit-msg · our message format, written for this guide #!/bin/sh # commit-msg: the first line must look like "type(ABC-123): description". # Git passes one argument: the file with the proposed message. first=$(head -n 1 "$1") case "$first" in "Merge "*|"Revert "*) exit 0 ;; # messages git writes itself esac if ! printf '%s\n' "$first" | grep -Eq '^(feat|fix|docs|chore|refactor|test)\([A-Z]+-[0-9]+\): .+'; then echo "commit-msg: expected 'type(ABC-123): description', got: $first" >&2 exit 1 fi exit 0 -
Stop a push: pre-push
Git passes the remote name and URL as arguments, and one line per ref to push on stdin. Nothing is sent until the hook exits with 0. Put the reason on stderr.
illustrative · hooks/pre-push · written for this guide, run on Nov 4, 2026 #!/bin/sh # pre-push: refuse a direct push to main. # Arguments: remote name and URL. Stdin: one line per ref to push: # <local-ref> <local-object-name> <remote-ref> <remote-object-name> while read -r local_ref local_oid remote_ref remote_oid; do if [ "$remote_ref" = "refs/heads/main" ]; then echo "pre-push: direct push to main refused ($1), use a branch" >&2 exit 1 fi done exit 0 -
Activate them
Add the
.gitattributesrule beforegit add, so the hooks are stored with LF line endings and run the same on Linux and macOS.git update-index --chmod=+xstores them in the index as100755, so a clone gets them executable. Setcore.hooksPathin the repo, not globally. The relative path also works in a linked worktree made withgit worktree add.adapted from · K-B setup printf 'hooks/* text eol=lf\n' >> .gitattributes chmod +x hooks/pre-commit hooks/commit-msg hooks/pre-push git add .gitattributes hooks git update-index --chmod=+x hooks/pre-commit hooks/commit-msg hooks/pre-push git config core.hooksPath hooks git commit -m "chore(ABC-1): add git hooks" -
Decide who may skip them
git commit --no-verifyskipspre-commitandcommit-msg;git push --no-verifyskipspre-push.prepare-commit-msgruns even then. Whether a check may be skipped is the repo owner's decision, not the agent's. For a check that a second model makes, see Commit review by a second model.
How to check it works
git ls-files -s hooksshows three lines that start with100755.- Stage a line that contains
DO NOT COMMITand commit. Expectpre-commit: an added line contains 'DO NOT COMMIT', commit stopped. - Stage a clean change and run
git commit -m "update stuff". Expectcommit-msg: expected 'type(ABC-123): description', got: update stuff. - Run
git push origin main. Expectpre-push: direct push to main refused (origin), use a branch.
What to watch out for
-
rule
A hook in the repo does nothing until git points at it
Git runs hooks only from the folder
core.hooksPathnames. In our test, a commit with a free-form message went through until the setting was in place. -
observed Nov 4, 2026
A hook that searches staged text finds its own pattern
Our first draft of the pre-commit hook stopped its own commit, because the hook file contains the marker. The example leaves out
hooks/with':(exclude)hooks'. -
observed Oct 15, 2026
A file staged by commit-msg missed the commit
A file our
commit-msghook added to the index did not go into the commit, and a hook that runs after the commit can only write into the next one. Whatever belongs in the same commit has to be written beforegit commitruns. We do that with an agent hook. -
observed Oct 18, 2026
A check written only in the instructions was skipped
A required check, described in the agent's instructions, was left out for a follow-up commit. A check in a commit hook runs for every commit.
-
rule
A global core.hooksPath replaces the hooks of every repo
Git then looks only in that folder, so the hooks of all repositories on the machine stop running.
-
rule
These hooks run only on the machine that commits or pushes
Server-side hooks such as
pre-receiveandupdaterun on the remote.
Sources
- Git documentation: githooks and git-config
- Our tests with Git 2.52, Nov 4, 2026
- K-B sprint notes, Oct 15, 2026, and session notes, Oct 18, 2026