Writing Git Commit Messages With LLMs in October 2026: Feed the Staged Diff, Supply the Why Yourself, and Review Before You Save
Asking a language model to write your commit message is one of the easiest AI habits to pick up. You stage some changes, paste the diff or run a tool that does it for you, and a tidy message appears. The trouble is that a commit message is supposed to record something the diff cannot: why the change was made. A model that only sees the diff can describe what changed, often at length, and then guess at the reason. Those guesses end up in your history permanently.
This guide covers a practical workflow for generating commit messages with LLMs that stay accurate and useful: what to feed the model, how to make it follow your team's convention, how to wire it into Git without losing control, and what to check before you press enter.
A Git commit message open in Vim. Image: Git (Linus Torvalds), GPL, via Wikimedia Commons.
Why AI commit messages go wrong
Most bad generated messages fail in a few predictable ways:
- Narrating the diff. "Updated user.py, changed the validate function, added a test file." That is a file list, and
git show --statalready gives you one. - Inventing motivation. The model sees a timeout raised from 5 to 30 seconds and writes "to improve reliability under heavy load." Maybe. Or maybe a partner API got slower. Only you know.
- Missing context outside the diff. The bug report, the incident, the discussion that led to the change: none of that is in the staged hunks.
- Describing the wrong changes. If you paste
git diffinstead of the staged diff, the model describes work you have not staged and will not commit. - Ignoring your format. Your repo uses Conventional Commits or a ticket prefix, and the model writes a free-form paragraph.
Each of these can be fixed with better inputs and a short review habit.
Step 1: Feed the staged diff, not the working tree
The commit will contain exactly what is in the index, so that is what the model should see:
git diff --staged
Plain git diff shows unstaged changes only, which is the opposite of what you want. If you use a tool that builds the prompt for you, check which command it runs.
A few more tips for the input:
- Add a little surrounding context.
git diff --staged -U10includes ten lines of context around each hunk instead of the default three, which helps the model understand which function a change lives in. - Exclude noise. Lockfiles, generated code, and snapshots can swamp the useful part of the diff. Git pathspecs let you leave them out of what you send:
git diff --staged -- . ':(exclude)package-lock.json'. Mention in the prompt that the lockfile also changed, so the message does not hide it. - Keep commits small. If the staged diff is thousands of lines, the honest commit message is "this should have been several commits." Split it with
git add -pbefore asking anyone, human or model, to summarize it.
Step 2: Tell the model the why yourself
This is the single biggest improvement. Before generating, write one or two plain sentences about the reason for the change and include them in the prompt. For example:
Reason: The billing provider started returning 429s during
month-end runs. We now retry with backoff instead of failing
the whole batch. Fixes ticket BILL-412.
The model is good at turning rough notes plus a diff into a clean, well-structured message. It is not able to recover intent that exists only in your head or in a ticket it cannot see. If you do not have time to write a reason, tell the model explicitly not to speculate about motivation and to describe only observable changes. A shorter honest message beats a longer invented one.
Step 3: Spell out your convention
Do not just say "write a good commit message." Give the exact format your repository uses. A common baseline that most Git tooling displays well:
- A subject line of roughly 50 characters, and generally no more than about 72, written in the imperative mood ("Add retry to billing client", not "Added" or "Adds").
- A blank line after the subject.
- A body wrapped at around 72 characters that explains what changed and why.
If your team uses the Conventional Commits specification, say so and list the types you allow. The specification's structure is type(optional scope): description, an optional body, and optional footers. A breaking change is marked either with a ! after the type or scope, or with a BREAKING CHANGE: footer. Models know this format, but they will happily invent types like update: or improvement: unless you constrain them.
A reusable prompt looks something like this:
Write a Git commit message for the staged diff below.
Rules:
- Conventional Commits. Allowed types: feat, fix, docs,
refactor, test, chore, perf, ci.
- Subject: imperative mood, max 72 characters, no period.
- Body: wrap at 72 chars. Explain what changed and why,
using ONLY the reason I give. Do not guess motivation.
- Do not list file names unless needed to understand it.
- If the change breaks a public API, add a
"BREAKING CHANGE:" footer describing the break.
- Footer: "Refs: BILL-412"
- Output only the commit message, no commentary.
Reason: <your one or two sentences>
Diff:
<output of git diff --staged>
Pasting two or three real commit messages from your own history as examples often helps more than any written rule, because the model copies their tone and level of detail.
Step 4: Wire it into Git without auto-committing
The safest integration is one that fills in your editor rather than committing for you. Git's prepare-commit-msg hook is designed for this. Git runs it before the editor opens and passes the path of the message file as the first argument, plus a second argument describing the message source (for example message when you used -m, or merge and squash for those operations).
A minimal sketch, saved as .git/hooks/prepare-commit-msg and made executable:
#!/bin/sh
MSG_FILE="$1"
SOURCE="$2"
# Only generate for a fresh, interactive commit.
# Leave -m, merges, squashes, amends, and templates alone.
[ -n "$SOURCE" ] && exit 0
DIFF=$(git diff --staged)
[ -z "$DIFF" ] && exit 0
# your-llm-cli is a placeholder for whatever tool you use.
SUGGESTION=$(printf '%s' "$DIFF" | your-llm-cli --prompt-file ~/.commit-prompt.txt) || exit 0
# Prepend the suggestion; keep Git's own comment lines below it.
{ printf '%s\n' "$SUGGESTION"; cat "$MSG_FILE"; } > "$MSG_FILE.tmp" && mv "$MSG_FILE.tmp" "$MSG_FILE"
A few things this design gets right:
- The editor still opens. You read and edit the suggestion before anything is recorded. Quitting with an empty message aborts the commit as usual.
- It fails open. If the model call errors or times out, the hook exits quietly and you write the message yourself. A commit hook should never block you because an API is down.
- It skips special cases. Merges, squashes, and
-mcommits already have messages, so the hook leaves them alone.
Hooks in .git/hooks are local and not shared through the repository. If your team wants a shared hook, use a hook manager or set core.hooksPath to a versioned directory, and make the AI step opt-in so nobody's diffs get sent to a service they did not agree to.
Step 5: Mind what leaves your machine
A staged diff can contain things you would never paste into a chat window: API keys in a config file, customer data in a fixture, internal hostnames. Before sending diffs to a hosted model:
- Check your company's policy on sending source code to external AI services.
- Run a secret scanner as a
pre-commithook. Git runspre-commitbeforeprepare-commit-msg, so a blocked secret never reaches the model. - Exclude sensitive paths from the diff you send, as shown in Step 1.
- Consider a locally hosted model for private repositories. Commit messages are a small, low-stakes task, and a small local model usually handles it fine.
The ship's log of the Belgica. Image: Till.niermann, CC BY 3.0, via Wikimedia Commons.
Step 6: Review before you save
Reading a generated message takes ten seconds. Run through this checklist:
- Is every claim true? If the message says "adds tests for the edge case," confirm the tests are actually in the staged diff.
- Is the reason yours? Delete any motivation you did not supply. "To improve performance" is a red flag if you never measured anything.
- Does the subject stand alone? Imagine reading it in
git log --onelinea year from now. "Fix bug" fails. "Retry billing requests on HTTP 429" passes. - Is the type right? A refactor that changes behavior is not a refactor. A fix that changes a public API may need a breaking-change marker, which matters if your release tooling reads it to decide version bumps.
- Are trailers correct? Ticket references,
Co-authored-by:lines, and sign-offs should be added by you or your tooling from real data, never invented by the model.
Step 7: Enforce the format in CI
Generated or not, messages drift. If your tooling depends on the format, for example for changelogs or semantic version bumps, add a linter such as commitlint as a commit-msg hook locally and as a check on pull requests. That way a model that slips into an invented type gets caught the same way a person would. If your team squash-merges pull requests, remember that the squash commit message is often built from the pull request title, so lint the title too.
A worked example
Suppose your staged diff changes a billing client so that a 429 response triggers up to three retries with exponential backoff, and adds a test for it. With no reason supplied, a typical model output might be:
Update billing client and add tests
Modified billing_client.py to add retry logic and updated
the request function. Added test_billing_client.py. This
improves the reliability and robustness of the system.
It lists files, uses the past tense, and closes with a vague claim nobody asked for. With the reason, the convention, and the "do not guess" rule from the prompt above, you get something closer to:
fix(billing): retry requests on HTTP 429 with backoff
The billing provider returns 429 during month-end batch
runs, which failed the whole batch. Retry up to three
times with exponential backoff before giving up, and add
a test covering the retry path.
Refs: BILL-412
The second message tells a future reader what happened, why, and where to find the discussion, and every sentence in it can be checked against the diff or the ticket.
The short version
- Send the staged diff (
git diff --staged), trimmed of lockfiles and generated noise. - Write the reason yourself in a sentence or two, and forbid the model from guessing motivation.
- Give the exact convention and a few real examples from your history.
- Use
prepare-commit-msgto pre-fill the editor, fail open, and skip merges and-mcommits. - Scan for secrets before the diff leaves your machine.
- Read every message before saving, and lint the format in CI.
Used this way, an LLM handles the tedious part of turning a diff into tidy prose while you supply the one thing it cannot see: the reason the change exists.

Comments
Post a Comment