twinny / blog

← all posts

A commit message from the diff: which diff, where it is cut, and what is scrubbed from the reply

The sparkle in the Source Control title bar runs one git command, maybe two, cuts the result at a hunk boundary, sends it as a single user message, and then undoes the model's habit of wrapping the answer in fences. The constants and the fallbacks in src/extension/review/commit-message.ts.

Twinny - Generate commit message is one of the smaller features in the extension that talk to a model: 163 lines in src/extension/review/commit-message.ts, two pure functions with a test file, and no UI of its own beyond the Source Control input box. The docs page says how to use it and how to change the style. This post is about what runs between the click and the text appearing in the box, because most of the decisions are about what not to send.

Which diff, and which not

The command runs git in the first workspace folder. getCommitDiff asks for git diff --cached first; if that is anything but whitespace, the staged changes are the diff and that is the end of it. Only when the index is empty does it fall back to plain git diff, the unstaged changes to tracked files. It is one or the other, never both, which is why the docs tell you to stage what you mean to commit: a half-staged tree gets a message about the staged half only. Untracked files appear in neither command, so a new file is invisible until it is added.

That is a different diff from the one @git attaches in chat. getGitContext in src/extension/chat/git-context.ts runs git diff HEAD, staged and unstaged together (or diff --cached in a repository with no commits yet, where there is no HEAD to diff against), and sends it with the branch name and git status --short. The commit button sends none of that: no branch, no status, no file list. The template is given only the diff, which is why it cannot pick a ticket number off the branch name and the docs point you at @git for that.

runGit itself is child_process.exec with a 32 MB maxBuffer. A diff larger than that makes exec throw, and the catch in generateCommitMessage reports it as “could not read the git diff. Is this folder a git repository?”, which is true of the usual cause and misleading for this one.

Where it is cut

The diff goes through truncateDiff with COMMIT_DIFF_MAX_CHARS = 24000, which the source comment calls “roughly what a small local model can take alongside the instructions”. Under the cap, nothing happens. Over it, the function takes the first 24,000 characters and looks backwards for the last \ndiff --git or \n@@ in them, whichever is later. If that boundary is past the halfway mark, the diff is cut there, so the last hunk the model sees is a whole hunk. If the only boundary is in the first half, say one enormous hunk, it is a hard cut at 24,000 and the model sees half a change, which is judged better than seeing a quarter of the diff. Either way the text ends with [diff truncated: N more characters not shown], so the model knows the picture is partial rather than describing a quarter of the change as the whole.

@git uses the same function with its own cap, GIT_CONTEXT_MAX_CHARS = 16000. The comment there says only that “a small model gets far less of the diff than the review feature sends”; the reason it is lower than the commit cap is that in a chat prompt the diff sits next to the question and whatever else was attached, whereas the commit prompt is the diff and four lines of instruction. Those are the only two callers. The review feature does not use it; a review is split by file, which is a different problem and a different post.

What is sent

The template is commit-message.hbs, rendered by Handlebars with noEscape, so nothing is HTML-escaped. The built-in text asks for the imperative mood, a subject under 72 characters, a body only when the subject cannot carry it, and “the commit message only: no code fences, no quotes, no explanation”, then {{{code}}} with the diff. There is no {{systemMessage}} in it and the request is built accordingly: generateSimpleCompletion in src/extension/chat/index.ts sends one message, role user, to the configured chat provider and its model, with no system message and no temperature. It is the same one-shot path that names a conversation after the first exchange and that writes a terminal command from a description. The reply is read to the end rather than streamed; the Source Control view shows a progress spinner titled “Twinny: writing commit message” until it arrives.

TemplateProvider.init writes the built-in templates to ~/.twinny/templates with the wx flag, which never overwrites, so an edited commit-message.hbs stays edited across updates. Since 4.2.2, a template that is missing, empty or will not parse falls back to the built-in copy and logs why; before that, deleting system.hbs made every template render empty and the commit button failed with the rest.

What is scrubbed

Two cleaners run on the reply, in order.

stripThinking runs inside generateSimpleCompletion on every one-shot reply. It removes <think> and <thinking> blocks with their contents, and, if the reply begins with a thinking tag that is never closed, all of it. A reasoning model that spends its whole output thinking and never closes the tag therefore produces an empty string, which becomes undefined, which becomes the “could not generate a commit message” error with a pointer at the output log.

cleanCommitMessage then handles the habits the instructions failed to prevent. A fenced block anywhere in the reply is taken as the answer and everything around it is dropped, so “Okay, here’s the commit message based on the diff:” above and “Let me know if you want changes.” below both go. Then, fence or not, a first line ending in a colon is removed, on the reasoning that preambles end in colons; a leading “Commit message:” goes too. Matching double or single quotes around the whole thing are stripped. Finally, runs of three or more newlines collapse to a blank line, so the subject and body are one blank line apart as git expects. The test file, src/test/suite/commit-message.test.ts, is a list of exactly these bad replies and what they should become.

The cleaner is deliberately dumb. It does not enforce the 72-character subject, does not reflow a long body, and does not reject a message that explains itself in prose without a fence or a colon. What it writes into the box is the model’s text with the wrapping removed; the box is editable and nothing is committed for you.

Where it goes

findRepository asks the built-in vscode.git extension for its repositories and picks the one whose root is a prefix of the workspace folder, or the first one if none matches. If there is a repository, the message becomes inputBox.value, the Source Control view is brought up, and, when the diff came from unstaged changes, the status bar says “commit message written from unstaged changes” for five seconds. If the Git view has no repository, say because the Git extension is disabled, the message is copied to the clipboard and a notification says so.

The whole thing is one git command, one request, two regex passes and a property assignment; the interesting choices are the caps and the cleaners. The user-facing side, including how to make it write Conventional Commits, is at docs.twinny.dev.

#commit-messages#git#internals