Generating Commit Messages from a Diff with AI

A prepare-commit-msg hook and a Python script that draft a Conventional Commits message from the staged diff. You edit it in your editor as usual.

5 min · · development · автоматизация

Generating commit messages from a diff is one of the simplest useful automations for developers: the model reads your staged changes and drafts a Conventional Commits message, and you edit it in your usual editor. Below are a Python script, a git hook, handling of large diffs, and an honest cost estimate.

What you get

  • git commit without -m opens the editor with a draft already on the first line, such as fix(auth): handle expired refresh token.
  • git commit -m "...", merges, and --amend are left alone by the hook.
  • If the API is unreachable or the answer does not look like Conventional Commits, the commit proceeds as usual with the empty template.

The generator suggests; the commit author still decides.

The generator script

Put the file in the repository as scripts/generate_commit_msg.py. You need pip install openai and a fetch_... key in OPENAI_API_KEY.

bash
#!/usr/bin/env python3
import os
import re
import subprocess
import sys

from openai import OpenAI

client = OpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["OPENAI_API_KEY"],
)
MODEL = os.environ.get("COMMIT_MSG_MODEL", "glm-5.3-flash")
MAX_DIFF_TOKENS = 3000

Why it is built this way:

  • The diff comes through stdin, not as an argument: a large diff passed as an argument hits the command-line length limit.
  • --stat in the prompt gives the model the full list of files even when the diff itself is trimmed.
  • The first line is validated with a regex. If the model replies with reasoning instead of a message, the script exits with an error and the hook simply inserts nothing.
  • The model comes from COMMIT_MSG_MODEL, so you can compare models without editing the code.

The prepare-commit-msg hook

According to the git documentation, prepare-commit-msg runs after the default message is prepared and before the editor starts. The second argument is the message source: message (with -m or -F), template, merge, squash, or commit (with -c, -C, --amend). For a plain git commit it is empty, and that is the only case where we generate.

Note that the message file is not empty at this point: it already holds git's commented template. So a "file is empty" check does not work; the draft has to be prepended to the file.

File .githooks/prepare-commit-msg:

bash
#!/bin/sh
COMMIT_MSG_FILE="$1"
COMMIT_SOURCE="$2"

[ -n "$COMMIT_SOURCE" ] && exit 0
[ -n "$SKIP_AI_MSG" ] && exit 0

MESSAGE=$(git diff --cached | python3 scripts/generate_commit_msg.py) || exit 0
[ -z "$MESSAGE" ] && exit 0

{ printf '%s\n' "$MESSAGE"; cat "$COMMIT_MSG_FILE"; } > "$COMMIT_MSG_FILE.tmp"
mv "$COMMIT_MSG_FILE.tmp" "$COMMIT_MSG_FILE"

To enable it for the whole team (.git/hooks is not committed, .githooks is):

bash
chmod +x .githooks/prepare-commit-msg
git config core.hooksPath .githooks

Git runs hooks from the root of the working tree, so the relative path scripts/generate_commit_msg.py works from any subfolder. The hook always exits with 0: a generation failure must not block the commit.

Running it manually without the hook

If you do not want a hook, generate a message and open it in the editor before committing:

bash
git diff --cached | python3 scripts/generate_commit_msg.py > /tmp/ai_msg && git commit -e -F /tmp/ai_msg

-e opens the editor and -F reads the text from a file. Since the source here is message, the hook will not generate a second time.

Large diffs

An updated lock file or generated code can easily produce a diff with tens of thousands of lines. Sending all of it makes no sense: it costs more, and a commit message describes the intent, not every line. smart_truncate keeps file and hunk headers (diff --git, @@) plus as much content as fits into a budget of about 3,000 tokens, and cuts the rest with a marker.

Two more tricks:

  • exclude noisy files right in git: git diff --cached -- . ':(exclude)package-lock.json';
  • for huge commits it is more honest to split them into several logical ones; the model cannot help with that.

What it costs

Assumptions: the prompt with rules and --stat is about 300 tokens, an average trimmed diff is about 1,700, so 2,000 input tokens in total; the answer is about 60 tokens. Per 1,000 commits that is 2,000,000 input and 60,000 output tokens.

ModelFormula per 1,000 commitsTotal
glm-5.3-flash ($0.04 / $0.04)(2,000,000 × 0.04 + 60,000 × 0.04) / 1,000,000≈ $0.08
claude-sonnet-5 ($0.3 / $0.3)(2,000,000 × 0.3 + 60,000 × 0.3) / 1,000,000≈ $0.62

Worst case, the diff hits the 3,000-token cap: about 3,300 input and 150 output tokens, which is (3,300 + 150) × 0.04 / 1,000,000 ≈ $0.00014 per commit on glm-5.3-flash.

A team of 10 people making 5 commits a day each produces 50 commits a day, about 1,100 a month (22 working days): roughly $0.09 a month on glm-5.3-flash or $0.68 on claude-sonnet-5. Which model writes messages you edit less often is for you to check: run the script with COMMIT_MSG_MODEL over your last 20–30 commits and compare with what people wrote. The current model list is at GET https://api.zukko.pro/v1/models or on the "Models" page in the dashboard.

A separate key for the hook is handy: in the dashboard you can restrict it to a list of allowed models and a daily dollar limit; see key limits. Create a key, and check the quick start for general setup.

FAQ

The hook does not fire. What should I check?

  1. git config core.hooksPath points to the hook folder and the file is executable (chmod +x).
  2. You commit without -m: with -m the source is message and the hook exits on purpose.
  3. OPENAI_API_KEY is visible in the environment where git runs. GUI clients and IDEs often do not read your .zshrc or .bashrc.
  4. Run the script by hand: git diff --cached | python3 scripts/generate_commit_msg.py; the error will show up in stderr.

How do I turn generation off temporarily?

SKIP_AI_MSG=1 git commit: the hook checks this variable. --no-verify will not help here: per the git documentation it does not suppress prepare-commit-msg, only pre-commit and commit-msg.

The model mixes up feat and fix. How do I fix that?

Add your team's rules to PROMPT: "feat is new user-facing behavior; fix is a bug fix; refactor changes no behavior; tests only is test; docs only is docs". Two or three examples from your own repository history work better than long explanations.

How do I hook this up with Husky?

In Husky v9 a hook is a plain shell script in .husky, without the husky.sh sourcing line from older versions. Create .husky/prepare-commit-msg with the same content as the example above; Husky sets core.hooksPath itself.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

Generate Commit Messages from git diff: Conventional Commits · Zukko