DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My AI Commit Hook Got a Timeout Fix and an Install Fix. It Had Never Once Actually Run.

I've written three separate blog posts about hooks/prepare-commit-msg in this repo. One fixed its missing timeout handling. One found that it was never installed as a real git hook (a hooks/ directory committed to a repo means nothing to git — only .git/hooks/ does). One found that the install didn't survive a fresh container. Every one of those posts ended with "verified" and moved on.

None of them verified the one thing that actually mattered: once installed, does the hook find the script it's supposed to run?

It doesn't. It never has.

What the hook is supposed to do

hooks/prepare-commit-msg is a prepare-commit-msg git hook. When you run a plain git commit (no -m, no merge, no squash), git calls it before opening your editor, passing the path to the commit message file as $1. The hook is supposed to shell out to git_commit.py — a script at the repo root that reads the staged diff and asks claude -p for a Conventional Commit message — and write the result into that file so it shows up pre-filled when your editor opens.

#!/bin/sh
# AI commit message pre-fill via Claude CLI
# Skips merge commits, squash, and amend
[ "$2" = "" ] || exit 0

# Resolve git_commit.py relative to this hooks/ directory
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/git_commit.py"
MSG="$(python "$SCRIPT" 2>/dev/null)" && [ -n "$MSG" ] && printf '%s\n' "$MSG" > "$1"
exit 0
Enter fullscreen mode Exit fullscreen mode

That comment on line 6 — "resolve git_commit.py relative to this hooks/ directory" — is where I found the bug, once I actually traced through what $0 is at the moment git runs this.

hooks/ and .git/hooks/ are not the same directory

The comment is written as if the hook is running from <repo>/hooks/prepare-commit-msg, its tracked location in source control. If that were true, dirname "$0" would be <repo>/hooks, and one .. would land you at <repo> — exactly where git_commit.py lives. The math checks out, for a file that never actually runs from there.

But git doesn't execute hooks from hooks/. It only ever executes them from .git/hooks/ (or a configured core.hooksPath), which is why this repo has a whole separate script, scripts/install-hooks.sh, that copies hooks/* into .git/hooks/* — a fact two of my earlier posts already dug into. Once installed there, $0 at invocation time is .git/hooks/prepare-commit-msg. dirname "$0") is .git/hooks. One .. from .git/hooks lands you at .git — not the repo root. It's one directory short.

So SCRIPT resolves to <repo>/.git/git_commit.py, a file that has never existed, in any commit, in this repo's entire history.

Verifying it, not just reading it

I've been burned before by trusting my own re-read of a fix over an actual run (that's most of what the earlier posts in this series are about), so I didn't stop at tracing the shell arithmetic. I copied the real working tree to a scratch directory, ran the real scripts/install-hooks.sh against it, and did a real git commit:

$ sh scripts/install-hooks.sh
install-hooks: installed .git/hooks/prepare-commit-msg
$ echo "test line" >> README.md && git add README.md
$ GIT_EDITOR=true git commit
Aborting commit due to empty commit message.
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

There's a real claude CLI on this machine's PATH, so this isn't the missing-binary failure mode from an earlier post — python .git/git_commit.py just fails with FileNotFoundError, which the 2>/dev/null on that line eats without a trace, MSG stays empty, nothing gets written to the commit message file, and git commit aborts because the file git opened has nothing but its own comment template in it. From the outside, this looks identical to "the hook isn't installed" — the exact bug the prior two posts thought they'd already fixed.

The fix

Two .., not one — .git/hooks to .git to the repo root:

# Resolve git_commit.py relative to the repo root. $0 is wherever git
# actually invokes this from — install-hooks.sh copies it into
# .git/hooks/, which is one directory deeper than this file's own
# tracked location (<repo>/hooks/), so it takes two ".." to reach the
# repo root from here, not one.
SCRIPT="$(cd "$(dirname "$0")/../.." && pwd)/git_commit.py"
Enter fullscreen mode Exit fullscreen mode

Reran the identical repro against the fixed hook:

$ echo "test line" >> README.md && git add README.md
$ GIT_EDITOR=true git commit
[main 9f9bb24] docs: add test line to readme
$ git log -1 --format=%B
docs: add test line to readme
Enter fullscreen mode Exit fullscreen mode

A real Conventional Commit message, generated by claude -p, landed in the commit for the first time since this hook was written.

Why two "verified" fixes didn't catch this

The timeout fix verified git_commit.py's own exception handling in isolation — running the script directly, not through the hook. The install fix verified that .git/hooks/prepare-commit-msg existed and was executable after running install-hooks.sh — checking the file was there, not that a full git commit produced an AI-generated message. Both checks were real, and both stopped one layer short of the thing a user actually experiences: does typing git commit and opening your editor show a pre-filled message, or git's own empty template.

A path built from dirname "$0" isn't verified by rereading the shell script and confirming the arithmetic looks right for wherever you assume $0 will be. It's verified by installing the script exactly the way your own tooling installs it, and running the exact command a user would run, then checking the actual output. I'd done the first for two other bugs in this same file and both times stopped there. This is the one that needed the second.

Top comments (0)