Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,43 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v

## [Unreleased]

## [1.15.0] - 2026-07-26

### Added
- **`commitbrief leaks` — audit the working tree and git history for committed
credentials (ADR-0036).** The pre-send secret scanner is a gate: it only ever sees
the one diff about to be sent, so it cannot answer "is there a key in my tree right
now?" or "did anyone ever commit one?" — and a key that was committed and later
removed is still in the history, still reachable in every clone and fork. `leaks`
answers both with the same eight built-in patterns plus your
`guard.secret_patterns`, deterministically: no provider call, no cache, no cost.
Both halves run by default (`--no-worktree` / `--no-history` switch either off).
The working-tree half reads every **tracked** file whole — untracked, gitignored
files like `.env` are deliberately out of scope, since that is where a secret is
supposed to live and it cannot leak through git. The history half scans the **added
lines** of the commits ADR-0035's filters select, attributing each hit to its
commit, author and date, because that is what decides whether a key still needs
rotating. Bounded by `--max-commits` (default 200), and truncation is always
reported.
Exits 1 on any hit so it gates CI out of the box; `--fail-on none` reports without
failing. `--json` emits the existing schema v1 with `meta.provider: "builtin"`, so
`commitbrief leaks --json | commitbrief guard --from-json -` enforces a
`.commitbrief/policy.yml` budget with no new plumbing.
Findings carry a file, a line and the pattern names — **never the matched text**.
Two limits are documented rather than hidden: it honors the ignore layers, so a key
inside `vendor/**` is not reported, and it is regex-only, so a high-entropy blob
with no recognizable prefix is invisible.
- **`commitbrief map` — the commit graph, and what your filter actually selected
(ADR-0037).** The commit filters can pick a non-contiguous set from anywhere in the
history, and the only feedback was a count ("12 commits matched") — so a wrong
filter silently reviewed the wrong code. `map` draws the DAG with matching commits
highlighted and the rest dimmed as context, which makes a filter checkable before
you pay for a review. `--branches` switches to a branch topology summary: where each
branch sits relative to the base and how far ahead/behind. Deterministic, always
exits 0 — a viewer, not a gate. Rows clip to the terminal rather than wrapping, and
colour plus box-drawing fall back together, so a pipe or `--color=never` yields
plain ASCII. No new dependencies: lane assignment is a pure function in a new
`internal/graph` package.
- **Commit-level filters: `--author`, `--committer`, `--start-date`,
`--end-date`, `--text` (ADR-0035).** Review a *set of commits* rather than a
single diff. `git diff` has no author/date/message options — those are
Expand Down Expand Up @@ -62,6 +98,15 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v
The version check runs **only** when you invoke the command: there is no
automatic update check and no telemetry.

### Changed
- `commitbrief list`'s built-in command reference was several releases stale — it
never listed `commit`, `guard`, `mcp`, `remote pr`, `doctor`, `providers`, `config`,
`install-hook` or `upgrade`. Rewritten to cover the whole surface, including the
path and commit filters.
- `--max-commits` and `--merges` are usage errors on a review when no commit filter is
set (nothing to modify), but ordinary bounds on `leaks` and `map`, which always walk
history.

### Fixed
- `commitbrief remote pr` now applies `--file` / `--dir` on the **posting**
path too. Only the `--no-post` path honored them, so a narrowed run that
Expand Down Expand Up @@ -2032,7 +2077,9 @@ Anthropic provider.
- Initial-commit `CommitDiff` via `go-git` returns `ErrUnsupported` and
is handled by the CLI fallback (ADR-0002 mitigation).

[Unreleased]: https://github.com/CommitBrief/commitbrief/compare/v1.13.0...HEAD
[Unreleased]: https://github.com/CommitBrief/commitbrief/compare/v1.15.0...HEAD
[1.15.0]: https://github.com/CommitBrief/commitbrief/compare/v1.14.0...v1.15.0
[1.14.0]: https://github.com/CommitBrief/commitbrief/compare/v1.13.0...v1.14.0
[1.13.0]: https://github.com/CommitBrief/commitbrief/compare/v1.12.0...v1.13.0
[1.12.0]: https://github.com/CommitBrief/commitbrief/compare/v1.11.0...v1.12.0
[1.11.0]: https://github.com/CommitBrief/commitbrief/compare/v1.10.0...v1.11.0
Expand Down
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,19 @@ commitbrief --committer carol --merges # committer identity; keep me
commitbrief --author alice --start-date 2026-06-01 --dir internal # all combinable
commitbrief diff main..develop --author alice # bound the walk to a range

# Audit for committed credentials — deterministic, no provider call
commitbrief leaks # tracked files + last 200 commits
commitbrief leaks --no-history # working tree only, fast
commitbrief leaks main..HEAD --no-worktree # exactly that range
commitbrief leaks --author alice --start-date 2026-01-01
commitbrief leaks --json | commitbrief guard --from-json - # gate CI on it

# See the commit graph — and exactly what a filter selects
commitbrief map # the DAG, newest first
commitbrief map --author alice # matches highlighted, rest dimmed
commitbrief map --branches # ahead/behind the base branch
commitbrief map main..develop --max-commits 50

# Plain-language change digest (read-only; no findings)
commitbrief summary # what's staged, grouped by area
commitbrief summary main...develop # a range; uses the commit messages in it
Expand Down Expand Up @@ -918,7 +931,37 @@ matching rules (exact path or gitignore-style glob), and exclusion is applied
last, so it always wins.

`commitbrief dry-run` reports how many commits matched and how many files each
layer removed.
layer removed. `commitbrief map` shows *which* commits a filter selects — matches
highlighted, everything else dimmed as context — which is the fastest way to check a
filter before paying for a review.

## Finding committed credentials

The pre-send secret scanner is a gate: it sees the one diff about to be sent. It
cannot tell you whether a key is sitting in your tree right now, or whether one was
committed and later removed — and a removed key is still in the history, still
reachable in every clone.

`commitbrief leaks` answers both, with the same pattern set and no provider call:

```sh
commitbrief leaks # tracked files + the last 200 commits
commitbrief leaks --patterns # what it looks for (built-ins + yours)
commitbrief leaks --fail-on none # report without failing the build
```

It exits 1 on any hit, so it gates CI out of the box, and `--json` emits schema v1 —
so `commitbrief leaks --json | commitbrief guard --from-json -` enforces a
`.commitbrief/policy.yml` budget with no extra plumbing.

Findings report a **file, a line and the pattern names** — never the matched text. The
scanner reads whole files, so its own report must not become a second copy of the
secret.

Two limits worth knowing: it honors the ignore layers above, so a key inside
`vendor/**` is not reported; and it is regex-only, so a high-entropy blob with no
recognizable prefix is invisible. It is a targeted check, not a general-purpose
secret scanner.

## Building from source

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

func TestRootCommandHasSubcommands(t *testing.T) {
root := newRootCmd()
want := []string{"cache", "commit", "compress", "config", "diff", "doctor", "dry-run", "guard", "init", "install-hook", "list", "mcp", "providers", "remote", "setup", "summary", "upgrade"}
want := []string{"cache", "commit", "compress", "config", "diff", "doctor", "dry-run", "guard", "init", "install-hook", "leaks", "list", "map", "mcp", "providers", "remote", "setup", "summary", "upgrade"}
got := []string{}
for _, c := range root.Commands() {
// cobra adds `help` and `completion` automatically; filter to ours.
Expand Down
19 changes: 18 additions & 1 deletion internal/cli/commitfilter.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ const commitFilterFlags = "--author/--committer/--start-date/--end-date/--text"
// positional args the walk defaults to HEAD — the implicit history walk that
// makes `commitbrief --author alice` work on its own.
func buildCommitFilter(cat *i18n.Catalog, scope reviewScopeFlags, diffArgs []string) (git.CommitFilter, error) {
return commitFilterFor(cat, scope, diffArgs, false)
}

// buildWalkFilter is buildCommitFilter for commands that walk history
// unconditionally — `leaks` and `map`. For them `--merges` and `--max-commits`
// are ordinary knobs rather than modifiers with nothing to modify, so the
// "modifier used alone" rejection does not apply.
//
// The distinction is real: on a review, `--max-commits 50` alone means the
// user expected a commit walk they never actually asked for, and silently
// reviewing the staged index instead would be wrong. On `leaks`, there is
// always a walk to bound.
func buildWalkFilter(cat *i18n.Catalog, diffArgs []string) (git.CommitFilter, error) {
return commitFilterFor(cat, reviewScopeFlags{}, diffArgs, true)
}

func commitFilterFor(cat *i18n.Catalog, scope reviewScopeFlags, diffArgs []string, alwaysWalks bool) (git.CommitFilter, error) {
f := git.CommitFilter{
Authors: trimAll(global.authors),
Committers: trimAll(global.committers),
Expand All @@ -60,7 +77,7 @@ func buildCommitFilter(cat *i18n.Catalog, scope reviewScopeFlags, diffArgs []str
global.startDate, global.endDate))
}

if !f.Active() {
if !f.Active() && !alwaysWalks {
// A modifier on its own can't do anything. Say so instead of running
// a review that silently ignored a flag the user typed.
if global.merges || global.maxCommits > 0 {
Expand Down
Loading
Loading