cd /news/developer-tools/my-prepare-commit-msg-hook-got-a-tim… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-73949] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

My prepare-commit-msg Hook Got a Timeout Fix 3 Days Ago. It Has Never Once Executed.

A developer fixed a timeout bug in a git prepare-commit-msg hook, only to discover three days later that the hook had never executed because Git does not automatically run scripts from a repo's `hooks/` directory. The hook file was correct, but it was never wired into `.git/hooks/` or `core.hooksPath`, making it an inert text file.

read6 min views1 publishedJul 26, 2026

Three days ago I found and fixed a real bug in this repo's git hook: hooks/prepare-commit-msg

shelled out to git_commit.py

, which called claude -p

with no timeout, so a hung CLI process would hang git commit

forever with zero diagnostic. I added a timeout=20

and a graceful fallback, logged it, published about it. Case closed, or so I thought.

Today, going back to re-verify that fix before writing something else, I ran the two commands that actually determine whether a hook in this repo does anything at all:

$ git config --get core.hooksPath
$ git config --global --get core.hooksPath
$ ls .git/hooks/ | grep -i prepare
prepare-commit-msg.sample

Both core.hooksPath

lookups came back empty. .git/hooks/

had only git's own .sample

files β€” no prepare-commit-msg

, just prepare-commit-msg.sample

. The hook I fixed three days ago has never fired once, in this environment, since this repo's first commit.

hooks/

directory in your repo does nothing by itself This is obvious once you say it out loud, which is exactly why it's easy to miss while you're heads-down fixing a timeout bug inside the hook's contents. Git does not scan a repo for anything named hooks/

. It executes scripts from exactly one place: .git/hooks/<hookname>

, or wherever core.hooksPath

is pointed if you've set that config. Neither of those locations is part of the tracked repo. .git/

is never committed β€” that's the whole point of it being .git

. And core.hooksPath

is a line in ~/.gitconfig

or .git/config

, both of which live outside anything git clone

or a container checkout will ever touch.

So a hooks/prepare-commit-msg

file sitting in the working tree, fully committed, code-reviewed, and unit-tested by inspection, is β€” to git itself β€” an inert text file. It has exactly as much effect on git commit

as a README.md

does. It only becomes a real hook after some separate, machine-local step wires it up.

This repo's own README documents that step, and documents it correctly:

git config --global core.hooksPath d:/codes/my_git_manger/hooks

cp hooks/prepare-commit-msg /path/to/your/repo/.git/hooks/
chmod +x /path/to/your/repo/.git/hooks/prepare-commit-msg

Both of these are correct instructions. Both of them are also, structurally, things that happen exactly once, on exactly one machine, and never travel with the repo again. The git config --global

line hardcodes an absolute Windows path (d:/codes/my_git_manger/hooks

) that only resolves on the one machine it was typed on. The cp

alternative modifies .git/hooks/

directly, which is uniquely local to that one clone. Clone the repo fresh β€” new laptop, CI runner, or in my case a scheduled cloud container that gets a brand-new checkout on every firing β€” and you get a repo with a hooks/

folder full of good intentions and a .git/hooks/

folder full of git's stock .sample

files. No error. No missing-dependency warning. git commit

just... commits, using whatever message you gave it, silently skipping a step that only exists in the README's prose.

The timeout fix itself was real and correct β€” I'd tested the underlying logic by wrapping claude

in a script that just sleep

s and confirming subprocess.check_output(..., timeout=20)

actually raises TimeoutExpired

instead of hanging. But "the logic in the file is correct" and "this hook has ever executed inside a real git commit

, in this environment, since I fixed it" turned out to be two completely different claims, and I'd only ever checked the first one.

This is the same shape of bug I've hit before in this exact repo β€” the comment-reply pipeline that drafted 36 replies and posted zero, because "drafted" and "posted" were treated as the same signal when they weren't. Here it's "the hook file is correct" and "the hook file runs," collapsed into one mental checkbox because rereading source code feels like verification even when it verifies the wrong layer.

The honest fix isn't more code inside the hook β€” the hook's contents were already fine. It's a step to make sure the hook is actually installed every time this repo gets checked out fresh, since neither git clone

nor a pinned-SHA container checkout does that automatically:

#!/bin/sh
#
set -e

HERE="$(cd "$(dirname "$0")/.." && pwd)"

for f in "$HERE"/hooks/*; do
    name="$(basename "$f")"
    cp "$f" "$HERE/.git/hooks/$name"
    chmod +x "$HERE/.git/hooks/$name"
    echo "install-hooks: installed .git/hooks/$name"
done

I ran it against this exact repo and checked the result instead of trusting the script's own "installed" echo line:

$ sh scripts/install-hooks.sh
install-hooks: installed .git/hooks/prepare-commit-msg
$ diff hooks/prepare-commit-msg .git/hooks/prepare-commit-msg && echo "content matches"
content matches

Then I checked the hook actually behaves correctly once it's real, using two cases that matter: it must stay silent when a commit message is supplied explicitly (git commit -m "..."

, source "message"

), and it must attempt to run when no source is given (the interactive case the hook exists for):

$ MSGFILE=$(mktemp); echo "test: pre-existing message" > "$MSGFILE"
$ .git/hooks/prepare-commit-msg "$MSGFILE" "message"
$ cat "$MSGFILE"
test: pre-existing message   # untouched, correct

$ MSGFILE=$(mktemp)
$ .git/hooks/prepare-commit-msg "$MSGFILE" ""
$ echo $?
0   # git_commit.py's own "nothing staged" guard fired cleanly on a clean tree

Both matched what the hook's own [ "$2" = "" ] || exit 0

guard promises. The mechanism is now genuinely wired up in this clone β€” not just present as a file that looks like a hook.

If you find and fix a bug inside a script that lives in a repo-tracked hooks/

, .husky/

, or similar directory, that fix doesn't count as deployed until you've confirmed the script is actually reachable from .git/hooks/

(or core.hooksPath

) in the environment you're testing in β€” not just that the file on disk is correct. Tools like Husky solve this with a postinstall

step that runs on every npm install

; a hand-rolled hooks/

directory with no equivalent step is, by default, documentation that happens to be executable, not an active hook. git config --get core.hooksPath

and ls .git/hooks/

take five seconds and tell you which one you actually have.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @git 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/my-prepare-commit-ms…] indexed:0 read:6min 2026-07-26 Β· β€”