Examples · Web Development
Commit messages from your diff
A Node script reads what you staged with git diff --staged, and Qwen3.8 writes a commit message in the Conventional Commits format. A regular expression checks the first line, and the script asks again once if it fails. You read the message before you commit.
Beginner · 20 min
The idea
A good commit message says what changed in one line you can read in git log, and why in the lines below it. Many teams follow Conventional Commits: a type, an optional scope, a short description.
fix(cart): multiply price by quantity in total calculation
The model reads your diff and writes the message. Your code checks the format, because tools read these lines too: changelogs and version numbers are made from them. The script:
- reads what you staged, with
git diff --staged; - asks the model for a message;
- checks the first line with a regular expression: a known type, an optional scope, at most 72 characters;
- if the check fails, asks again once, saying what was wrong; then it stops and leaves the message to you.
What you need
Node.js 22 or newer, Git, a key, and the openai package, as in Your first API call in JavaScript. Make a folder commit-demo, and run npm install openai in it.
A tiny repository
So that your results look like ours, start from the same repository. Save this as make_repo.sh in commit-demo:
mkdir shop && cd shop
git init -q
git config user.name "Student"
git config user.email "student@example.org"
cat > cart.js <<'JS'
// The total of a shopping cart, in euro
export function total(items) {
let sum = 0;
for (const item of items) {
sum += item.price;
}
return sum;
}
JS
git add cart.js
git commit -q -m "feat(cart): add total of the cart"
# The bug: a cart with 3 notebooks at 2.50 euro costs 2.50, not 7.50
cat > cart.js <<'JS'
// The total of a shopping cart, in euro
export function total(items) {
let sum = 0;
for (const item of items) {
sum += item.price * item.quantity;
}
return sum;
}
JS
git add cart.js
git status --short
Run it with bash make_repo.sh. It makes the folder shop with one commit, and stages a fix: M cart.js. On Windows, run it in Git Bash.
The program
Save it as commit_message.mjs in commit-demo, next to node_modules:
import { execFileSync } from "node:child_process";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.ai.itsincom.org/v1",
apiKey: process.env.API_KEY,
});
const model = process.argv[2] ?? "qwen3.8-27b";
const diff = execFileSync("git", ["diff", "--staged"], { encoding: "utf8" });
if (!diff.trim()) {
console.error("Nothing is staged: run git add first.");
process.exit(1);
}
// type, optional (scope), optional ! for a breaking change, colon, space, description
const FIRST_LINE = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: \S/;
function problem(message) {
const lines = message.split("\n");
if (!FIRST_LINE.test(lines[0])) return `the first line «${lines[0]}» is not «type(scope): description»`;
if (lines[0].length > 72) return `the first line «${lines[0]}» has ${lines[0].length} characters, the limit is 72`;
if (lines.length > 1 && lines[1] !== "") return "the second line must be empty";
return null;
}
const messages = [
{
role: "system",
content: "You write git commit messages in the Conventional Commits format: a first line " +
"«type(scope): description», at most 72 characters, in English, in the imperative " +
"(«add», not «added»). Types: feat, fix, docs, style, refactor, perf, test, build, ci, " +
"chore, revert. If the change needs it, add an empty line and a short body that says why. " +
"Answer with the commit message only.",
},
{ role: "user", content: diff },
];
for (let attempt = 1; attempt <= 2; attempt++) {
const answer = await client.chat.completions.create({ model, messages });
const choice = answer.choices[0];
const message = (choice.message.content ?? "").trim(); // Qwen3.8 may start with empty lines
const error = choice.finish_reason !== "stop" ? `cut off (${choice.finish_reason})` : problem(message);
if (!error) {
console.log(message);
process.exit(0);
}
console.error(`Attempt ${attempt}: ${error}`);
messages.push(
{ role: "assistant", content: message },
{ role: "user", content: `Not valid: ${error}. Write the commit message again.` },
);
}
console.error("No valid message after two attempts: write this one yourself.");
process.exit(1);
The message goes to the standard output, and everything else to the standard error. So you can save the message in a file, read it, and commit with it. Run it from inside shop:
cd shop
node ../commit_message.mjs > ../message.txt && cat ../message.txt
git commit -F ../message.txt
If the script fails, && skips cat, and message.txt is empty. That is why you read it before you run git commit. With an empty file, Git stops anyway: “Aborting commit due to empty commit message”.
What you should see
Four runs on the same fix gave four different messages, all valid at the first attempt. The three we timed took two to three seconds each.
fix(cart): multiply price by quantity when calculating total
The total was summing only the unit price, ignoring the quantity
of each item.
fix(cart): account for quantity when calculating total
fix(cart): account for item quantity in total calculation
The total was only summing the unit price, ignoring the quantity,
so carts with multiple units of an item returned an incorrect total.
fix(cart): multiply price by quantity in total calculation
The total was summing only unit prices, ignoring how many of each item
were in the cart.
We committed the last one. Then we added a new function to cart.js and staged it:
// Shipping: free from 50 euro, 4.90 euro below
export function shipping(items) {
return total(items) >= 50 ? 0 : 4.9;
}
Three runs, three messages, all with the type feat this time:
feat(cart): add shipping calculation based on order total
Free shipping for orders of 50 euros or more; 4.90 euro fee below that.
feat(cart): add shipping cost calculation
feat(cart): add shipping calculation based on cart total
Free shipping for orders of 50 EUR or more; 4.90 EUR otherwise.
Qwen3.8 chose the right type and the right scope in all seven runs. The body changes from run to run: sometimes there is one, sometimes not.
What the check catches
With qwen3.8-27b the check never failed in our runs. To see it work, try other models: the script takes one as an argument, for example node ../commit_message.mjs ministral-3-14b. On the same fix:
-
ministral-3-14b, three runs: every attempt started with a Markdown fence,```or```markdown, so the first line was not a commit line. The second attempt did the same, and the script gave up each time:Attempt 1: the first line «```markdown» is not «type(scope): description» Attempt 2: the first line «```markdown» is not «type(scope): description» No valid message after two attempts: write this one yourself. -
apertus-70b-instruct, three runs. Once the first line had 135 characters, then 76 at the second attempt, and the script gave up. Once the first attempt was valid. Once the first line had 73 characters, and the second attempt passed:Attempt 1: the first line «fix: Compute cart total correctly with multiple items of the same product» has 73 characters, the limit is 72 fix: compute cart total correctly for multiple items of the same product (This message is now 68 characters)Look at the body. The line passes, at exactly 72 characters, and the model wrote a note about its own message, with a wrong count, where the body goes. The check reads only the first line, so this message passed. That is why you read the message before you commit.
Good to know
- Ask again once, not forever. A second attempt with the reason can fix a small error: 73 characters became 72. It did not fix 135, which came back as 76. When the same mistake comes back, as with Ministral's fences, more attempts only cost time and credits.
- The model sees only the diff. It does not know why you made the change: the issue, the bug report, the customer who complained. If the “why” matters, write the body yourself.
- Your diff goes to the school's machines. Do not stage passwords or keys, and do not send code you are not allowed to share.
- Only
featandfixcome from the Conventional Commits rules. The other types are a common habit, from the Angular project. Your team may use a different list: put that list in the regular expression and in the system message.
Exercise
- Make it a Git hook. In
shop/.git/hooks/prepare-commit-msg, run the script and write its output into the file Git passes as the first argument. Make the hook executable withchmod +x. Thengit commitopens your editor with the message already written. - Ministral's fences: strip them in code, with
message.replace(/^```\w*\n|\n```$/g, ""), before the check. Is that better than asking again? What else could your code repair on its own, and what should it never repair? - Add a check for the body: no line longer than 72 characters. Would it have stopped Apertus's note?
- Stage two unrelated changes at once: the fix and a change in the README. What type does the model choose? A commit should do one thing: what does that tell you?
