Skip to content

AI-Generated Git Commit Messages

Lesson 1 of 8Beginner → Intermediate10 min readGitHub Copilot & AI Engineering · AI + GitVerified: git 2.43.0 on Ubuntu 24.04, September 2026

Commit messages are the most-written and least-loved text in software engineering. They are also the first thing anybody reads when investigating a regression six months later.

AI is well suited to the mechanical half of the job: reading a diff and describing what changed. It is structurally unable to do the other half, and knowing where the line falls is what separates a useful message from a fluent one that says nothing.

Give it the diff. Not the filenames, not a description — the diff.

Terminal window
git diff --staged

What it doesPrints the exact changes currently staged for commit.

Why we run itThis is what the next commit will contain. Any message describing something else is describing something else.

Expected resultA unified diff of the staged changes, or no output if nothing is staged.

Pipe that into whichever assistant you use, or attach it in your editor, and ask for a message. Then read the result against the diff before committing.

Everything below is why that works and where it stops.

The difference between a useful message and a generic one is almost never prompt wording. It is input.

Without the diff, asking for “a commit message for my changes to the calculator module” produces something like:

Refactor calculator module for improved maintainability

Grammatical, conventional, and derived entirely from the words “calculator” and “changes”. It would fit any commit touching that file, which is another way of saying it describes none of them.

With the diff — for a real staged change adding a function:

diff --git a/calc.py b/calc.py
index 4693ad3..bebeabd 100644
--- a/calc.py
+++ b/calc.py
@@ -1,2 +1,7 @@
def add(a, b):
return a + b
+
+def divide(a, b):
+ if b == 0:
+ raise ValueError("division by zero")
+ return a / b

the model has facts: a new function, its name, its signature, and that it raises on a zero divisor. A message can now name those things, because they are in the input rather than inferred from a path.

The general principle, which applies to every lesson in this cluster:

A model’s output is bounded by what it can see. The most effective thing you can do is show it more of the right thing, not phrase the request more carefully.

Git has three states, and asking about the wrong one produces a message describing work you are not committing.

CommandShows
git diffUnstaged changes — modified but not added
git diff --stagedStaged changes — exactly what the next commit contains
git diff HEADBoth together

--staged is nearly always the one you want. If you have staged part of your work — which is good practice, and covered in the Git index — then git diff shows the other part, and a message generated from it will describe changes that are not in the commit.

This is a mistake that produces confidently wrong history, and nothing downstream catches it.

Terminal window
git diff --staged --stat

What it doesSummarises the staged change as files and line counts rather than full content.

Why we run itUseful when the diff is too large to paste, and as a sanity check that you are committing what you think.

Expected resultOne line per file with insertion and deletion counts, then a summary line.

For the change above this returns:

calc.py | 5 +++++
1 file changed, 5 insertions(+)

That is not enough to write a good message from — it names no functions — but it is an excellent check that your staging is what you intended before you generate anything.

The conventional structure, and where AI can and cannot help with each part:

Subject line — what changed, imperative mood, roughly 50 characters. AI does this well, given the diff.

Body — why it changed, and what a reader needs to know. AI cannot do this unless you supply the reason. It can structure and phrase what you tell it.

Footers — issue references, breaking-change notes, co-authors. AI can format these; the content comes from you.

The division is clean and worth internalising: the diff answers “what”, you answer “why”. A workflow that gets both right is one where you give the model the diff and a sentence of intent:

Here is the staged diff. The reason for this change is that division by zero was returning inf instead of raising, which surfaced as a confusing error three layers up. Write a commit message.

That produces a message with a real body, because the body’s content was supplied rather than invented.

Where a project uses Conventional Commits, the format is mechanical and AI follows it reliably:

<type>(<scope>): <description>
[optional body]
[optional footer(s)]

Common types: feat, fix, docs, style, refactor, test, chore, perf, build, ci.

Two parts need care.

The type is a judgement, not a fact. Whether a change is a fix or a refactor depends on whether the previous behaviour was wrong — which the diff does not say. A model will guess, and it guesses toward refactor for anything structural. If the type matters for your release automation, check it.

Scope is project-specific. A model infers scope from the path, which is often right and sometimes produces a scope your convention does not use. Listing your valid scopes in repository instructions fixes this permanently.

The one part of the format where a wrong answer has downstream consequences, because release tooling reads it to decide version bumps.

feat(api)!: require explicit timeout on client construction
BREAKING CHANGE: Client() no longer defaults timeout to None. Callers must
pass a timeout explicitly.

Whether a change is breaking is a question about your consumers, not about the diff. Removing a parameter is obviously breaking. Changing a default value, tightening validation, or altering an error type may or may not be, depending on who depends on the current behaviour.

Do not delegate that decision. A model marking something breaking that is not causes an unnecessary major release; missing one that is causes a broken downstream build and an apology.

Describing the mechanism instead of the change. “Add if statement to divide function” is true and useless. Good prompting and a short instruction about your conventions both help; reading the output helps most.

Inventing motivation. “…to improve error handling for better user experience” — plausible, unsourced, and now permanently in your history.

Overstating scope. A three-line change described as a refactor of the module.

Padding. Multi-paragraph bodies on trivial commits. If the change is one line and obvious, the message should be one line.

Losing the ticket reference. Issue linkage comes from you. It is worth putting in an instructions file if your project requires it.

Two levels of automation, with different trade-offs.

In the tool you already use. Most Copilot surfaces will generate a message from the staged diff directly — the editor’s source control panel, or Copilot CLI, which can run git diff --staged itself rather than being handed the output. This is the low-friction option and it keeps you in the loop by default, because the message appears in a box you have to accept.

With a prepare-commit-msg hook. Git’s hook runs before the editor opens and can pre-fill the message. It is the most seamless option and the one that most easily removes the review step, because a pre-filled message in an editor reads as done.

If you use one, two rules:

  • Pre-fill, never auto-commit. The hook should populate the editor, not bypass it.
  • Fail open. A hook that errors — no network, no credentials, rate limited — must let the commit proceed with an empty message rather than blocking work.

See Git hooks for the mechanics, and note that hooks are local: a hook that improves your messages does nothing for the rest of the team, which is what repository instructions are for.

The reason to care about any of this is that commit history is a debugging tool, and its value shows up months later.

The questions history gets asked:

“When did this behaviour change?” — answered by git log -S and git bisect, both of which work better when messages describe behaviour rather than mechanism. “Fix rounding in invoice totals” is searchable; “update calc.py” is not.

“Why is this like this?” — answered by a message body, which is the part AI cannot supply and the part most often missing.

“What went into this release?” — answered by release notes generated from history, which inherit whatever quality the messages have.

That third one is worth noticing. Generated release notes are downstream of generated commit messages, so a habit of accepting vague messages compounds: the notes are then a summary of summaries, each step further from the diff.

The practical standard: a message is good enough if somebody investigating an incident could tell, from the subject line alone, whether this commit is worth opening. That is a lower bar than eloquence and a higher bar than “refactor module”.

Generating a commit message means sending your diff to a model provider. Three practical checks.

What is in the diff. A staged .env, a test fixture with a real token, a customer identifier in a test case. Pasting a diff is publishing it to that provider — and unlike a commit, there is no rewriting it afterwards. The secret prevention work from Pillar 5 is what stops this arising.

Whether your organisation has a position. Content exclusion settings, approved tooling, repositories that must not leave your infrastructure. Worth knowing before adopting a habit.

Whether the repository is sensitive. Most are not. Some genuinely are, and the answer there is to generate messages from --stat output, or not at all.

  1. Stage deliberately. git add -p if the working tree contains more than one logical change. The message quality problem is often a staging problem.

  2. Check what you staged. git diff --staged --stat.

  3. Generate from the real diff, with a sentence of intent if the “why” is not obvious.

  4. Read the message against the diff. Specifically: does it claim anything the diff does not show?

  5. Fix the type and any breaking-change marker yourself, if you use Conventional Commits.

  6. Commit.

Step 4 is the one that matters and takes about five seconds. It is also the step that catches the invented rationale, which is the failure mode with the longest half-life.

A model has a context limit, and a 4,000-line diff will either be truncated or degrade the output. Three approaches, in order of how much they improve the message.

Split the commit. Usually the right answer. A diff too large to summarise is generally a commit doing too many things, and git add -p produces both better history and better messages. The tooling problem is a symptom.

Summarise structurally first. Give it git diff --staged --stat plus the diffs of the two or three files that carry the actual change, rather than everything. Generated files, lock files and reformatting add volume and no information.

Exclude the noise explicitly:

Terminal window
{/* Skip a lock file that contributes bulk and nothing else */}
git diff --staged -- . ':(exclude)package-lock.json'

What not to do is paste the first however-many lines and hope. Truncation is silent, and the resulting message describes the part of the change that happened to fit — which for an alphabetically-ordered diff is an arbitrary subset.

Generating from the wrong diff. git diff when you meant git diff --staged, producing a message about changes you are not committing.

Accepting a message that states a reason. The diff cannot contain your reasons.

Letting AI decide the Conventional Commit type. fix versus refactor depends on whether the old behaviour was wrong.

Letting AI decide what is breaking. That is a claim about your consumers, and release tooling acts on it.

Pasting a diff without reading it. If a credential is in it, it is now in a prompt.

Using AI to compensate for bad staging. A commit containing three unrelated changes has no good message. The fix is git add -p, not better prompting.

The diff is the evidence; the message is the summary. AI is good at summarising evidence and cannot supply evidence it was not given — so everything about message quality reduces to what you showed it and whether you checked the result.

  • Give the model the actual diff; input beats prompt wording
  • git diff --staged is what the next commit contains, and is usually the right input
  • The diff answers “what”; only you can answer “why”
  • Supplying one sentence of intent is what makes a real message body possible
  • Conventional Commit type is a judgement about whether old behaviour was wrong
  • Whether a change is breaking is a claim about consumers, and release tooling acts on it
  • A pasted diff is published to a provider, so know what is in it
  • git commit --amend makes a pre-push message cheap to fix
  • A commit with three unrelated changes has no good message; fix the staging

Use a disposable repository.

  1. Make two unrelated changes to a file. Stage only one with git add -p.

  2. Ask for a commit message using git diff output. Predict: does it describe the staged change, the unstaged one, or both?

  3. Repeat with git diff --staged. Compare.

  4. Ask for a message giving only the filename and no diff. Predict: is the result specific enough to be useful in six months?

  5. Make a change that alters a default value. Ask whether it is a breaking change. Predict: can the model know? What would it need?

  6. Add a line to .github/copilot-instructions.md listing your valid Conventional Commit scopes, then regenerate. Predict: does the scope improve?

  7. Delete the repository.

AI-assisted engineering learning pathEleven lessons on getting value from Copilot and agents without giving up review discipline.