I built a pre-commit hook that pre-fills your commit message using Claude: git commit opens your editor and the message is already there, generated from the staged diff. I'd installed it globally via core.hooksPath and used it for weeks without a single failed commit. I took that as a good sign. It wasn't — it meant I'd never once noticed when it quietly did nothing.
Here's the hook, hooks/prepare-commit-msg, more or less as I wrote it:
#!/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
The first real line is deliberate and correct: git passes $2 as the commit source (merge, squash, commit for --amend, empty for a normal commit), and I only want the AI to prefill genuinely new messages, not overwrite what you're already amending. That part's fine.
The second line is where it goes wrong, and it went wrong for weeks without telling me.
What || true actually buys you
git_commit.py runs git diff --staged, ships it to claude -p as a subprocess, and prints back a Conventional Commit message. It can fail for reasons that have nothing to do with the diff: claude isn't on PATH in this shell, the OAuth session has no active claude -p context, the subprocess times out, stdin/stdout gets into a weird state. When any of that happens, python "$SCRIPT" exits non-zero.
A prepare-commit-msg hook that exits non-zero aborts the commit. That's a real problem — a broken third-party AI call should never be able to block you from committing code, ever, full stop. So I added 2>/dev/null || true: redirect stderr so the failure doesn't dump a traceback into your editor buffer, then force the exit code to 0 no matter what happened upstream. The commit always goes through now.
But "always goes through" and "always works" got merged into the same behavior. When git_commit.py fails, $1 — the commit message file git is about to open in your editor — just never gets written to. You open your editor to an empty message, same as any commit where the AI genuinely had nothing useful to say, or where the hook simply isn't installed. There is no difference on screen between "the tool ran and produced nothing" and "the tool crashed and produced nothing." Both look like a blank buffer. I only found this because I happened to run a commit from an environment where I hadn't set up claude yet — a fresh clone, a CI-style container — and sat there for a second wondering why the AI message feature I'd been using for weeks had apparently stopped existing, before remembering there was nothing in the logs to tell me it had failed. 2>/dev/null had already thrown the reason away.
That's the same shape as an extension that reports success while quietly saving only half your data — the failure is real, it's just been engineered to be invisible at the exact point a human would have noticed it.
The fix isn't "stop swallowing errors"
The tempting fix is to remove || true and let the hook fail loudly. That's wrong too — it brings back the original problem, where a flaky AI subprocess can block you from committing legitimate code. The actual bug isn't "the hook doesn't fail" — commits genuinely should never fail because of this. The bug is that failure and silence were wired to the same branch. You can keep the always-succeed guarantee and still leave a trace:
#!/bin/sh
[ "$2" = "" ] || exit 0
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/git_commit.py"
if ! python "$SCRIPT" > "$1" 2>/tmp/git_commit_err; then
{
echo ""
echo "# git_commit.py failed — message not pre-filled:"
sed 's/^/# /' /tmp/git_commit_err
} >> "$1"
fi
Git ignores lines starting with # in a commit message file — that's how the default git commit template shows you the "Please enter the commit message..." instructions without them ending up in the actual commit. So on failure, $1 isn't silently empty anymore; it opens in your editor with a visible # git_commit.py failed — message not pre-filled: <actual error> comment block. The commit still isn't blocked. You still have to write your own message, same as before this hook existed. But now you know why you're writing your own message instead of assuming the tool just had nothing to say, and if it's claude: command not found, you know exactly what to go fix.
The general version
|| true and 2>/dev/null are a reasonable pair when a step is genuinely optional and its failure mode is genuinely silent-safe — a cache warm, a metrics ping, a notification that's nice to have. They're the wrong pair the moment the step produces output a human downstream is going to read and trust, because now "it failed" and "it succeeded with nothing to report" render identically, and the only way to tell them apart is to already know to be suspicious. If a hook, a CI step, or an agent tool call feeds its output into something a person reads without a separate success signal, "never block on failure" and "never hide failure" have to be two different lines of code, not one line doing both jobs at once.
Top comments (1)
A hook that never fails is often just a status message with extra confidence. The useful test is whether it blocks a known-bad change in a controlled case. If the failure path is never exercised, the team is trusting ceremony instead of signal.