DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My Commit Hook Calls an LLM on Every Commit. It Had No Timeout, So Neither Did `git commit`.

I have a prepare-commit-msg hook in this repo that shells out to Claude Code to draft the commit message from the staged diff. It's been running quietly for a month, and I never once thought about what happens when the call doesn't come back.

The setup is small on purpose. A shell hook resolves and calls a Python script:

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

SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/git_commit.py"
python "$SCRIPT" > "$1" 2>/dev/null || true
Enter fullscreen mode Exit fullscreen mode

And the script itself:

diff = subprocess.check_output(["git", "diff", "--staged"], text=True)
...
raw = subprocess.check_output(
    ["claude", "-p", SYSTEM + "\n\n" + diff],
    text=True,
).strip()
Enter fullscreen mode Exit fullscreen mode

I went looking at this today for an unrelated reason — I wanted to see whether the hook logged anything when git_commit.py failed, because I'd noticed a couple of commits in this repo's history with empty-ish messages and no clear reason why. It didn't take long to find it: subprocess.check_output has no timeout argument anywhere in either the diff call or the claude -p call. None. If the claude CLI process hangs — auth prompt waiting on stdin it'll never get in a hook context, a stalled network call, a stuck MCP server it's trying to reach on startup — git commit doesn't fail. It just doesn't return. You're sitting at a blinking cursor with no error, no timestamp, nothing telling you this is an LLM call and not a slow git commit doing something else internally.

That's bad enough as an availability problem. What made it worse is the second half of the hook, the part I'd never actually read closely before: python "$SCRIPT" > "$1" 2>/dev/null || true.

$1 here is the path to COMMIT_EDITMSG — the file git has already pre-populated with its default template (the comment lines about "Please enter the commit message," the branch name, the changed files). The > redirect opens that file in truncate mode the moment the shell parses the line, before Python has produced a single byte of output. If git_commit.py throws — say claude isn't on PATH in whatever shell spawned this commit, which is exactly the kind of thing that differs between an interactive terminal and a script — the traceback goes to stderr, which is thrown away by the redirect. Stdout stays empty. The file that gets written to $1 is a zero-byte file, because > already truncated it and nothing filled it back in. And || true means the hook itself reports success no matter what happened inside.

Git's actual behavior here saved me from the worst-case outcome: an empty commit message file makes git commit abort with "Aborting commit due to empty commit message," so this never silently created empty-message commits. But that's an accident of git's own guardrail, not something this hook was designed around. And it still destroys the one thing you'd want to fall back to — git's own pre-filled template with the diffstat and branch name — replacing it with nothing, on every single failure path, with zero indication of why.

I sat down and reproduced the hang deliberately rather than trust my read of the code. I pointed PATH at a wrapper script that just calls sleep 300 instead of the real claude binary, staged a change, and ran git commit. It hung, exactly as predicted, with no output and no way to tell from the terminal that anything besides git itself was running. ps in another terminal showed the real culprit — a python process blocked on subprocess.check_output, itself blocked on the wrapper's sleep.

The fix is two small, boring changes, and I want to write them out because "add a timeout" is the kind of fix that's easy to nod along to and easy to skip in practice.

First, git_commit.py gets an actual timeout and a message that survives to stderr:

try:
    raw = subprocess.check_output(
        ["claude", "-p", SYSTEM + "\n\n" + diff],
        text=True,
        timeout=20,
    ).strip()
except subprocess.TimeoutExpired:
    print("claude -p timed out after 20s", file=sys.stderr)
    raise SystemExit(1)
Enter fullscreen mode Exit fullscreen mode

Second, and honestly the more important fix, the hook stops truncating the commit message file unless it actually has something to put there:

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

Now git_commit.py's output is captured into a shell variable first. $1 is only touched, only overwritten, if the script exited zero and actually produced non-empty text. Every failure path — timeout, claude missing, a bad response — now leaves git's own default template exactly as git left it. You still get to write your own commit message by hand, which is what should have happened on failure all along, instead of staring at a file that got wiped for no visible reason.

The instinct I want to flag here isn't really about hooks or about subprocess. It's that "I'll redirect the output to where it needs to go" and "I'll only write output if there's output worth writing" read as the same idea when you're first wiring something together, and they're not. A blind redirect commits to overwriting the destination before it knows whether the thing producing that output is going to succeed. Any hook, script, or agent step that writes into a file another tool already populated with something useful — a template, a default, a prior good state — should build the new content somewhere else first and only swap it in once it's confirmed good. That one change is what turned a silent, unexplained wipe into a script that fails the way you'd actually want it to: loudly, on stderr, leaving what was already there alone.

Top comments (0)