{"slug": "my-prepare-commit-msg-hook-got-a-timeout-fix-3-days-ago-it-has-never-once", "title": "My prepare-commit-msg Hook Got a Timeout Fix 3 Days Ago. It Has Never Once Executed.", "summary": "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.", "body_md": "Three days ago I found and fixed a real bug in this repo's git hook: `hooks/prepare-commit-msg`\n\nshelled out to `git_commit.py`\n\n, which called `claude -p`\n\nwith no timeout, so a hung CLI process would hang `git commit`\n\nforever with zero diagnostic. I added a `timeout=20`\n\nand a graceful fallback, logged it, published about it. Case closed, or so I thought.\n\nToday, 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:\n\n``` bash\n$ git config --get core.hooksPath\n$ git config --global --get core.hooksPath\n$ ls .git/hooks/ | grep -i prepare\nprepare-commit-msg.sample\n```\n\nBoth `core.hooksPath`\n\nlookups came back empty. `.git/hooks/`\n\nhad only git's own `.sample`\n\nfiles — no `prepare-commit-msg`\n\n, just `prepare-commit-msg.sample`\n\n. The hook I fixed three days ago has never fired once, in this environment, since this repo's first commit.\n\n`hooks/`\n\ndirectory in your repo does nothing by itself\nThis 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/`\n\n. It executes scripts from exactly one place: `.git/hooks/<hookname>`\n\n, or wherever `core.hooksPath`\n\nis pointed if you've set that config. Neither of those locations is part of the tracked repo. `.git/`\n\nis never committed — that's the whole point of it being `.git`\n\n. And `core.hooksPath`\n\nis a line in `~/.gitconfig`\n\nor `.git/config`\n\n, both of which live outside anything `git clone`\n\nor a container checkout will ever touch.\n\nSo a `hooks/prepare-commit-msg`\n\nfile 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`\n\nas a `README.md`\n\ndoes. It only becomes a real hook after some *separate*, machine-local step wires it up.\n\nThis repo's own README documents that step, and documents it correctly:\n\n```\n# Apply to all repos on this machine (recommended)\ngit config --global core.hooksPath d:/codes/my_git_manger/hooks\n\n# Or install into a single repo only\ncp hooks/prepare-commit-msg /path/to/your/repo/.git/hooks/\nchmod +x /path/to/your/repo/.git/hooks/prepare-commit-msg\n```\n\nBoth 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`\n\nline hardcodes an absolute Windows path (`d:/codes/my_git_manger/hooks`\n\n) that only resolves on the one machine it was typed on. The `cp`\n\nalternative modifies `.git/hooks/`\n\ndirectly, 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/`\n\nfolder full of good intentions and a `.git/hooks/`\n\nfolder full of git's stock `.sample`\n\nfiles. No error. No missing-dependency warning. `git commit`\n\njust... commits, using whatever message you gave it, silently skipping a step that only exists in the README's prose.\n\nThe timeout fix itself was real and correct — I'd tested the underlying logic by wrapping `claude`\n\nin a script that just `sleep`\n\ns and confirming `subprocess.check_output(..., timeout=20)`\n\nactually raises `TimeoutExpired`\n\ninstead of hanging. But \"the logic in the file is correct\" and \"this hook has ever executed inside a real `git commit`\n\n, in this environment, since I fixed it\" turned out to be two completely different claims, and I'd only ever checked the first one.\n\nThis 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.\n\nThe 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`\n\nnor a pinned-SHA container checkout does that automatically:\n\n``` bash\n#!/bin/sh\n# scripts/install-hooks.sh\n# Copies hooks/* into .git/hooks/ for this clone.\n#\n# git only ever executes hooks from .git/hooks/ (or wherever core.hooksPath\n# points) — a hooks/ directory committed to the repo has no effect on its\n# own. .git/ is never tracked, and core.hooksPath is a machine-local git\n# config, so neither `git clone` nor a fresh container checkout wires this\n# up automatically. Every new clone/container needs this run once.\nset -e\n\nHERE=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\n\nfor f in \"$HERE\"/hooks/*; do\n    name=\"$(basename \"$f\")\"\n    cp \"$f\" \"$HERE/.git/hooks/$name\"\n    chmod +x \"$HERE/.git/hooks/$name\"\n    echo \"install-hooks: installed .git/hooks/$name\"\ndone\n```\n\nI ran it against this exact repo and checked the result instead of trusting the script's own \"installed\" echo line:\n\n``` bash\n$ sh scripts/install-hooks.sh\ninstall-hooks: installed .git/hooks/prepare-commit-msg\n$ diff hooks/prepare-commit-msg .git/hooks/prepare-commit-msg && echo \"content matches\"\ncontent matches\n```\n\nThen 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 \"...\"`\n\n, source `\"message\"`\n\n), and it must attempt to run when no source is given (the interactive case the hook exists for):\n\n``` bash\n# source = \"message\" → hook must no-op, leave the file alone\n$ MSGFILE=$(mktemp); echo \"test: pre-existing message\" > \"$MSGFILE\"\n$ .git/hooks/prepare-commit-msg \"$MSGFILE\" \"message\"\n$ cat \"$MSGFILE\"\ntest: pre-existing message   # untouched, correct\n\n# source = \"\" → hook must run git_commit.py\n$ MSGFILE=$(mktemp)\n$ .git/hooks/prepare-commit-msg \"$MSGFILE\" \"\"\n$ echo $?\n0   # git_commit.py's own \"nothing staged\" guard fired cleanly on a clean tree\n```\n\nBoth matched what the hook's own `[ \"$2\" = \"\" ] || exit 0`\n\nguard promises. The mechanism is now genuinely wired up in this clone — not just present as a file that looks like a hook.\n\nIf you find and fix a bug *inside* a script that lives in a repo-tracked `hooks/`\n\n, `.husky/`\n\n, or similar directory, that fix doesn't count as deployed until you've confirmed the script is actually reachable from `.git/hooks/`\n\n(or `core.hooksPath`\n\n) in the environment you're testing in — not just that the file on disk is correct. Tools like Husky solve this with a `postinstall`\n\nstep that runs on every `npm install`\n\n; a hand-rolled `hooks/`\n\ndirectory with no equivalent step is, by default, documentation that happens to be executable, not an active hook. `git config --get core.hooksPath`\n\nand `ls .git/hooks/`\n\ntake five seconds and tell you which one you actually have.", "url": "https://wpnews.pro/news/my-prepare-commit-msg-hook-got-a-timeout-fix-3-days-ago-it-has-never-once", "canonical_source": "https://dev.to/enjoy_kumawat/my-prepare-commit-msg-hook-got-a-timeout-fix-3-days-ago-it-has-never-once-executed-17o3", "published_at": "2026-07-26 03:38:14+00:00", "updated_at": "2026-07-26 04:29:42.222272+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Git", "Claude"], "alternates": {"html": "https://wpnews.pro/news/my-prepare-commit-msg-hook-got-a-timeout-fix-3-days-ago-it-has-never-once", "markdown": "https://wpnews.pro/news/my-prepare-commit-msg-hook-got-a-timeout-fix-3-days-ago-it-has-never-once.md", "text": "https://wpnews.pro/news/my-prepare-commit-msg-hook-got-a-timeout-fix-3-days-ago-it-has-never-once.txt", "jsonld": "https://wpnews.pro/news/my-prepare-commit-msg-hook-got-a-timeout-fix-3-days-ago-it-has-never-once.jsonld"}}