Skip to content
Fondazione ITS INCOM

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:

  1. reads what you staged, with git diff --staged;
  2. asks the model for a message;
  3. checks the first line with a regular expression: a known type, an optional scope, at most 72 characters;
  4. 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:

Good to know

Exercise

All examples