# Three flags into one subcommand: the `commitbrief diff` collapse and what it taught me about CLI surface area.

> v0.9.0 retired --commit, --branch, and --pull-request in favour of a single 'diff' subcommand that pass-throughs to git diff. A note on why reinventing what git already does is the wrong shape.

Published July 10, 2026 by Muhammet Şafak (https://www.muhammetsafak.com.tr)

Canonical URL: https://commitbrief.com/blog/commitbrief-diff-subcommand/
Tags: cli-design, philosophy, git

---

By the time v0.8.x was current, CommitBrief had four ways to point at a git diff: `--staged`, `--unstaged`, `--commit <hash>`, `--branch <target>`, and `--pull-request <target>...<feature>`. That's five flags, three of which were thin wrappers around `git diff` invocations the user could already type by hand. I'd added each one when a user asked for it. They worked. They were also obviously wrong, in the same way a `--lines` flag on `cat` would be obviously wrong — the underlying tool already speaks the language.

v0.9.0 retired the last three in favour of a single subcommand. This post is about why that shape change was the right call, what I had to argue against to make it, and what the experience taught me about how flags accumulate when nobody's watching.

## The before and after

The replacement table is the cleanest summary:

| v0.8.x                                       | v0.9.0+                              |
|----------------------------------------------|--------------------------------------|
| `commitbrief --commit HEAD~1`                | `commitbrief diff HEAD~1`            |
| `commitbrief --branch main feature`          | `commitbrief diff main feature`      |
| `commitbrief --pull-request main...feature`  | `commitbrief diff main...feature`    |
|                                              | `commitbrief diff HEAD~3 HEAD`       |
|                                              | `commitbrief diff <merge-sha>`       |
|                                              | `commitbrief diff main -- '*.go'`    |

The right column is shorter, more uniform, and — this is the part that mattered most to me — strictly more capable than the left. The five things on the left correspond to exactly five `git diff` invocations; the right column accepts every `git diff` invocation, because the subcommand forwards its arguments verbatim to `git diff --no-color --no-ext-diff <args>`.

`--staged` and `--unstaged` stayed as scope flags on the bare command. They're conceptually different — they describe a state of the working tree, not a range — and they don't have a natural `git diff` argument that means exactly the same thing. `git diff --cached` is the equivalent of `--staged`; collapsing that into the subcommand would mean typing `commitbrief diff --cached`, which is uglier than the dedicated flag.

## The argument I had to lose

The argument for keeping the three scope flags was usability: they were friendlier than typing `commitbrief diff`. A user who didn't think in terms of `git diff` syntax could reach for `--commit HEAD~1` and have something obviously named pointing at the thing they wanted.

I held that position for two releases. Two things finally moved me off it:

The first was that the friendlier names were also less expressive. `--commit <hash>` could only point at one commit. The moment a user wanted "the last three commits combined" or "a feature branch compared against main with a pathspec filter on the Go files only," they had to drop out of the dedicated flags and into `git diff` syntax anyway — except now they were learning a new tool because CommitBrief didn't have a way to say what they meant. The friendly flags were a leaky abstraction. They covered the easy cases at the cost of making the medium cases impossible.

The second was that every dedicated flag I'd shipped had created a documentation maintenance burden. Each flag needed its own help text, its own scope-handling code path, its own integration tests, its own merge-commit edge case (which is what the v0.5.0 OQ-03 work was about — `--commit <merge-sha>` had to special-case first-parent semantics with a warning, because the dedicated flag pretended a merge commit was an ordinary commit, when `git diff <merge-sha>` itself doesn't pretend that). The subcommand collapse killed the merge-commit warning code entirely — `commitbrief diff <merge-sha>` follows `git diff`'s semantics, which the user already knows, so there's nothing to warn about.

## The principle behind the collapse

The shape I came to was something like: _don't recreate semantics your underlying tool already exposes; expose your underlying tool instead._

In CommitBrief's case the underlying tool is `git diff`. Anything `git diff` understands as a range — single commits, two commits, three-dot ranges, merge SHAs, pathspecs after `--`, every weird operator git supports — is something CommitBrief should be able to review. The subcommand model is the smallest API surface that achieves that: one verb, N positional arguments, pass-through to `git diff`.

The cost of this shape is one extra word the user has to type (`diff`). The benefit is a feature surface that doesn't need to be documented because the underlying tool already documents it. `commitbrief diff --help` could legitimately just say _"runs `git diff <args>` and reviews the result; see `git diff --help` for the argument grammar."_

In the post-collapse v1.0 doc, that's pretty much what it says.

## What this isn't an argument for

I want to be careful with how I generalise this. The "expose your underlying tool" principle works for CommitBrief and `git diff` because git diff has a stable, well-known argument grammar that engineers using a code-review tool already speak. The principle wouldn't work for, say, an LLM-driven test runner exposing `go test` semantics — `go test` flags are less well-known and the tool would lose more value by making users go through them than it would gain.

The criterion is roughly: _is the underlying tool's argument grammar something the user already knows, or would have to learn anyway?_ If the answer is yes, pass through and stop maintaining a parallel grammar. If the answer is no, the friendlier wrapper is doing real work.

For git diff, the answer is clearly yes — every developer who's typed `git diff main...feature` once has internalised that vocabulary. CommitBrief was making them learn a second name for the same thing.

## The breakage cost was real

The collapse was a breaking change. Scripts that called `commitbrief --commit abc123` had to update to `commitbrief diff abc123`. CI configs that wired `--branch` had to migrate. The [v0.x → v1.0 migration guide](https://github.com/CommitBrief/commitbrief/blob/main/CHANGELOG.md#migration-guide-v0x--v10) collected the rewrites in a table, but a migration guide doesn't make the upgrade free — every existing user had to do work to keep their setup running.

I held off on the collapse for two releases partly for this reason. The trigger that finally made me ship it was the realisation that the longer I waited, the more existing usage there'd be to migrate. v0.9 was the last reasonable window — beyond that, the cost of breakage would have justified keeping the old flags as deprecated aliases through the v1.x line, which would have meant carrying the maintenance burden I wanted to drop in the first place.

The breakage was clean rather than gradual. No deprecation warnings, no aliases — the old flags just stop being recognised. That's the version of breaking change I tend to prefer: a hard line at a specific release, documented in the migration guide, with no ambiguity about whether the old shape "still kind of works." Ambiguous deprecation is what lets a codebase carry years of legacy surface.

## What I'd do again

Two things I'd repeat without hesitation:

**Wait until the friendly wrappers visibly leak before collapsing them.** I tried to collapse earlier — I had a branch in late v0.6 that did the rewrite — and I dropped it because the case didn't feel strong enough yet. By v0.8 it was obvious. Premature collapse would have lost adopters; well-timed collapse cost a migration step and freed me from maintenance.

**Make the migration table the centrepiece of the breaking change.** A table with old-syntax on the left, new-syntax on the right, was the artifact that got people through the upgrade. Prose explanation of "why" matters too, but the table is what people copy-paste into a sed script.

What I'd do differently: I should have shipped the scope-flag collapse and the global `--file`/`--dir` filter pair (which replaced `--file` as a single-path scope flag in the same release) in two separate breaking changes instead of one. Bundling them made the migration guide longer than it needed to be and obscured the principle behind each change.

The next post in this series turns to the inverse problem: where the underlying tool _doesn't_ exist yet, and we had to build one. Specifically, the JSON contract that holds together across four very different LLM providers — Anthropic, OpenAI, Gemini, and Ollama — each with its own structured-output mechanism.