AI Commit Message Generator Git Hook with claude -p - NextGenBeing
Back to discoveries

AI Commit Message Generator Git Hook with claude -p

Set up a git hook that drafts Conventional Commits messages and PR summaries with claude -p, with measured latency, the 1M-token limit and a secret filter.

AI Workflows 12 min read
Bekzod Erkinov

Bekzod Erkinov

Oct 17, 2026 • 0 views
Size:
Height:
12 min read 4,952 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 8 sections

AI Commit Message Generator Git Hook with claude -p

Writing a commit message takes you a few seconds, and sending a raw diff to a model can leak a password sitting on a context line. A prepare-commit-msg hook that calls claude -p fixes the first problem and, with a filter in front of it, mostly avoids the second. The price is time. In my runs a real git commit with a 244-byte staged diff took 6.1-9.0 s (5 runs). Generated diffs of 41 KB to 1.7 MB took 5.6-22 s for the script alone, and a 3.2 MB diff failed with Prompt is too long. Piped stdin is also capped at 10 MB, and the 60 KB cutoff in the scripts below is my own cost/quality choice, not a measured threshold.

Decision rule. Measured below with a real git commit, the hook added about 6-9 s per commit (5 runs, 6.1-9.0 s) against 0.44-0.58 s with AI_COMMIT_OFF=1 (3 runs). Every commit pays that wait, so use the always-on hook only if you want a draft on most commits. Otherwise set AI_COMMIT_OFF=1 by default and run the same script on demand (command in "When this isn't worth it").

Setup. One machine (Windows, Git Bash), a Claude subscription login, throwaway repos, one to five runs per measurement, all rerun on 2026-10-10. claude --version prints 2.1.296 (Claude Code), git version 2.55.0.windows.3, GNU sed 4.9. For more claude -p chores, see Claude Code headless: automate dev chores with claude -p.

Install and smoke test

Save the scripts below into bin/ and the two hooks into .git/hooks/, then:

chmod +x bin/*.sh .git/hooks/prepare-commit-msg .git/hooks/commit-msg
echo "# smoke test" >> README.md && git add README.md
sh bin/ai-msg.sh                          # script alone: one Conventional Commits subject
GIT_EDITOR=cat git commit                 # real hook path: draft is printed, commit is made
git reset --soft HEAD~1                   # undo the test commit
File Goes in Purpose
ai-diff.sh, ai-msg.sh, pr-summary.sh, check-subjects.sh bin/ filtered diff, commit message, PR summary, CI check
prepare-commit-msg, commit-msg .git/hooks/ draft writer, format gate

Generate commit messages with claude -p in a prepare-commit-msg hook

Per the githooks documentation, prepare-commit-msg runs right after Git prepares the default message and before the editor starts. Its second argument is the message source: message (-m/-F), template, merge, squash, or commit (-c, -C, --amend). A non-zero exit aborts git commit. So this one writes a draft only when the source is empty, and it always exits 0:

#!/bin/sh
# .git/hooks/prepare-commit-msg   ($1 = message file, $2 = source)
[ -n "$2" ] && exit 0            # -m, merge, amend...: leave alone
[ -n "$AI_COMMIT_OFF" ] && exit 0
msg=$("$(git rev-parse --show-toplevel)/bin/ai-msg.sh" 2>/dev/null) || exit 0
[ -z "$msg" ] && exit 0          # never block a commit because the AI failed
printf '%s\n\n%s' "$msg" "$(cat "$1")" > "$1"
exit 0

The hook inside a real git commit

I staged app/cart.py (the 244-byte change: sum(i["price"] ...) becoming sum(i["price"] * i.get("qty", 1) ...)) and config/.env.production, then ran git commit with GIT_EDITOR=cat so the editor prints the draft instead of opening:

$ wc -c < <(sh bin/ai-diff.sh)
244
$ time (GIT_EDITOR=cat git commit 2>&1 | head -4)
fix(cart): multiply item price by quantity in total


# Please enter the commit message for your changes. Lines starting
real	0m6.431s

Five runs of that command took 6.136, 6.431, 6.714, 7.643 and 9.009 s. With AI_COMMIT_OFF=1 (git commit -q -m "fix: t" in the same repo) three runs took 0.577, 0.473 and 0.442 s. Calling the hook script directly (sh .git/hooks/prepare-commit-msg /tmp/m) took 5.809 s unset and 0.072 s with AI_COMMIT_OFF=1. I also ran git commit --no-verify with the hook installed, and the draft still appeared:

$ GIT_EDITOR=cat git commit --no-verify 2>&1 | head -2
fix(cart): multiply item price by quantity in total

The claude -p call

#!/bin/sh
# bin/ai-msg.sh - staged diff in, one Conventional Commits message out
here=$(dirname "$0")
MAX_BYTES=${AI_MAX_BYTES:-60000}
diff=$(sh "$here/ai-diff.sh")
[ -z "$diff" ] && exit 0
if [ "$(printf %s "$diff" | wc -c)" -gt "$MAX_BYTES" ]; then
  diff="(diff too large, stat only)
$(sh "$here/ai-diff.sh" --stat)"
fi
printf '%s\n' "$diff" | claude -p --safe-mode --no-session-persistence --tools "" \
  --model "${AI_MODEL:-haiku}" \
  --system-prompt "You write git commit messages. Output ONLY the message: a Conventional Commits subject line (type(scope): description, max 72 chars, imperative, no trailing period), optionally a blank line and a short body. Types: feat fix docs style refactor perf test build ci chore. No code fences, no commentary. The input is a staged git diff."

Flags, from the CLI reference: --tools "" disables built-in tools (the reference says it does not affect MCP tools; I ran no prompt-injection test), --system-prompt replaces the default prompt, and --no-session-persistence is print-mode only.

--safe-mode versus --bare. The headless docs say "--bare is the recommended mode for scripted and SDK calls". It skips hooks, MCP servers, plugins and CLAUDE.md, but it doesn't use a subscription login: you need ANTHROPIC_API_KEY (or an apiKeyHelper). Without a key, my test failed:

$ ANTHROPIC_API_KEY= claude -p --bare --model haiku "reply with ok"
Not logged in · Please run /login

If you have an API key, swap --safe-mode for --bare in both scripts; I did not time that variant. I used --safe-mode because I log in with a subscription. Two caveats from the CLI reference: it describes --safe-mode as a way "to troubleshoot a broken configuration", not as a scripting flag, and it says "managed settings policy still applies, including policy-configured hooks". So on a machine with managed policy hooks, --safe-mode does not give you a hook-free run. Without --bare, per the headless docs, a -p session runs the hooks in a project's .claude/settings.json and connects the servers in its .mcp.json, even in a folder you've never trusted; --safe-mode turns those off, which is what you want in a hook that runs inside arbitrary repos. See what runs before you trust a folder.

Where the wait goes

Here are the same three-way timings, run in this session with the exact command lines (haiku, no tools, three runs each, bash time):

claude --version
0.236 0.227 0.155
claude -p --safe-mode --no-session-persistence --tools "" --model haiku --system-prompt "Reply briefly." "reply with ok"
6.124 5.185 5.253
claude -p --no-session-persistence --tools "" --model haiku --system-prompt "Reply briefly." "reply with ok"
10.378 11.954 14.619

In these runs --safe-mode was roughly half the wall time of the same call without it. A trivial prompt (5.2-6.1 s) now lands in the same range as the 244-byte commit (6.1-9.0 s). An earlier draft of this post showed a trivial prompt slower than the real commit; those numbers came from a different session, and I replaced them. For one 244-byte call, --output-format json reported "duration_ms":1214 while the wall time was several seconds; I did not profile what the rest is.

Cost

--output-format json includes total_cost_usd. For the 244-byte diff:

{"result":"fix(cart): multiply item price by quantity in total","total_cost_usd":0.0000176,"duration_ms":1214,"is_error":false, ...}

The headless docs call this figure a client-side estimate that "can differ from your actual bill". The CLI reference says claude auth login --console signs in "for API usage billing instead of a Claude subscription", so what you pay depends on how you logged in. I used a subscription login and did not check how these calls show up in my usage limits.

When claude fails: the error text goes to stdout

The headless docs say a failure inside the run, such as missing authentication, is printed as the result on stdout. That is exactly what msg=$(...) captures. I ran ai-msg.sh with a model name that doesn't exist:

$ AI_MODEL=no-such-model sh bin/ai-msg.sh > out.txt 2> err.txt; echo "exit=$?"
exit=1
$ cat out.txt
There's an issue with the selected model (no-such-model). It may not exist or you may not have access to it. Run --model to pick a different model.

The only thing keeping that sentence out of your commit is the non-zero exit status: the claude pipeline is the last command in ai-msg.sh, so the script exits 1 and || exit 0 in the hook throws the captured text away. If you change ai-msg.sh so something runs after the claude pipeline, keep its exit status.

Keep secrets out of the prompt

Two filters run before anything leaves the machine: drop whole files by pathspec (from the diff and the diffstat), then mask secret-looking values in what's left. One script serves both the commit path and the PR path, so the rules can't drift:

#!/bin/sh
# bin/ai-diff.sh [--stat] [range] - prompt-safe diff on stdout
# no range: staged changes. With a range (main...HEAD): that range.
# Excluded paths vanish from the diff AND from --stat, so names don't leak.
# The **/ prefix matters: ':(exclude).env*' only matches a root-level .env file.
fmt=-U2; [ "$1" = "--stat" ] && { fmt=--stat; shift; }
if [ -n "$1" ]; then set -- "$1"; else set -- --cached; fi
git diff $fmt --no-color "$@" -- . \
  ':(exclude,glob)**/*.lock' ':(exclude,glob)**/package-lock.json' ':(exclude,glob)**/*.pem' \
  ':(exclude,glob)**/*.key' ':(exclude,glob)**/.env*' ':(exclude,glob)**/*.min.js' |
sed -E \
  -e 's/(AKIA[0-9A-Z]{16})/[REDACTED_AWS_KEY]/g' \
  -e 's/(sk-[A-Za-z0-9_-]{16,})/[REDACTED_API_KEY]/g' \
  -e 's/(ghp_[A-Za-z0-9]{20,})/[REDACTED_GH_TOKEN]/g' \
  -e 's#(://[^:/@ ]*:)[^@ /]+@#\1[REDACTED]@#g' \
  -e 's/((password|passwd|secret|token|api_?key)[A-Za-z_]*[ ]*[=:][ ]*).*/\1[REDACTED]/I'

The last sed rule uses the I (case-insensitive) flag, a GNU extension. I only ran GNU sed 4.9; on macOS/BSD sed it will probably fail, which I did not test.

Before and after, rerun in this session. I staged an app/config.py with an AWS-style key, a Stripe-style key, an unquoted and a quoted password with spaces, a token_count, and two connection strings, plus config/.env.production:

app/config.py contents:                      what ai-diff.sh sends:
AWS_KEY = "AKIAIOSFODNN7EXAMPLE"             +AWS_KEY = "[REDACTED_AWS_KEY]"
STRIPE_API_KEY = sk_live_abcdefghijklmnop1234   +STRIPE_API_KEY = [REDACTED]
password = my secret phrase                  +password = [REDACTED]
db_password = "hunter2 hunter2"              +db_password = [REDACTED]
token_count = 5                              +token_count = [REDACTED]
CONN = "postgres://admin:[email protected]/prod"   +CONN = "postgres://admin:[REDACTED]@db.internal/prod"
REDIS = f"redis://:r3disp4ss@cache:6379/0"            +REDIS = f"redis://:[REDACTED]@cache:6379/0"

$ sh bin/ai-diff.sh --stat
 app/config.py | 7 +++++++
 1 file changed, 7 insertions(+)
$ sh bin/ai-msg.sh
feat(config): add app configuration module with service settings

.env.production is absent from the diffstat, and the message contains none of the values. The model saw the masked diff, so this shows the filter and the model working together on one example; it is not a guarantee for other inputs. Context lines are filtered too: -U2 includes two unchanged lines around each change and the sed runs over every line. I committed a file with password = "my secret phrase" and a connection string, then changed only the line between them:

 A = 1
 password = [REDACTED]
-B = 2
+B = 99
 CONN = "postgres://admin:[REDACTED]@db.internal/prod"

What this still doesn't do:

  • It only catches shapes it knows. Regex redaction masks known key formats, variable names and URL credentials, nothing else. A secret in a variable called x passes through.
  • False positives. token_count = 5 was masked, and the keyword rule masks to the end of the line, so it also eats a trailing , or ). I didn't measure how often this happens in a real codebase.
  • Excluded files are still committed. They are only excluded from what is sent. File names of non-excluded files, and your own commit subjects in the PR script, are not filtered.

If sending diffs to a third party is itself the problem, a filter won't fix it; a dedicated secret scanner such as gitleaks is the stronger option, and I did not run one here.

Where the diff size limit actually is

Each row is the full ai-msg.sh wall time with haiku, rerun in this session. Rows are staged, machine-generated files of near-identical functions, so they say nothing about message quality on a real change of that size. To reproduce them, save this as gen.py:

import sys
for i in range(int(sys.argv[1])):
    print(f'def handler_{i}(event, ctx=None):\n    """Handle event {i}."""\n    return {{"id": {i}, "ok": True}}\n')

then, for each N:

python -I gen.py N > app/handlers.py && git add app/handlers.py
sh bin/ai-diff.sh | wc -c                       # "Diff bytes after filtering"
time sh bin/ai-msg.sh                           # default 60,000-byte cap
time AI_MAX_BYTES=100000000 sh bin/ai-msg.sh    # "cap off": the whole diff is sent
git reset -q                                    # unstage before the next N
N functions Diff bytes after filtering Cap Wall time per run Subject (last run)
400 40,616 default 6.4, 6.0, 5.6 s feat(handlers): add handler_0 through handler_399
800 81,416 default (stat only) 6.4, 8.5, 6.4 s feat(handlers): add app/handlers.py
800 81,416 off 6.8, 6.2, 6.0 s feat(handlers): add handler_0 through handler_799
10,000 1,046,817 off 8.7, 11.2 s feat(handlers): add handler_0 through handler_9999
16,000 1,694,817 off 22.2, 12.3 s feat(handlers): add handler_0 through handler_15999
30,000 3,206,818 off 8.4 s (1 run) exit 1, stdout Prompt is too long

The break is the context window, not the byte cap. I reran the 10,000, 16,000 and 30,000 rows with --output-format json (same flags, --system-prompt "Output ONLY a Conventional Commits subject."). The CLI reports the window itself in modelUsage:

10000: "cacheReadInputTokens":497735, "costUSD":0.02495025, "contextWindow":1000000   (claude-haiku-5-5)
16000: "cacheReadInputTokens":797733, "costUSD":0.039945150000000006, "contextWindow":1000000
30000: {"result":"Prompt is too long","is_error":true,"total_cost_usd":0,"modelUsage":{}}

So these repetitive Python diffs came to roughly 0.47 tokens per byte, and the 3.2 MB diff does not fit in 1,000,000 tokens. In ai-msg.sh that is exit 1 with the error text on stdout, so the hook discards it (same mechanism as the bad-model case above) and you get an empty editor. Token density depends on the content, so your break point will differ. Two things to read off the table: the big calls cost cents here (see the costUSD lines), and the stat-only fallback was not clearly faster than sending the 81 KB diff in full.

The cutoff costs detail, not errors. With the default cap, the 81 KB diff went out as --stat only, and the subject named the file instead of the 800 functions the full diff supported. A diffstat describes 800 near-identical functions almost as well as the diff, so a real refactor across many files might lose more; I did not test that. Whatever you set AI_MAX_BYTES to is a cost/quality choice.

Above 10 MB. The headless docs cap piped stdin at 10 MB. I piped a 12.4 MB file straight into claude -p:

$ python -I -c "open('/tmp/big.txt','w').write('a'*12_400_000)"
$ claude -p --safe-mode --tools "" --model haiku --system-prompt "x" < /tmp/big.txt > out.txt 2> err.txt; echo "exit=$?"
exit=1
$ wc -c < out.txt
0
$ cat err.txt
Error: piped stdin input exceeds 10MB. Pass large content as a file path in your prompt instead.

Exit 1, empty stdout, message on stderr. In ai-msg.sh that is exit 1 again, so the hook falls back to an empty editor.

Enforce Conventional Commits with a commit-msg hook

The Conventional Commits spec defines type(scope)!: description, plus an optional body and footers. This gate checks only the subject line. The case block is the part nobody warns you about: without it, the regex also rejects subjects Git generates itself.

#!/bin/sh
# .git/hooks/commit-msg
subject=$(grep -v '^#' "$1" | sed -n '1p')
# subjects Git writes itself: merges, fixup!/squash!/amend! (autosquash)
case "$subject" in
  "Merge "*|"fixup! "*|"squash! "*|"amend! "*) exit 0 ;;
esac
# the text after "type: " must be 1-72 chars (the whole line can be longer)
re='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._-]+\))?!?: .{1,72}$'
if ! printf '%s' "$subject" | grep -Eq "$re"; then
  echo "commit-msg: subject must look like type(scope): description" >&2
  echo "got: $subject" >&2
  exit 1
fi

The githooks page says commit-msg is invoked by git commit and git merge, can be bypassed with --no-verify, and aborts the command on a non-zero exit. I ran git merge --no-ff and git commit --fixup HEAD in a scratch repo with the hook minus its case block, then with it:

== merge, nocase
commit-msg: subject must look like type(scope): description
got: Merge branch 'feat1'
Not committing merge; use 'git commit' to complete the merge.
== fixup, nocase
commit-msg: subject must look like type(scope): description
got: fixup! chore: add m
== merge, full
 1 file changed, 1 insertion(+)
== fixup, full
log subject: fixup! Merge branch 'feat1'

git revert never reached the hook. The githooks page doesn't list git revert among the commands that invoke commit-msg, and in my test it didn't run. I replaced commit-msg with echo "commit-msg ran" >&2, made a commit with git commit -m, then reverted:

-- git commit -m 'feat: add b'
commit-msg ran
-- git revert HEAD
[main 5fdcc52] Revert "feat: add b"
-- control: git commit -m 'Revert "feat: x"'   (hook restored, no case block)
commit-msg: subject must look like type(scope): description
got: Revert "feat: x"
exit=1

So in this setup git revert doesn't run commit-msg, while a hand-typed subject starting with Revert " is rejected. I did not test cherry-pick or other sequencer commands. The regex doesn't police capitalization or trailing periods: feat: Add thing. passes. Tighten it or use commitlint (not run here).

Enforce it in CI too

The gate disappears under git commit --no-verify, so check the same subjects in CI. This script walks BASE..HEAD, skips merges, lets Revert " subjects through (they bypass the local hook, as shown above) and fails on anything else:

#!/bin/sh
# bin/check-subjects.sh BASE - CI: fail if a commit subject in BASE..HEAD breaks the format
re='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._-]+\))?!?: .{1,72}$'
bad=0
for sha in $(git rev-list --no-merges "$1..HEAD"); do
  s=$(git log -1 --format=%s "$sha")
  case "$s" in 'Revert "'*) continue ;; esac   # git revert skips commit-msg locally
  printf '%s\n' "$s" | grep -Eq "$re" || { echo "BAD  $sha $s"; bad=1; }
done
exit $bad

Unlike the local hook, it does not exempt fixup!, because those should be squashed before merge. Keep the regex in sync with commit-msg. Run against the scratch repo above, where I had added Fixed stuff with --no-verify on top of the leftover fixup! commit:

$ sh bin/check-subjects.sh $(git rev-list --max-parents=0 HEAD); echo "exit=$?"
BAD  07945c95910ee18d650b10978c338ec76bf5baee Fixed stuff
BAD  4f669464af511fce0a54ddcc5b22fd175a4277b4 fixup! Merge branch 'feat1'
exit=1

In CI, run sh bin/check-subjects.sh origin/main after a checkout that includes the base branch's history; I ran it locally only, not in a CI system.

Generate a PR description with claude -p

pr-summary.sh sends commit subjects, the diffstat and the diff, all through the same ai-diff.sh (same excludes, same redaction). The diff is cut at AI_PR_BYTES, default 40,000:

#!/bin/sh
# bin/pr-summary.sh [base] - PR title + description for HEAD vs base
here=$(dirname "$0")
range="${1:-main}...HEAD"
cap=${AI_PR_BYTES:-40000}
{
  echo "COMMITS:"; git log --no-merges --format='- %s' "$range"
  echo; echo "DIFFSTAT:"; sh "$here/ai-diff.sh" --stat "$range"
  echo; echo "DIFF (filtered, truncated to $cap bytes):"
  sh "$here/ai-diff.sh" "$range" | head -c "$cap"
} | claude -p --safe-mode --no-session-persistence --tools "" --model "${AI_MODEL:-haiku}" \
  --system-prompt "You write pull request descriptions. Output markdown: first line is the PR title (conventional commit style, <=70 chars), then ## Summary (2-4 bullets), ## Changes (grouped by area), ## Risk / what to test (1-3 bullets). Only state what the input shows. If the diff was truncated say so."

My test branch has three commits on top of a base commit: a README edit, a hand-written cart change, and a discounts.py that adds apply_discount and an unused clamp() plus a deploy.pem. What the model got as commits and diffstat (deploy.pem absent):

$ git log --no-merges --format='- %s' main...HEAD
- feat(cart): support item quantities and guard discount inputs
- feat: written by hand
- docs: update readme
$ sh bin/ai-diff.sh --stat main...HEAD
 README.md        | 5 +++++
 app/cart.py      | 2 +-
 app/discounts.py | 7 +++++++
 3 files changed, 13 insertions(+), 1 deletion(-)

Output (haiku, one run), abridged:

feat(cart): support item quantities and add discount helpers

## Summary
- `cart.total` now multiplies each item's price by an optional `qty` field, defaulting to 1.
- Adds `app/discounts.py` with `SAVE10` and `SAVE20` rates, an `apply_discount` function that rejects negative amounts, and a `clamp` helper that limits rates to 0.0–0.5.
...
## Risk / what to test
- Nothing in this diff calls `apply_discount` or `clamp` ... The commit title says "guard discount inputs," but the only guard is the negative-amount check. Unknown codes silently give 0% off ...
- `qty` is not validated. ...

It described the real change, rewrote the title, and noticed that the "guard discount inputs" commit title overstates what the code does. One run, one model; that is an anecdote, not a reliable mismatch detector.

Untested: I did not run gh pr create (my scratch repos have no GitHub remote). The idea is to split the first line off as the title: sh bin/pr-summary.sh > pr.md; tail -n +2 pr.md > body.md; gh pr create --title "$(head -1 pr.md)" --body-file body.md. Check the flags in the gh manual before relying on it.

When this isn't worth it

  • You type messages fast. Set AI_COMMIT_OFF=1 by default and generate on demand: git commit -e -m "$(sh bin/ai-msg.sh)". It calls the same script, so you only lose the draft appearing automatically.
  • Your diffs are mostly generated or vendored. The guard falls back to stat-only and the message gets generic, as shown above.
  • The repo is regulated or holds secrets in code. A regex filter is a seatbelt, not a guarantee.
  • You want intent in the history. The model sees only the diff. I didn't test feeding it the branch name.

What to do next

  1. In a scratch repo with the hooks installed and a change staged, time a real commit both ways: time (GIT_EDITOR=cat git commit >/dev/null 2>&1); git reset -q --soft HEAD~1; time (AI_COMMIT_OFF=1 GIT_EDITOR=cat git commit -q -m "fix: t" >/dev/null 2>&1). The difference is what the hook costs you per commit.
  2. Run sh bin/ai-diff.sh and sh bin/ai-diff.sh --stat on a real staged change and read every line. That's exactly what leaves your machine.
  3. Add sh bin/check-subjects.sh origin/main as a CI step on pull requests, so --no-verify can't skip the format check.
  4. Run sh bin/pr-summary.sh > pr.md on your next branch and edit the result before you open the PR.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.