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.
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 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.
Related reading
- Jun 30, 2026Hooks that survive Tower, GitHub Desktop, and JetBrains.
Why git hooks installed from the command line silently fail when you commit from a GUI client — and the absolute-path embedding trick that makes commitbrief install-hook just work everywhere.
- May 31, 2026Review before the push: why the strongest review window is right before `git commit -m`.
Why CommitBrief defaults to --staged scope. Reviewing a change that hasn't entered history yet is always cheaper than patching it with a force-push.
- May 26, 2026LLM code review doesn't replace human review. It multiplies it.
The position paper that ships with v1.0 — what an LLM catches, what it doesn't, and why a human reviewer's job changes shape rather than disappearing.