The Last Step Should Stay Boring: Designing AI Commit Tools That Fail Closed

An AI can write a convincing commit message in seconds. That is the easy part.

The difficult part begins one millisecond later: what is the tool allowed to do with it?

When I designed Commit Quill, a VS Code extension for generating commit messages, the tempting interaction was obvious. Read the diff, call a model, run git commit, show a pleasant little success toast. One click, maximum magic.

It is also a dangerous place to optimize for magic. A commit changes repository history, and the model is working from a bounded view of the tree. If its response is malformed, if the request was cancelled, if Git state moved, or if the proposed grouping is simply wrong, “almost correct” is not a useful result.

So I made the last step boring. The default output goes into VS Code's Source Control input box, where it remains editable and uncommitted. On the staged-diff path, direct commit is an explicit opt-in.

That small UI decision turned into a larger architecture rule: an AI-assisted mutation workflow should fail closed.

Round 1: Generate a Draft, Not a Commit

The ordinary Commit Quill path starts from the staged diff. It sends the staged file list, the staged patch, and recent commit subjects to the selected provider. The generated subject is then appended to the Source Control input box.

The user can inspect it next to the same diff the model saw, edit it, or ignore it entirely.

That distinction matters because generation and mutation are different capabilities. The model is good at summarizing intent. Git is very good at making history real. There is no architectural reason those two powers have to be fused into one step.

Commit Quill does expose commitQuill.commitDirectly, and on the staged-diff path the setting is an explicit exception rather than the default. Two paths commit directly regardless of it: splitting an unstaged tree always commits, because a stage-commit loop has nowhere to put each intermediate message, and drafting falls back to committing when there is no Git-extension repository handle to write an input box into. Both still show the proposal for approval first — the setting governs where the message lands, not whether you saw it. Opting into direct mutation should feel like changing a safety policy, not like dismissing a tooltip.

This is the first boundary:

diff -> provider -> editable proposal -> human commit

Not this:

diff -> provider -> surprise, history changed

(The second diagram is shorter. That is not always a compliment.)

Round 2: Preserve the Index You Actually Found

The next complication appears when staged and unstaged work coexist.

A tool that sees the whole working tree might decide to “help” by staging additional files before generating a message. That silently changes the unit the developer had already prepared. The commit can still look coherent while containing work that was never meant to be in it.

Commit Quill therefore gives existing staged changes precedence. When staged changes exist, it analyzes only those changes and does not stage anything else.

When nothing is staged, the extension may ask the model to propose semantic groups for the unstaged tree. Even then, the proposal is shown before mutation, and a group may contain only paths that Git actually reported.

That last constraint sounds pedestrian. It is load-bearing. A language model can return a path that looks plausible, especially when nearby filenames share a pattern. The workflow must treat the Git status snapshot as an allowlist, not as a suggestion.

The sequence becomes:

read Git status
  -> build an allowed path set
  -> ask for groups
  -> reject unknown paths
  -> show the proposal
  -> stage and commit one approved group at a time

The model proposes meaning. Deterministic code enforces membership.

Round 3: Cancellation Must Reach the Network

A progress notification with a Cancel button is easy to build. It is also easy to fake accidentally.

If the button only hides the notification while fetch() keeps running until its timeout, the operation is not cancelled. The user has merely stopped watching it.

Commit Quill connects the VS Code cancellation token to an AbortController, and the same signal reaches the HTTP client. Cancelling aborts the in-flight provider request and ends the command without creating a new commit.

This is another fail-closed property: cancellation is a terminal outcome, not an error that the workflow should recover from by continuing with whatever partial state it has.

The same rule applies to malformed provider output, missing configuration, and Git failures. They stop the workflow. There is no “best effort” commit assembled from the pieces that happened to survive.

That behavior is less impressive in a demo because nothing happens. In a mutation tool, nothing happened is often the correct receipt.

Round 4: Keep Credentials Out of the Story

An extension that supports user-supplied provider keys has another boundary to defend. The API key should authenticate the request, but it should never become part of the content being analyzed.

Commit Quill stores keys separately per provider in VS Code Secret Storage. They travel in request headers only. They do not belong in workspace settings, prompts, or error messages.

That separation is more than secret storage hygiene. It keeps three data classes from collapsing into one another:

  • repository context, which the model needs;
  • provider configuration, which the adapter needs;
  • credentials, which only the transport needs.

Once a key enters a prompt-building function, every log, fixture, retry, and provider error becomes a possible exfiltration surface. The simplest safe prompt is the one that never had access to the credential.

Failure Should Have a Shape

“Fail closed” can become vague security wallpaper if it is not translated into observable outcomes. For this workflow, the useful outcomes are concrete:

  • a draft was written to the Source Control input box;
  • approved commits were created, with a count;
  • the run was cancelled, possibly after some already-approved groups committed;
  • there was nothing to commit;
  • the workflow stopped before mutation because an input or provider response was invalid.

Partial mutation deserves special honesty. If a multi-group run creates one commit and the user cancels before the next, pretending the whole run “cancelled” hides a real repository change. The receipt must say how many commits already exist and leave the remaining paths staged or unstaged as they are.

Atomicity would be nice here, but rolling back Git history automatically would add a more dangerous mutation to repair an already-understood one. The boring answer is better: report exactly what happened and stop.

What I Would Reuse in Any AI Mutation Tool

Commit messages are only one example. The same structure applies to migration generators, dependency updaters, release tools, and agents that prepare pull requests.

The reusable rules are small:

  1. Separate generation from mutation.
  2. Make the current deterministic state an allowlist.
  3. Put human approval immediately before the irreversible step.
  4. Propagate cancellation through every layer, including transport.
  5. Keep secrets below the prompt boundary.
  6. Stop on malformed output instead of repairing it into something plausible.
  7. Report partial mutation precisely.

None of these rules makes the model smarter. That is the point.

The architecture assumes the model will eventually be wrong, late, unavailable, or interrupted. Safety comes from making those states ordinary rather than exceptional.

The Last Step

There is a strange instinct in AI tooling to remove every remaining click. Sometimes that is good product work. Sometimes the final click is the boundary that tells the user, “Up to here, the machine was proposing. From here, history changes.”

I like automation. I built an extension specifically to avoid writing repetitive commit messages by hand. But I do not want the most probabilistic component in the workflow to own the most durable action by default.

Let the model write the sentence. Let deterministic code validate the scope. Let the human decide when the repository should remember it.

The last step can stay boring. Git history has enough personality already.