Generating Commit Messages That Are Actually Useful
Guides

Generating Commit Messages That Are Actually Useful

A diff shows what changed, never why. Where generation genuinely helps, a working prepare-commit-msg hook, and the Conventional Commits rules a model gets wrong.

A good commit message answers a question the diff cannot: why. The diff already shows that a timeout went from 30 to 5. What nobody can recover in two years is that it was 30 because of a slow upstream that has since been replaced.

A model reading only the diff has exactly the same handicap. It can describe the change accurately and it cannot know the reason, so if you ask for a full message it will produce a fluent restatement of the patch — which is the least useful commit message there is.

That does not make generation worthless. It makes it a subject-line and structure problem rather than a rationale problem.

Split the message into the two halves

The subject line is a summary of the diff. It is mechanical, it is the part people most often write badly, and it is fully derivable from the change. Generate it.

The body is the rationale, the alternatives considered, the link to the incident. It is not in the diff. Prompt for it, leave it blank, but never let a model fill it with plausible-sounding motivation.

In practice this means a hook that pre-fills the subject and a scaffolded body, then opens the editor. You are removing the blank-page problem, not the thinking.

Stage first, and only send the staged diff

The staged diff is what is being committed. Sending the working tree instead produces messages describing changes that are not in the commit, which is worse than no message at all.

git diff --cached --no-color --unified=3 -- . ':(exclude)*.lock' ':(exclude)package-lock.json'

Exclude lockfiles, generated code and vendored directories. A 12,000-line lockfile update will dominate the context and produce a message about dependency resolution when the actual change was three lines in a router.

Cap the diff size and degrade gracefully. Above a threshold, send the file list and the stat summary rather than the full patch — a message that says which subsystems were touched is more useful than one generated from a truncated diff with the important hunk cut off.

Get the Conventional Commits details right

If your repository uses Conventional Commits, the model needs the rules stated, because it will otherwise approximate them. The specification is specific about several points.

feat MUST be used when a commit adds a new feature; fix MUST be used when it patches a bug. Other types are permitted, and the Angular-derived config-conventional preset for commitlint recommends build, chore, ci, docs, style, refactor, perf and test.

Breaking changes are indicated either by a ! immediately before the colon, or by a BREAKING CHANGE: footer, or both. If ! is used the footer may be omitted and the description itself describes the break. The token must be uppercase — BREAKING-CHANGE with a hyphen is synonymous, but lowercase is not valid. Everything else in the format is case-insensitive.

The rule worth enforcing in your own code rather than trusting to the prompt: never let the model decide that something is breaking. Detecting an API break requires knowing who the consumers are, which is not in the diff. Have the hook prompt the human, and default to not breaking.

Then validate the output deterministically. Commitlint or an equivalent check will reject a malformed type or a missing scope far more reliably than any instruction in a prompt.

A prepare-commit-msg hook that works

Git calls prepare-commit-msg with up to three arguments: the path to the file holding the message so far, a description of the message source, and the commit SHA when amending. That second argument is what stops the hook from clobbering messages you did not want touched.

#!/usr/bin/env bash
# .git/hooks/prepare-commit-msg
set -euo pipefail

MSG_FILE="$1"
SOURCE="${2:-}"

# Do not interfere with merges, squashes, amends, -m, or -F.
case "$SOURCE" in
  merge|squash|commit|message|template) exit 0 ;;
esac

DIFF=$(git diff --cached --no-color -U3 -- . ':(exclude)*.lock' | head -c 60000)
[ -z "$DIFF" ] && exit 0

SUBJECT=$(curl -sS --max-time 8 "$BASE_URL/chat/completions" \
  -H "Authorization: Bearer $LLM_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg d "$DIFF" '{
        model: "MODEL",
        max_tokens: 40,
        messages: [
          {role: "system", content: "Write one Conventional Commits subject line for this staged diff. Imperative mood, lower case after the colon, no trailing period, at most 72 characters. Output only the line."},
          {role: "user", content: $d}
        ]}')" \
  | jq -r '.choices[0].message.content // empty') || exit 0

[ -z "$SUBJECT" ] && exit 0
printf '%s\n\n# Why: \n%s\n' "$SUBJECT" "$(cat "$MSG_FILE")" > "$MSG_FILE"

Three properties make this safe to live with. It exits zero on every failure path, so a network problem never blocks a commit. It has a short timeout, because a commit that hangs for ten seconds will be uninstalled by Friday. And it leaves a Why: line for the human, which is the part that matters.

Latency is the adoption constraint

Committing is a reflex action. Anything that adds a noticeable pause to it gets removed, regardless of output quality.

Budget under two seconds. That means a small fast model, a low output cap — a subject line is under twenty tokens — and a hard timeout. Do not stream, do not retry, and do not run more than one call.

The same logic argues against generating messages in CI after the fact. Rewriting history to improve messages is disruptive and the message is most valuable at the moment of commit, when the author still remembers the reason.

Know what you are sending

Your staged diff is your source code, and occasionally a credential someone staged by accident. A commit hook sends it to a third party on every commit, silently, from every developer machine.

Run a secret scan before the call rather than after, exclude paths that are known to hold configuration, and make the hook opt-in per developer through an environment variable rather than mandatory via a shared hooks directory. Check your provider retention policy and be able to answer the question when someone asks it, because someone will.

Where it genuinely pays off

Mechanical commits — dependency bumps, renames, generated code updates, formatting passes — where the message is pure description and writing it is a chore. Generation is close to perfect here.

Large refactors where summarising forty files accurately takes a human several minutes. The model produces a first draft grouped by subsystem, and you edit.

Repositories with a strict format that people get wrong. Enforcing type, scope and casing automatically removes a recurring review comment.

Where it does not pay off: small, deliberate commits by someone who knows exactly what they did. Typing fix: reject negative amounts in charge() takes four seconds and carries intent no model could infer.

A short checklist

  1. Generate the subject line only; scaffold the body for a human.
  2. Send the staged diff, with lockfiles and generated paths excluded.
  3. State the commit convention explicitly and validate it with a linter.
  4. Never infer breaking changes; ask.
  5. Exit zero on any failure; keep the timeout under two seconds.
  6. Skip merges, squashes, amends and messages supplied with -m.
  7. Scan for secrets before sending, and make the hook opt-in.

Common questions

Can a model write the body of a commit message?

Not the part that matters. The reason for a change is not present in the diff, so anything it writes there is inferred. Generate the subject and leave a prompt for the rationale.

How do I stop the hook from overwriting a message I typed with -m?

Check the second argument git passes to prepare-commit-msg. Exit early when the source is message, template, merge, squash or commit.

Should the model decide whether a change is breaking?

No. Recognising a break requires knowing the consumers, which is not in the diff. Prompt the author and take the marker from them, then validate the format with commitlint.

Similar articles

Building a Changelog Generator People Actually Read
Guides
Guides·9 min read

Building a Changelog Generator People Actually Read

Restating commit subjects is not a changelog. How to pick the right input, separate classification from writing, handle reverts, and keep regeneration deterministic.

Read
Building a PR Summariser Reviewers Do Not Skip
Guides
Guides·9 min read

Building a PR Summariser Reviewers Do Not Skip

Most PR summary bots restate the diff and get ignored within a fortnight. What reviewers actually need, how to select the diff, and how to keep cost per PR predictable.

Read
Automating Code Review With LLMs Without Drowning in Noise
Guides
Guides·9 min read

Automating Code Review With LLMs Without Drowning in Noise

Automated review fails on precision, not capability. How to budget comments, give the model the context a diff omits, and measure whether anyone is acting on the output.

Read