diff --git a/CHANGELOG.md b/CHANGELOG.md index 450a751..301069c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,44 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v ## [Unreleased] ### Added +- **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 + `git log` options — so setting any of these switches diff acquisition to a + commit walk: pick the matching commits, then concatenate their patches. + Different filter kinds are AND'd, multiple values of one kind are OR'd, so + `--author alice --author bob --start-date 2026-06-01` means "(Alice or Bob) + and since June". Identity matching is a case-insensitive substring over both + the name and the email. `--end-date` is **inclusive** of the day named + (git's bare `--until` stops at that day's midnight and silently drops it). + `--text` matches the commit message *and* the name of a branch, in which + case the commits unique to that branch are pulled in — best-effort by + nature, since a squash- or rebase-merged branch no longer owns its commits. + With no explicit range the walk covers `HEAD`; a subcommand's range bounds + it (`commitbrief diff main..develop --author alice`). + Two modifiers shape a walk but never start one, and are rejected if used + alone: `--max-commits N` (default 200) caps the selection and always reports + truncation rather than silently reviewing a subset, and `--merges` keeps + merge commits, which are excluded by default. + Available on the default review, `diff`, `summary`, `dry-run`, the MCP + `review` tool and `guard`. Rejected by `commit` (it describes the staged + index, which has no commits) and by `remote pr` (its diff comes from + `gh pr diff`, not local git). +- **`--exclude-file` / `--exclude-dir` path denylists.** The inverse of + `--file` / `--dir`, sharing their exact matching rules (literal path or + gitignore-style glob) and applied after them, so an exclusion always wins: + `--dir internal --exclude-dir internal/cli`. An invalid glob errors before + any provider call, as it does for the allowlist. +- **Path and commit filters are now reachable over MCP.** The `review` tool + gained `file`, `dir`, `exclude_file`, `exclude_dir`, `author`, `committer`, + `start_date`, `end_date`, `text`, `max_commits` and `merges` arguments. + `--file` / `--dir` were previously CLI-only in practice: the MCP seam resets + the global flag state, so a host had no way to narrow a review by path. + `guard` forwards the same set from its inherited persistent flags. +- **`meta.filtered_commits` in the JSON output.** Optional, `omitempty`, so + schema stays `1` — the count of commits whose patches make up the reviewed + diff. `dry-run` gains matching `Commits (walked)` / `Commits (matched)` lines + and an `--exclude-file/--exclude-dir` row in its per-layer file accounting. - **`commitbrief upgrade` — in-tool updates across every install method (ADR-0034).** Detects whether the running binary came from Homebrew, Scoop, `go install` or a GitHub Releases tarball. Package-managed installs are delegated to @@ -24,6 +62,12 @@ 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. +### 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 + commented on GitHub reviewed a different file set than the same command with + `--no-post`. + ## [1.14.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index a1f3fba..86e412c 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,20 @@ commitbrief --unstaged --dir database/seeder --dir app/Models commitbrief diff HEAD~3 HEAD --dir docs commitbrief --staged --file '*.go' # gitignore-style glob (any depth) commitbrief --staged --file 'internal/**/*.ts' # anchored recursive glob +commitbrief --staged --exclude-file '*_test.go' # denylist; wins over the includes +commitbrief --staged --dir internal --exclude-dir internal/cli + +# Select the commits themselves — author, date window, message or branch name. +# Any of these walks history instead of reading the index, so they replace +# --staged/--unstaged rather than combining with them. +commitbrief --author alice --author bob # either person's commits +commitbrief --author alice@example.com # name or email, case-insensitive +commitbrief --start-date 2026-01-01 # on or after (inclusive) +commitbrief --end-date 2026-03-31 # on or before (inclusive) +commitbrief --text payment # commit message OR branch name +commitbrief --committer carol --merges # committer identity; keep merges +commitbrief --author alice --start-date 2026-06-01 --dir internal # all combinable +commitbrief diff main..develop --author alice # bound the walk to a range # Plain-language change digest (read-only; no findings) commitbrief summary # what's staged, grouped by area @@ -851,7 +865,44 @@ Review content lives in two files: ## Filtering -Three layers, applied in order. Later layers win, so a `!pattern` in +Two independent axes: **which commits** are reviewed, and **which files** +within them. + +### Commit filters (ADR-0035) + +`--author`, `--committer`, `--start-date`, `--end-date` and `--text` select a +set of commits. Setting any of them switches the scope from "the index" to a +history walk, so they cannot be combined with `--staged` / `--unstaged` — those +have no commits yet. `commitbrief commit` and `commitbrief remote pr` reject +them outright (the first describes the index; the second reads its diff from +`gh`, not local git). + +| Flag | Matches | +|---|---| +| `--author` | author name **or** email, case-insensitive substring; repeatable, OR'd | +| `--committer` | committer name or email; repeatable, OR'd | +| `--start-date YYYY-MM-DD` | author date on or after this day (inclusive) | +| `--end-date YYYY-MM-DD` | author date on or before this day (**inclusive** — unlike git's bare `--until`) | +| `--text` | the commit message, **plus** commits unique to a branch whose name contains the text | +| `--max-commits N` | cap the selection (default 200); truncation is always reported | +| `--merges` | keep merge commits, which are excluded by default | + +Different kinds are AND'd, multiple values of one kind are OR'd: +`--author alice --author bob --start-date 2026-06-01` means "(Alice or Bob) +**and** since June". + +The revision range walked is `HEAD` by default, or the range you give a +subcommand: `commitbrief diff main..develop --author alice`. The resulting +diff is the **concatenation of the matching commits' patches**, not a +cumulative range diff — so a file changed in three of them appears three +times, and no unmatched commit's work leaks in. + +Branch-name matching is best-effort by nature: a squash- or rebase-merged +branch no longer owns its commits, so nothing will be found for it. + +### File filters + +Three ignore layers, applied in order. Later layers win, so a `!pattern` in `.commitbriefignore` can revert a built-in exclusion: 1. **Built-in defaults** — binaries, lock files, `vendor/**`, @@ -861,7 +912,13 @@ Three layers, applied in order. Later layers win, so a `!pattern` in 3. **`COMMITBRIEF.md` semantic filter** — natural-language rules the LLM applies to whatever survives the first two layers. -`commitbrief dry-run --staged` reports how many files each layer removed. +On top of those, `--file` / `--dir` narrow to a path allowlist and +`--exclude-file` / `--exclude-dir` remove from it. All four share the same +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. ## Building from source diff --git a/internal/cli/commit.go b/internal/cli/commit.go index 192f05c..f386416 100644 --- a/internal/cli/commit.go +++ b/internal/cli/commit.go @@ -86,9 +86,16 @@ func runCommit(cmd *cobra.Command) error { if global.json || global.markdown || global.output != "" { return errors.New(app.Catalog.T("commit.flag_conflict_output")) } - if len(global.files) > 0 || len(global.dirs) > 0 { + if len(global.files) > 0 || len(global.dirs) > 0 || + len(global.excludeFiles) > 0 || len(global.excludeDirs) > 0 { return errors.New(app.Catalog.T("commit.flag_conflict_filter")) } + // Commit filters select commits that already exist; `commit` describes the + // staged index, which by definition has none yet. Reject rather than + // silently ignore. + if commitFiltersRequested() { + return errors.New(app.Catalog.T("commit.flag_conflict_commit_filter", commitFilterFlags)) + } // Committing needs confirmation we can only get on a TTY. A non-TTY run // must pass --yes to commit the top suggestion unattended; otherwise we @@ -102,7 +109,7 @@ func runCommit(cmd *cobra.Command) error { defer prog.Close() prog.Start(app.Catalog.T("progress.searching")) - raw, err := fetchDiff(app.Repo, reviewScopeFlags{staged: true}, nil) + raw, _, err := fetchDiff(cmd.Context(), app.Repo, reviewScopeFlags{staged: true}, nil, git.CommitFilter{}) if err != nil { prog.Fail(err) return err diff --git a/internal/cli/commitfilter.go b/internal/cli/commitfilter.go new file mode 100644 index 0000000..94902f2 --- /dev/null +++ b/internal/cli/commitfilter.go @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package cli + +import ( + "errors" + "strings" + "time" + + "github.com/CommitBrief/commitbrief/internal/diff" + "github.com/CommitBrief/commitbrief/internal/git" + "github.com/CommitBrief/commitbrief/internal/i18n" +) + +// Commit-level filters, CLI side (ADR-0035). +// +// The global --author / --committer / --start-date / --end-date / --text flags +// select a SET OF COMMITS rather than a single diff. When any of them is set, +// fetchDiff stops asking git for one `git diff` and asks internal/git for the +// concatenated patches of the matching commits instead. +// +// --merges and --max-commits are modifiers: they shape a commit walk but never +// start one. Using either alone is a usage error rather than a silent no-op. + +// dateLayout is the only accepted --start-date / --end-date form. Deliberately +// strict: git's approxidate would happily read "next tuesday" and quietly +// resolve a typo like "06-2026" to something unintended. +const dateLayout = "2006-01-02" + +// commitFilterFlags are the flag names that switch on the commit walk, used in +// the error messages so the user sees exactly which surface they hit. +const commitFilterFlags = "--author/--committer/--start-date/--end-date/--text" + +// buildCommitFilter assembles the filter from the global flags, validating as +// it goes. It returns the zero CommitFilter (Active() == false) when no +// selecting flag is present, which keeps the ordinary `git diff` path intact. +// +// diffArgs are the positional `git diff` arguments (the `diff` / `summary` +// subcommands); they define the revision range the walk covers. With no +// 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) { + f := git.CommitFilter{ + Authors: trimAll(global.authors), + Committers: trimAll(global.committers), + Text: strings.TrimSpace(global.text), + Merges: global.merges, + MaxCommits: global.maxCommits, + } + + var err error + if f.Since, err = parseFilterDate(cat, "--start-date", global.startDate, false); err != nil { + return git.CommitFilter{}, err + } + if f.Until, err = parseFilterDate(cat, "--end-date", global.endDate, true); err != nil { + return git.CommitFilter{}, err + } + if !f.Since.IsZero() && !f.Until.IsZero() && f.Until.Before(f.Since) { + return git.CommitFilter{}, errors.New(cat.T("filter.date.range_inverted", + global.startDate, global.endDate)) + } + + if !f.Active() { + // 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 { + return git.CommitFilter{}, errors.New(cat.T("filter.commit.modifier_only", commitFilterFlags)) + } + return git.CommitFilter{}, nil + } + + // A commit walk has no staged or unstaged changes to look at; the two + // scopes are mutually exclusive with the filters by construction. + if scope.staged || scope.unstaged { + return git.CommitFilter{}, errors.New(cat.T("filter.commit.scope_conflict", commitFilterFlags)) + } + + rev, ok := commitFilterRev(diffArgs) + if !ok { + return git.CommitFilter{}, errors.New(cat.T("filter.commit.unsupported_range", + strings.Join(diffArgs, " "))) + } + f.Rev = rev + return f, nil +} + +// commitFilterRev maps the positional `git diff` arguments onto the revision +// range the commit walk should cover. +// +// (none) → HEAD (the implicit history walk) +// main...feature → main..feature (PR-style: what the branch added) +// main..feature → as-is +// HEAD~3 HEAD → HEAD~3..HEAD +// HEAD / → as-is (git log walks the ancestry — which +// is what a filtered review wants, +// unlike the summary manifest) +// anything with a flag or a `--` pathspec → not ok +// +// The last case is refused rather than guessed: `git diff` pathspecs and +// options do not carry over to `git log` with the same meaning, and a wrong +// guess would silently review the wrong commits. +func commitFilterRev(diffArgs []string) ([]string, bool) { + if len(diffArgs) == 0 { + return []string{"HEAD"}, true + } + for _, a := range diffArgs { + if a == "--" || strings.HasPrefix(a, "-") { + return nil, false + } + } + switch len(diffArgs) { + case 1: + a := diffArgs[0] + if strings.Contains(a, "...") { + return []string{strings.Replace(a, "...", "..", 1)}, true + } + return []string{a}, true + case 2: + if strings.Contains(diffArgs[0], "..") || strings.Contains(diffArgs[1], "..") { + return nil, false + } + return []string{diffArgs[0] + ".." + diffArgs[1]}, true + default: + return nil, false + } +} + +// parseFilterDate accepts a strict YYYY-MM-DD value in the host's local time +// zone. endOfDay expands the value to 23:59:59 so --end-date includes the day +// the user named — git's bare `--until=` stops at that day's midnight, +// which silently drops it. +func parseFilterDate(cat *i18n.Catalog, flag, value string, endOfDay bool) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, nil + } + ts, err := time.ParseInLocation(dateLayout, value, time.Local) + if err != nil { + return time.Time{}, errors.New(cat.T("filter.date.invalid", flag, value)) + } + if endOfDay { + ts = ts.Add(24*time.Hour - time.Second) + } + return ts, nil +} + +// keepAndDropPaths applies the path allowlist then the path denylist, in that +// order, so an exclusion always wins over an inclusion. Every pipeline that +// narrows by path goes through here rather than calling the two in sequence +// itself — the order is a contract, not an implementation detail. +// +// dry-run is the one exception: it calls the two separately because its report +// attributes a file count to each layer. +func keepAndDropPaths(d diff.Diff) (diff.Diff, error) { + out, err := diff.KeepPaths(d, global.files, global.dirs) + if err != nil { + return diff.Diff{}, err + } + return diff.DropPaths(out, global.excludeFiles, global.excludeDirs) +} + +// commitFilterLimit resolves the effective commit cap for reporting, so the +// dry-run report names the number that actually applied rather than the +// literal flag value (0 means "the default"). +func commitFilterLimit(f git.CommitFilter) int { + if f.MaxCommits > 0 { + return f.MaxCommits + } + return git.DefaultMaxCommits +} + +// trimAll drops blank entries so `--author ""` doesn't widen the match to +// everything (matchAny treats an empty needle list as unconstrained). +func trimAll(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// commitFiltersRequested reports whether the user typed any commit-filter flag +// at all, including the modifiers. Commands that cannot honor them (`commit`, +// `remote pr`) use this to reject rather than silently ignore. +func commitFiltersRequested() bool { + return len(trimAll(global.authors)) > 0 || + len(trimAll(global.committers)) > 0 || + strings.TrimSpace(global.startDate) != "" || + strings.TrimSpace(global.endDate) != "" || + strings.TrimSpace(global.text) != "" || + global.merges || + global.maxCommits > 0 +} + +// deriveLogRange turns the user's `git diff` arguments into a clean +// two-endpoint `git log` range for the `summary` commit manifest, returning +// ok=false when no such range can be derived (in which case the summary +// proceeds diff-only, with no commit attribution). It deliberately refuses +// anything ambiguous: +// +// - "main...develop" → "main..develop" (PR-style three-dot diff → the +// commits unique to develop) +// - "main..develop" → "main..develop" (already a range) +// - "HEAD~3 HEAD" → "HEAD~3..HEAD" (two endpoints) +// - "HEAD" / "" → ok=false (a single ref would make git log +// walk all of history, not "this change") +// - anything with flags or a `--` pathspec → ok=false +// +// This is stricter than commitFilterRev on purpose: the manifest is an +// attribution aid for a cumulative range diff, so a bare ref would attribute +// the entire project history to the change. A commit-filtered review has the +// opposite need — it *wants* the ancestry walk, bounded by --max-commits. +func deriveLogRange(diffArgs []string) ([]string, bool) { + if len(diffArgs) == 0 { + return nil, false + } + for _, a := range diffArgs { + if a == "--" || strings.HasPrefix(a, "-") { + return nil, false + } + } + switch len(diffArgs) { + case 1: + a := diffArgs[0] + if strings.Contains(a, "...") { + return []string{strings.Replace(a, "...", "..", 1)}, true + } + if strings.Contains(a, "..") { + return []string{a}, true + } + return nil, false + case 2: + // Two bare refs ("main feature", "HEAD~3 HEAD") → a..b. Refs already + // carrying range syntax here would be malformed git diff input, so a + // plain join is the faithful mapping. + if strings.Contains(diffArgs[0], "..") || strings.Contains(diffArgs[1], "..") { + return nil, false + } + return []string{diffArgs[0] + ".." + diffArgs[1]}, true + default: + return nil, false + } +} diff --git a/internal/cli/dryrun.go b/internal/cli/dryrun.go index 298aba4..2ac4566 100644 --- a/internal/cli/dryrun.go +++ b/internal/cli/dryrun.go @@ -28,7 +28,11 @@ func newDryRunCmd() *cobra.Command { if err != nil { return err } - rawDiff, err := fetchDiff(app.Repo, reviewScope, nil) + commitFilter, err := buildCommitFilter(app.Catalog, reviewScope, nil) + if err != nil { + return err + } + rawDiff, selection, err := fetchDiff(cmd.Context(), app.Repo, reviewScope, nil, commitFilter) if err != nil { return err } @@ -50,6 +54,12 @@ func newDryRunCmd() *cobra.Command { return errors.New(app.Catalog.T("filter.glob.invalid", err.Error())) } pathFilterExcluded := beforePathFilter - parsed.FileCount() + beforeExclude := parsed.FileCount() + parsed, err = diff.DropPaths(parsed, global.excludeFiles, global.excludeDirs) + if err != nil { + return errors.New(app.Catalog.T("filter.glob.invalid", err.Error())) + } + excludeFilterExcluded := beforeExclude - parsed.FileCount() loaded, err := rules.Load(app.RepoRoot) if err != nil { return err @@ -120,17 +130,33 @@ func newDryRunCmd() *cobra.Command { lines := []string{ "Dry run — no provider call.", fmt.Sprintf("Origin: %s", rawDiff.Origin), + } + // Commit accounting only appears for a commit-filtered run — an + // ordinary staged/unstaged dry-run has no commit set to report. + if commitFilter.Active() { + matched := fmt.Sprintf("Commits (matched): %d", len(selection.Commits)) + if selection.Truncated { + matched += fmt.Sprintf(" (truncated at --max-commits %d)", commitFilterLimit(commitFilter)) + } + walked := fmt.Sprintf("Commits (walked): %d", selection.Walked) + if selection.WalkTruncated { + walked += " (walk limit reached; older history not inspected)" + } + lines = append(lines, walked, matched) + } + lines = append(lines, fmt.Sprintf("Files (input): %d", before), fmt.Sprintf(" built-in ignore filtered: %d", builtinExcluded), fmt.Sprintf(" .commitbriefignore net filtered: %d", repoExcluded), fmt.Sprintf(" --file/--dir path filter: %d", pathFilterExcluded), + fmt.Sprintf(" --exclude-file/--exclude-dir: %d", excludeFilterExcluded), fmt.Sprintf("Files (review): %d", parsed.FileCount()), fmt.Sprintf("Added lines: %d", parsed.AddedLines()), fmt.Sprintf("Deleted lines: %d", parsed.DeletedLines()), fmt.Sprintf("Provider: %s", app.Config.Provider), fmt.Sprintf("Model: %s", modelName), fmt.Sprintf("Lang: %s (source: %s)", app.Lang.Code, app.Lang.Source), - } + ) rulesLine := fmt.Sprintf("Rules source: %s", loaded.Source) if loaded.Path != "" { rulesLine += fmt.Sprintf(" (%s)", loaded.Path) diff --git a/internal/cli/guard.go b/internal/cli/guard.go index 0b3a412..8d50d85 100644 --- a/internal/cli/guard.go +++ b/internal/cli/guard.go @@ -148,7 +148,25 @@ func guardReviewJSON(cmd *cobra.Command) (string, error) { NoFlaky: global.noFlaky, // FailOn deliberately left empty: the policy gate decides the verdict, // not the review's own --fail-on. - } + + // The MCP seam wipes the global flag state, so the path (ADR-0026) + // and commit (ADR-0035) filters have to be carried across explicitly + // or a `guard --author alice` would gate on an unfiltered review. + File: global.files, + Dir: global.dirs, + ExcludeFile: global.excludeFiles, + ExcludeDir: global.excludeDirs, + Author: global.authors, + Committer: global.committers, + StartDate: strings.TrimSpace(global.startDate), + EndDate: strings.TrimSpace(global.endDate), + Text: strings.TrimSpace(global.text), + MaxCommits: global.maxCommits, + Merges: global.merges, + } + // guard --unstaged together with a commit filter is left to fail in + // buildCommitFilter, which reports the scope conflict with the same + // message a review would — one rule, one message. _, reviewJSON, err := runReviewForMCP(cmd.Context(), args) if err != nil { return "", err diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index c09cdb6..6dd1f9a 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -2050,3 +2050,259 @@ func TestInjectionScanToggleOffSilences(t *testing.T) { truncate(e.errOut.String(), 400)) } } + +// ---------- --exclude-file / --exclude-dir path denylists (ADR-0035) ---------- + +func TestExcludeFileRemovesNamedFile(t *testing.T) { + e := newCLIEnv(t) + writeFile(t, filepath.Join(e.repoRoot, "second.go"), + "package app\n\nfunc Other() {}\n") + gitCmd(t, e.repoRoot, "add", "second.go") + + if err := e.run("dry-run", "--staged", "--exclude-file", "second.go"); err != nil { + t.Fatalf("dry-run --exclude-file: %v", err) + } + out := e.out.String() + if !strings.Contains(out, "Files (review): 1") { + t.Errorf("expected one file after --exclude-file; got:\n%s", truncate(out, 600)) + } + if !strings.Contains(out, "--exclude-file/--exclude-dir: 1") { + t.Errorf("expected the dry-run report to attribute 1 file to the denylist; got:\n%s", + truncate(out, 600)) + } +} + +func TestExcludeDirRemovesSubtree(t *testing.T) { + e := newCLIEnv(t) + writeFile(t, filepath.Join(e.repoRoot, "models", "user.go"), + "package models\n\ntype User struct{}\n") + gitCmd(t, e.repoRoot, "add", "models/user.go") + + if err := e.run("dry-run", "--staged", "--exclude-dir", "models"); err != nil { + t.Fatalf("dry-run --exclude-dir: %v", err) + } + if out := e.out.String(); !strings.Contains(out, "Files (review): 1") { + t.Errorf("expected the models/ subtree to be dropped; got:\n%s", truncate(out, 600)) + } +} + +func TestExcludeWinsOverInclude(t *testing.T) { + // --dir narrows, --exclude-dir then removes from within that narrowing. + // Order is a contract: the exclusion must win. + e := newCLIEnv(t) + writeFile(t, filepath.Join(e.repoRoot, "internal", "cli", "a.go"), + "package cli\n\nvar A = 1\n") + writeFile(t, filepath.Join(e.repoRoot, "internal", "diff", "b.go"), + "package diff\n\nvar B = 1\n") + gitCmd(t, e.repoRoot, "add", "internal/cli/a.go", "internal/diff/b.go") + + if err := e.run("dry-run", "--staged", + "--dir", "internal", "--exclude-dir", "internal/cli"); err != nil { + t.Fatalf("dry-run --dir + --exclude-dir: %v", err) + } + if out := e.out.String(); !strings.Contains(out, "Files (review): 1") { + t.Errorf("expected only internal/diff/b.go to survive; got:\n%s", truncate(out, 600)) + } +} + +func TestExcludeGlobInvalidErrors(t *testing.T) { + e := newCLIEnv(t) + err := e.run("dry-run", "--staged", "--exclude-file", "[abc.go") + if err == nil { + t.Fatal("an unterminated character class must error, not silently pass every file") + } + if !strings.Contains(err.Error(), "glob") { + t.Errorf("expected a glob error; got %v", err) + } +} + +// ---------- commit-level filters (ADR-0035) ---------- + +// commitAs commits the working tree under an explicit author identity and +// author date, which is what the commit filters select on. +func commitAs(t *testing.T, repo, name, email, date, msg string) { + t.Helper() + gitCmd(t, repo, "commit", "-q", + "--author", name+" <"+email+">", + "--date", date+"T12:00:00+00:00", + "-m", msg) +} + +// newFilterEnv extends the standard harness with a second commit by a +// different author, so author/date filters have both a hit and a miss. +// The staged change the harness leaves behind is committed first so the +// working tree is clean. +func newFilterEnv(t *testing.T) *cliEnv { + t.Helper() + e := newCLIEnv(t) + commitAs(t, e.repoRoot, "Alice", "alice@example.com", "2026-01-10", "feat: login validation") + writeFile(t, filepath.Join(e.repoRoot, "billing.go"), "package app\n\nvar Rate = 1\n") + gitCmd(t, e.repoRoot, "add", "billing.go") + commitAs(t, e.repoRoot, "Bob", "bob@example.com", "2026-02-10", "feat: billing rate") + return e +} + +func TestCommitFilterAuthorSelectsCommits(t *testing.T) { + e := newFilterEnv(t) + if err := e.run("dry-run", "--author", "alice"); err != nil { + t.Fatalf("dry-run --author: %v\nstderr:\n%s", err, e.errOut.String()) + } + out := e.out.String() + if !strings.Contains(out, "Commits (matched): 1") { + t.Errorf("expected exactly Alice's commit; got:\n%s", truncate(out, 800)) + } + if !strings.Contains(out, "Origin: filtered") { + t.Errorf("expected the filtered origin; got:\n%s", truncate(out, 800)) + } +} + +func TestCommitFilterAuthorAndDateCombine(t *testing.T) { + e := newFilterEnv(t) + // Alice's commit is in January; the window excludes it. + if err := e.run("dry-run", "--author", "alice", + "--start-date", "2026-02-01", "--end-date", "2026-02-28"); err != nil { + t.Fatalf("dry-run --author+dates: %v", err) + } + if out := e.out.String(); !strings.Contains(out, "Commits (matched): 0") { + t.Errorf("author AND date must both apply; got:\n%s", truncate(out, 800)) + } +} + +func TestCommitFilterTextMatchesMessage(t *testing.T) { + e := newFilterEnv(t) + if err := e.run("dry-run", "--text", "billing"); err != nil { + t.Fatalf("dry-run --text: %v", err) + } + if out := e.out.String(); !strings.Contains(out, "Commits (matched): 1") { + t.Errorf("expected the billing commit; got:\n%s", truncate(out, 800)) + } +} + +func TestCommitFilterMaxCommitsTruncates(t *testing.T) { + e := newFilterEnv(t) + if err := e.run("dry-run", "--start-date", "2026-01-01", "--max-commits", "1"); err != nil { + t.Fatalf("dry-run --max-commits: %v", err) + } + out := e.out.String() + if !strings.Contains(out, "Commits (matched): 1") { + t.Errorf("expected the cap to apply; got:\n%s", truncate(out, 800)) + } + if !strings.Contains(out, "truncated at --max-commits 1") { + t.Errorf("truncation must be reported, never silent; got:\n%s", truncate(out, 800)) + } +} + +func TestCommitFilterCombinesWithPathFilters(t *testing.T) { + e := newFilterEnv(t) + if err := e.run("dry-run", "--start-date", "2026-01-01", + "--exclude-file", "billing.go"); err != nil { + t.Fatalf("dry-run commit+path filter: %v", err) + } + out := e.out.String() + // Three commits: the harness's "initial", Alice's, and Bob's. + if !strings.Contains(out, "Commits (matched): 3") { + t.Errorf("every commit should match the date filter; got:\n%s", truncate(out, 800)) + } + if !strings.Contains(out, "--exclude-file/--exclude-dir: 1") { + t.Errorf("the path denylist must still apply on top; got:\n%s", truncate(out, 800)) + } +} + +func TestCommitFilterRunsFullReview(t *testing.T) { + // End-to-end through the mock provider: a commit-filtered run must reach + // the renderer like any other review. + e := newFilterEnv(t) + if err := e.run("--author", "alice", "--no-cache", "--no-cost-check"); err != nil { + t.Fatalf("commit-filtered review: %v\nstderr:\n%s", err, e.errOut.String()) + } + if !strings.Contains(e.out.String(), "mock review output") { + t.Errorf("expected mock provider output; got:\n%s", truncate(e.out.String(), 400)) + } +} + +func TestCommitFilterReportsCountInJSON(t *testing.T) { + e := newFilterEnv(t) + if err := e.run("--author", "alice", "--json", "--no-cache", "--no-cost-check"); err != nil { + t.Fatalf("commit-filtered --json review: %v\nstderr:\n%s", err, e.errOut.String()) + } + if !strings.Contains(e.out.String(), `"filtered_commits": 1`) { + t.Errorf("expected meta.filtered_commits in the JSON document; got:\n%s", + truncate(e.out.String(), 800)) + } +} + +func TestCommitFilterRejectsStagedScope(t *testing.T) { + e := newFilterEnv(t) + err := e.run("--staged", "--author", "alice") + if err == nil { + t.Fatal("--staged with a commit filter must error: the index has no commits") + } + if !strings.Contains(err.Error(), "--staged") { + t.Errorf("error should name the conflicting scope flag; got %v", err) + } +} + +func TestCommitFilterRejectsUnstagedScope(t *testing.T) { + e := newFilterEnv(t) + if err := e.run("--unstaged", "--text", "billing"); err == nil { + t.Fatal("--unstaged with a commit filter must error") + } +} + +func TestCommitFilterRejectsMalformedDate(t *testing.T) { + e := newCLIEnv(t) + err := e.run("dry-run", "--start-date", "06-2026") + if err == nil { + t.Fatal("a malformed --start-date must fail before any provider call") + } + if !strings.Contains(err.Error(), "YYYY-MM-DD") { + t.Errorf("error should show the expected form; got %v", err) + } +} + +func TestCommitFilterRejectsInvertedDateRange(t *testing.T) { + e := newCLIEnv(t) + if err := e.run("dry-run", "--start-date", "2026-03-01", "--end-date", "2026-01-01"); err == nil { + t.Fatal("an inverted date range selects nothing and must error") + } +} + +func TestCommitFilterModifierAloneIsRejected(t *testing.T) { + // --merges / --max-commits shape a commit walk but never start one. + // Accepting them alone would silently ignore what the user typed. + e := newCLIEnv(t) + if err := e.run("dry-run", "--merges"); err == nil { + t.Fatal("--merges alone must error") + } + e2 := newCLIEnv(t) + if err := e2.run("dry-run", "--max-commits", "5"); err == nil { + t.Fatal("--max-commits alone must error") + } +} + +func TestCommitFilterRejectedByCommitCommand(t *testing.T) { + e := newCLIEnv(t) + err := e.run("commit", "--author", "alice") + if err == nil { + t.Fatal("commit describes the staged index, which has no commits; must error") + } + if !strings.Contains(err.Error(), "commit") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCommitFilterRejectedByRemotePR(t *testing.T) { + // The PR diff comes from `gh`, not local git — reject before anything + // else, including the gh presence check. + e := newCLIEnv(t) + if err := e.run("remote", "pr", "1", "--author", "alice"); err == nil { + t.Fatal("remote pr with a commit filter must error") + } +} + +func TestExcludeFiltersRejectedByCommitCommand(t *testing.T) { + e := newCLIEnv(t) + if err := e.run("commit", "--exclude-file", "app.go"); err == nil { + t.Fatal("commit must reject the path denylist for the same reason it rejects --file/--dir") + } +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index ac2417b..5ca879d 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -116,6 +116,61 @@ func reviewToolInputSchema() json.RawMessage { "type": "boolean", "description": "Skip the deterministic flaky-test detector (ADR-0022).", }, + // Path filters (ADR-0026). Previously CLI-only: the MCP seam + // resets the global flag state, so a host had no way to narrow a + // review by path at all. + "file": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Review only these files. A plain value is an exact path; a value containing */?/[ is a gitignore-style glob (e.g. \"*.go\", \"internal/**/*.ts\").", + }, + "dir": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Review only files under these directories. A plain value is a / prefix; a glob value is matched gitignore-style.", + }, + "exclude_file": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Skip these files or globs. Same matching rules as `file`, applied after it so an exclusion wins.", + }, + "exclude_dir": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Skip files under these directories or matching dir globs. Applied after `dir` so an exclusion wins.", + }, + // Commit-level filters (ADR-0035). Any of these switches the scope + // to a commit walk; `staged`/`unstaged` then become invalid. + "author": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Review only commits authored by these people (matches name or email, case-insensitive). Selects a commit set instead of a staged/unstaged diff.", + }, + "committer": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Review only commits committed by these people (matches name or email, case-insensitive).", + }, + "start_date": map[string]any{ + "type": "string", + "description": "Review only commits on or after this date, YYYY-MM-DD, inclusive.", + }, + "end_date": map[string]any{ + "type": "string", + "description": "Review only commits on or before this date, YYYY-MM-DD, inclusive.", + }, + "text": map[string]any{ + "type": "string", + "description": "Review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive).", + }, + "max_commits": map[string]any{ + "type": "integer", + "description": "Cap how many matching commits enter the review (0 = the built-in default). Only meaningful alongside another commit filter.", + }, + "merges": map[string]any{ + "type": "boolean", + "description": "Include merge commits in a commit-filtered review (excluded by default). Only meaningful alongside another commit filter.", + }, }, "additionalProperties": false, } @@ -151,6 +206,31 @@ type reviewToolArgs struct { FailOn string `json:"fail_on,omitempty"` MinSeverity string `json:"min_severity,omitempty"` NoFlaky bool `json:"no_flaky,omitempty"` + + // Path filters (ADR-0026) and commit filters (ADR-0035). They mirror the + // identically-named global flags; `guard` fills them from those flags, + // an MCP host from the tool arguments. + File []string `json:"file,omitempty"` + Dir []string `json:"dir,omitempty"` + ExcludeFile []string `json:"exclude_file,omitempty"` + ExcludeDir []string `json:"exclude_dir,omitempty"` + Author []string `json:"author,omitempty"` + Committer []string `json:"committer,omitempty"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` + Text string `json:"text,omitempty"` + MaxCommits int `json:"max_commits,omitempty"` + Merges bool `json:"merges,omitempty"` +} + +// commitFilterRequested reports whether any commit-selecting argument is set, +// so the scope choice below can skip the staged default that would otherwise +// conflict with a commit walk. +func (a reviewToolArgs) commitFilterRequested() bool { + return len(a.Author) > 0 || len(a.Committer) > 0 || + strings.TrimSpace(a.StartDate) != "" || + strings.TrimSpace(a.EndDate) != "" || + strings.TrimSpace(a.Text) != "" } // reviewToolHandler returns the ToolHandler that runs the review pipeline. It @@ -221,6 +301,10 @@ func runReviewForMCP(ctx context.Context, args reviewToolArgs) (string, string, case len(args.Diff) > 0: // Range review: scope flags are ignored when diffArgs is non-empty, // matching the `diff` subcommand. + case args.commitFilterRequested(): + // A commit walk has no staged/unstaged scope; leaving both false is + // what buildCommitFilter requires (setting staged here would make + // every filtered call fail with a scope conflict). case args.Unstaged: scope.unstaged = true default: @@ -233,6 +317,17 @@ func runReviewForMCP(ctx context.Context, args reviewToolArgs) (string, string, global.failOn = strings.TrimSpace(args.FailOn) global.minSeverity = strings.TrimSpace(args.MinSeverity) global.noFlaky = args.NoFlaky + global.files = args.File + global.dirs = args.Dir + global.excludeFiles = args.ExcludeFile + global.excludeDirs = args.ExcludeDir + global.authors = args.Author + global.committers = args.Committer + global.startDate = strings.TrimSpace(args.StartDate) + global.endDate = strings.TrimSpace(args.EndDate) + global.text = strings.TrimSpace(args.Text) + global.maxCommits = args.MaxCommits + global.merges = args.Merges // Synthetic command: buffered sinks + the host context. runReview reads // cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr() — never os.Stdout diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 2d16001..cf6e4aa 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -5,6 +5,7 @@ package cli import ( "encoding/json" "os" + "path/filepath" "strings" "testing" ) @@ -238,3 +239,72 @@ func decodeResult(t *testing.T, r mcpResponse, v any) { t.Fatalf("decode result: %v\n%s", err, r.Result) } } + +// TestMCPReviewPathFilterArgs covers the path filters over MCP. They were +// CLI-only before ADR-0035: the MCP seam resets the global flag state, so a +// host had no way to narrow a review by path at all. +func TestMCPReviewPathFilterArgs(t *testing.T) { + e := newCLIEnv(t) + writeFile(t, filepath.Join(e.repoRoot, "second.go"), "package app\n\nfunc Other() {}\n") + gitCmd(t, e.repoRoot, "add", "second.go") + + resps := runMCPServer(t, e, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"review","arguments":{"staged":true,"exclude_file":["second.go"]}}}`, + ) + var callRes struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + decodeResult(t, resps[0], &callRes) + if callRes.IsError { + t.Fatalf("path filter args should be accepted; got %q", callRes.Content[0].Text) + } +} + +// TestMCPReviewCommitFilterArgs drives a commit walk through the tool +// arguments. The handler must not set the staged scope for a commit-filtered +// call, or buildCommitFilter would reject it as a scope conflict. +func TestMCPReviewCommitFilterArgs(t *testing.T) { + e := newCLIEnv(t) + gitCmd(t, e.repoRoot, "commit", "-q", "-m", "feat: login validation") + + resps := runMCPServer(t, e, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"review","arguments":{"text":"login"}}}`, + ) + var callRes struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + decodeResult(t, resps[0], &callRes) + if callRes.IsError { + t.Fatalf("commit filter args should drive a commit walk; got %q", callRes.Content[0].Text) + } + if !strings.Contains(callRes.Content[1].Text, `"filtered_commits": 1`) { + t.Errorf("expected meta.filtered_commits in the returned document; got %q", + callRes.Content[1].Text) + } +} + +// TestMCPReviewToolSchemaAdvertisesFilters guards the two-surface contract: +// every argument reviewToolArgs decodes must also be advertised, or a host +// following the schema can never reach it (additionalProperties:false). +func TestMCPReviewToolSchemaAdvertisesFilters(t *testing.T) { + var schema struct { + Properties map[string]any `json:"properties"` + } + if err := json.Unmarshal(reviewToolInputSchema(), &schema); err != nil { + t.Fatal(err) + } + for _, key := range []string{ + "file", "dir", "exclude_file", "exclude_dir", + "author", "committer", "start_date", "end_date", "text", "max_commits", "merges", + } { + if _, ok := schema.Properties[key]; !ok { + t.Errorf("input schema is missing the %q property", key) + } + } +} diff --git a/internal/cli/remote_pr.go b/internal/cli/remote_pr.go index bf9b16d..ae6919a 100644 --- a/internal/cli/remote_pr.go +++ b/internal/cli/remote_pr.go @@ -84,6 +84,15 @@ func parseRequestChangesOn(raw string) (render.Severity, error) { } func runRemotePR(cmd *cobra.Command, prID string, f remotePRFlags, runner remote.Runner) error { + // The PR diff comes from `gh pr diff`, not from local git, so there is no + // commit history here to walk. Reject the commit filters up front, on both + // entry paths, rather than accepting flags that could not be honored. + if commitFiltersRequested() { + // pickErrorCatalog, not appContext: this check runs before either + // path resolves its context, and it must fire the same way whether + // or not the user is standing in a git repo. + return errors.New(pickErrorCatalog().T("remote.commit_filter_unsupported", commitFilterFlags)) + } // --no-post (ADR-0016 §Update): use the PR diff purely as a review // source and render to the terminal like a local review — no GitHub // writes (no comments, no verdict), so the local-render and CLI flags @@ -295,7 +304,7 @@ func runRemotePRLocal(cmd *cobra.Command, prID string, f remotePRFlags, runner r return err } parsed = diff.Filter(parsed, buildMatcher(app.RepoRoot)) - parsed, err = diff.KeepPaths(parsed, global.files, global.dirs) + parsed, err = keepAndDropPaths(parsed) if err != nil { err = errors.New(cat.T("filter.glob.invalid", err.Error())) prog.Fail(err) @@ -547,6 +556,13 @@ func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f r return prReviewResult{}, err } parsed = diff.Filter(parsed, buildMatcher(app.RepoRoot)) + // The --no-post path has always honored --file/--dir; the posting path + // silently did not. Both go through the shared helper now so a narrowed + // `remote pr` reviews the same file set whether or not it comments. + parsed, err = keepAndDropPaths(parsed) + if err != nil { + return prReviewResult{}, errors.New(app.Catalog.T("filter.glob.invalid", err.Error())) + } if parsed.Empty() { return prReviewResult{findings: []render.Finding{}, anchors: map[string]diff.FileAnchors{}}, nil } diff --git a/internal/cli/review.go b/internal/cli/review.go index 164dcba..3571482 100644 --- a/internal/cli/review.go +++ b/internal/cli/review.go @@ -61,6 +61,14 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er return err } + // Commit-level filters (ADR-0035). Resolved before anything else that + // costs time so a malformed date or an impossible scope combination + // fails on the spot rather than after the rules/architecture load. + commitFilter, err := buildCommitFilter(app.Catalog, scope, diffArgs) + if err != nil { + return err + } + // --suggest-commit (ADR-0015) is staged-only and conflicts with the // structured / file-output flags. Validate up front so a misuse fails // before any provider call. @@ -114,7 +122,7 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er defer prog.Close() prog.Start(app.Catalog.T("progress.searching")) - rawDiff, err := fetchDiff(app.Repo, scope, diffArgs) + rawDiff, selection, err := fetchDiff(ctx, app.Repo, scope, diffArgs, commitFilter) if err != nil { prog.Fail(err) return err @@ -126,12 +134,13 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er } matcher := buildMatcher(app.RepoRoot) parsed = diff.Filter(parsed, matcher) - parsed, err = diff.KeepPaths(parsed, global.files, global.dirs) + parsed, err = keepAndDropPaths(parsed) if err != nil { err = errors.New(app.Catalog.T("filter.glob.invalid", err.Error())) prog.Fail(err) return err } + reportSelection(cmd, app, prog, selection, commitFilter) if parsed.Empty() { prog.Finish() prog.Close() @@ -337,17 +346,18 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er // what would have been spent — surfaced as "Saved" by the // verbose footer (see render/verbose.go). meta := render.Meta{ - Provider: prov.Name(), - Model: model, - Lang: app.Lang.Code, - Cached: true, - Timestamp: entry.CreatedAt, - Usage: usage, - Cost: resolvePricing(app.Config, prov, model).Cost(usage), - Files: parsed.FileCount(), - LinesAdded: parsed.AddedLines(), - LinesRemoved: parsed.DeletedLines(), - RulesLoaded: loaded.Source != rules.SourceDefault, + Provider: prov.Name(), + Model: model, + Lang: app.Lang.Code, + Cached: true, + Timestamp: entry.CreatedAt, + Usage: usage, + Cost: resolvePricing(app.Config, prov, model).Cost(usage), + Files: parsed.FileCount(), + LinesAdded: parsed.AddedLines(), + LinesRemoved: parsed.DeletedLines(), + RulesLoaded: loaded.Source != rules.SourceDefault, + FilteredCommits: len(selection.Commits), } // Parse Findings unless the entry was written in markdown-fallback // or plain-text mode — in those cases the cached Content is @@ -520,6 +530,8 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er Suppressed: suppressed, Retries: retries, DegradeReason: degrade, + + FilteredCommits: len(selection.Commits), } if !global.noCache && cacheStore != nil { @@ -964,14 +976,58 @@ func degradeReason(err error) string { } } -func fetchDiff(repo *git.DispatchRepo, scope reviewScopeFlags, diffArgs []string) (git.Diff, error) { - if len(diffArgs) > 0 { - return repo.Diff(diffArgs) +// fetchDiff is the single diff-acquisition seam every command funnels +// through. An active commit filter (ADR-0035) replaces the `git diff` call +// with a commit walk plus per-commit patch concatenation; everything else is +// the original three-way scope choice, untouched. +// +// The returned Selection is empty for the non-filtered paths — those scopes +// have no commit set to report on. +func fetchDiff(ctx context.Context, repo *git.DispatchRepo, scope reviewScopeFlags, diffArgs []string, cf git.CommitFilter) (git.Diff, git.Selection, error) { + if cf.Active() { + return git.FilteredDiff(ctx, repo.Root(), cf) } - if scope.unstaged { - return repo.UnstagedDiff() + var ( + d git.Diff + err error + ) + switch { + case len(diffArgs) > 0: + d, err = repo.Diff(diffArgs) + case scope.unstaged: + d, err = repo.UnstagedDiff() + default: + d, err = repo.StagedDiff() } - return repo.StagedDiff() + return d, git.Selection{}, err +} + +// reportSelection emits the "which commits did this actually cover?" lines for +// a commit-filtered run. Silence would be the dangerous option here: a +// truncated walk means the review looked at a subset, and the user has to know. +func reportSelection(cmd *cobra.Command, app *appContext, prog *ui.Progress, sel git.Selection, cf git.CommitFilter) { + if !cf.Active() { + return + } + prog.Info(app.Catalog.T("filter.commit.selected", len(sel.Commits), sel.Walked)) + if !sel.Truncated && !sel.WalkTruncated { + return + } + // Truncation goes out unconditionally — not through the quiet-gated + // info channel — because --quiet must never hide the fact that the + // review covered only a subset of the matching history. + prog.Pause() + if sel.Truncated { + limit := cf.MaxCommits + if limit <= 0 { + limit = git.DefaultMaxCommits + } + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), app.Catalog.T("filter.commit.truncated", limit)) + } + if sel.WalkTruncated { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), app.Catalog.T("filter.commit.walk_truncated")) + } + prog.Resume() } // resolveArchContext discovers and renders the architecture-constraints block diff --git a/internal/cli/root.go b/internal/cli/root.go index 4c20b15..c4daa28 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -59,7 +59,19 @@ type globalFlags struct { showPrompt bool // --show-prompt; print the assembled system+user prompt and exit (no provider call) files []string // global --file (repeatable); path filter applied post-parse dirs []string // global --dir (repeatable); prefix filter applied post-parse - genMan string // hidden: --gen-man writes man pages and exits + excludeFiles []string // global --exclude-file (repeatable); path denylist applied after --file/--dir (ADR-0035) + excludeDirs []string // global --exclude-dir (repeatable); dir denylist applied after --file/--dir (ADR-0035) + // Commit-level filters (ADR-0035). Any of authors/committers/startDate/ + // endDate/text switches diff acquisition from `git diff` to a commit + // walk; merges and maxCommits only modify such a walk. + authors []string // --author (repeatable); matches name or email, case-insensitive + committers []string // --committer (repeatable) + startDate string // --start-date YYYY-MM-DD (inclusive) + endDate string // --end-date YYYY-MM-DD (inclusive; expanded to end-of-day) + text string // --text; matches the commit message or a branch name + maxCommits int // --max-commits; 0 → git.DefaultMaxCommits + merges bool // --merges; include merge commits (default: excluded) + genMan string // hidden: --gen-man writes man pages and exits } var global globalFlags @@ -130,6 +142,17 @@ func newRootCmd() *cobra.Command { flags.StringVar(&global.color, "color", "auto", "color output: auto, always, never") flags.StringSliceVarP(&global.files, "file", "f", nil, "review only these files or globs (e.g. `*.go`, `internal/**/*.ts`; repeatable, one pattern per flag — patterns can't be comma-joined); combines with the active scope flag") flags.StringSliceVarP(&global.dirs, "dir", "d", nil, "review only files under these directories or matching dir globs (e.g. `internal/**`; repeatable, one pattern per flag); combines with the active scope flag") + flags.StringSliceVar(&global.excludeFiles, "exclude-file", nil, "skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins") + flags.StringSliceVar(&global.excludeDirs, "exclude-dir", nil, "skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins") + // Commit-level filters (ADR-0035). Long-form only: the short-flag + // namespace is reserved for the flags that were already common. + flags.StringSliceVar(&global.authors, "author", nil, "review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk") + flags.StringSliceVar(&global.committers, "committer", nil, "review only commits committed by these people (repeatable; matches name or email, case-insensitive)") + flags.StringVar(&global.startDate, "start-date", "", "review only commits on or after this date (YYYY-MM-DD, inclusive)") + flags.StringVar(&global.endDate, "end-date", "", "review only commits on or before this date (YYYY-MM-DD, inclusive)") + flags.StringVar(&global.text, "text", "", "review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive)") + flags.IntVar(&global.maxCommits, "max-commits", 0, "cap how many matching commits enter the review (0 = "+strconv.Itoa(git.DefaultMaxCommits)+"); only meaningful with another commit filter") + flags.BoolVar(&global.merges, "merges", false, "include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter") flags.StringVar(&global.cli, "cli", "", "use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli") flags.BoolVar(&global.withContext, "with-context", false, "let the CLI provider read project files beyond the diff to ground the review (CLI providers only; the host CLI's agent reads your repo — see --help)") flags.BoolVar(&global.showPrompt, "show-prompt", false, "print the exact system + user prompt that would be sent, then exit (no provider call, no cost)") diff --git a/internal/cli/summary.go b/internal/cli/summary.go index 5877afa..8ab9e11 100644 --- a/internal/cli/summary.go +++ b/internal/cli/summary.go @@ -74,11 +74,16 @@ func runSummary(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) e return errors.New(app.Catalog.T("summary.flag_conflict_review")) } + commitFilter, err := buildCommitFilter(app.Catalog, scope, diffArgs) + if err != nil { + return err + } + prog := ui.NewProgress(cmd.ErrOrStderr(), ui.ParseColorMode(global.color), global.quiet) defer prog.Close() prog.Start(app.Catalog.T("progress.searching")) - rawDiff, err := fetchDiff(app.Repo, scope, diffArgs) + rawDiff, selection, err := fetchDiff(ctx, app.Repo, scope, diffArgs, commitFilter) if err != nil { prog.Fail(err) return err @@ -89,12 +94,13 @@ func runSummary(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) e return err } parsed = diff.Filter(parsed, buildMatcher(app.RepoRoot)) - parsed, err = diff.KeepPaths(parsed, global.files, global.dirs) + parsed, err = keepAndDropPaths(parsed) if err != nil { err = errors.New(app.Catalog.T("filter.glob.invalid", err.Error())) prog.Fail(err) return err } + reportSelection(cmd, app, prog, selection, commitFilter) if parsed.Empty() { prog.Finish() prog.Close() @@ -111,9 +117,18 @@ func runSummary(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) e // never walk all of history. Best-effort: a git error here is swallowed // and the summary proceeds without attribution. var manifest string - if logArgs, ok := deriveLogRange(diffArgs); ok { - if commits, cErr := git.RangeCommits(ctx, app.RepoRoot, logArgs); cErr == nil { - manifest = formatManifest(commits) + switch { + case commitFilter.Active(): + // The filter already resolved the exact commit set that produced this + // diff, with full metadata. Reusing it keeps the attribution honest — + // a second, unfiltered `git log` would credit commits the review never + // looked at. + manifest = formatManifest(selection.Commits) + default: + if logArgs, ok := deriveLogRange(diffArgs); ok { + if commits, cErr := git.RangeCommits(ctx, app.RepoRoot, logArgs); cErr == nil { + manifest = formatManifest(commits) + } } } @@ -292,50 +307,6 @@ func emitSummary(cmd *cobra.Command, content string) error { return nil } -// deriveLogRange turns the user's `git diff` arguments into a clean -// two-endpoint `git log` range, returning ok=false when no such range can be -// derived (in which case the summary proceeds diff-only, with no commit -// attribution). It deliberately refuses anything ambiguous: -// -// - "main...develop" → "main..develop" (PR-style three-dot diff → the -// commits unique to develop) -// - "main..develop" → "main..develop" (already a range) -// - "HEAD~3 HEAD" → "HEAD~3..HEAD" (two endpoints) -// - "HEAD" / "" → ok=false (a single ref would make git log -// walk all of history, not "this change") -// - anything with flags or a `--` pathspec → ok=false -func deriveLogRange(diffArgs []string) ([]string, bool) { - if len(diffArgs) == 0 { - return nil, false - } - for _, a := range diffArgs { - if a == "--" || strings.HasPrefix(a, "-") { - return nil, false - } - } - switch len(diffArgs) { - case 1: - a := diffArgs[0] - if strings.Contains(a, "...") { - return []string{strings.Replace(a, "...", "..", 1)}, true - } - if strings.Contains(a, "..") { - return []string{a}, true - } - return nil, false - case 2: - // Two bare refs ("main feature", "HEAD~3 HEAD") → a..b. Refs already - // carrying range syntax here would be malformed git diff input, so a - // plain join is the faithful mapping. - if strings.Contains(diffArgs[0], "..") || strings.Contains(diffArgs[1], "..") { - return nil, false - } - return []string{diffArgs[0] + ".." + diffArgs[1]}, true - default: - return nil, false - } -} - // maxManifestFilesPerCommit caps how many touched paths a single commit // contributes to the manifest. A bulk commit (e.g. a man-page regeneration // touching every page) would otherwise dump dozens of low-signal paths that diff --git a/internal/diff/filter.go b/internal/diff/filter.go index b4d90f0..5d791e0 100644 --- a/internal/diff/filter.go +++ b/internal/diff/filter.go @@ -66,15 +66,60 @@ func shouldExclude(f FileDiff, m *ignore.Matcher) bool { // `filepath.ToSlash` normalization on both sides so a user passing // `app\Models` on Windows still matches `app/Models/User.go`. func KeepPaths(d Diff, files, dirs []string) (Diff, error) { + return selectPaths(d, files, dirs, true) +} + +// DropPaths is the inverse of KeepPaths: it removes every file matching the +// supplied denylists (`--exclude-file` / `--exclude-dir`) and keeps the rest. +// Pattern semantics are identical — same literal/glob bucketing, same +// gitignore matcher, same union across files, dirs and globs — so a pattern +// that would select a file under KeepPaths removes exactly that file here. +// +// Both empty → no filtering (returns d unchanged). Applied after KeepPaths, +// so exclusion wins: `--dir internal --exclude-dir internal/cli` reviews +// everything under internal/ except internal/cli. +// +// An invalid glob returns a non-nil error, exactly as KeepPaths does — an +// unusable denylist must not silently pass every file through. +func DropPaths(d Diff, files, dirs []string) (Diff, error) { + return selectPaths(d, files, dirs, false) +} + +// selectPaths is the shared engine: build the matcher once, then keep the +// files whose match result equals `keepOnMatch`. +func selectPaths(d Diff, files, dirs []string, keepOnMatch bool) (Diff, error) { if len(files) == 0 && len(dirs) == 0 { return d, nil } + m, err := newPathMatcher(files, dirs) + if err != nil { + return Diff{}, err + } + out := Diff{Origin: d.Origin, Args: d.Args} + for _, f := range d.Files { + if m.matches(f) == keepOnMatch { + out.Files = append(out.Files, f) + } + } + out.addedLines, out.deletedLines = countLineKinds(out.Files) + return out, nil +} - var ( - literalFiles = make(map[string]struct{}, len(files)) - literalDirs = make([]string, 0, len(dirs)) - globSources = make([]string, 0, len(files)+len(dirs)) - ) +// pathMatcher holds one compiled --file/--dir style pattern set. Splitting it +// out lets the allowlist and the denylist share a single implementation of +// the bucketing rules ADR-0026 froze, so the two can never drift apart. +type pathMatcher struct { + literalFiles map[string]struct{} + literalDirs []string + globs []gitignore.Pattern +} + +func newPathMatcher(files, dirs []string) (pathMatcher, error) { + m := pathMatcher{ + literalFiles: make(map[string]struct{}, len(files)), + literalDirs: make([]string, 0, len(dirs)), + } + globSources := make([]string, 0, len(files)+len(dirs)) for _, f := range files { trimmed := strings.TrimSpace(f) @@ -86,7 +131,7 @@ func KeepPaths(d Diff, files, dirs []string) (Diff, error) { globSources = append(globSources, norm) continue } - literalFiles[norm] = struct{}{} + m.literalFiles[norm] = struct{}{} } for _, dir := range dirs { trimmed := strings.TrimSpace(dir) @@ -102,22 +147,19 @@ func KeepPaths(d Diff, files, dirs []string) (Diff, error) { if clean == "" { continue } - literalDirs = append(literalDirs, clean+"/") + m.literalDirs = append(m.literalDirs, clean+"/") } globs, err := compileGlobs(globSources) if err != nil { - return Diff{}, err + return pathMatcher{}, err } + m.globs = globs + return m, nil +} - out := Diff{Origin: d.Origin, Args: d.Args} - for _, f := range d.Files { - if matchesPathAllowlist(f, literalFiles, literalDirs) || matchesAnyGlob(f, globs) { - out.Files = append(out.Files, f) - } - } - out.addedLines, out.deletedLines = countLineKinds(out.Files) - return out, nil +func (m pathMatcher) matches(f FileDiff) bool { + return matchesPathAllowlist(f, m.literalFiles, m.literalDirs) || matchesAnyGlob(f, m.globs) } // toSlashPattern normalizes a user-supplied --file/--dir pattern to diff --git a/internal/diff/filter_test.go b/internal/diff/filter_test.go index 892ba64..0b68d8d 100644 --- a/internal/diff/filter_test.go +++ b/internal/diff/filter_test.go @@ -331,3 +331,159 @@ func TestKeepPaths_InvalidGlobReturnsError(t *testing.T) { t.Fatalf("invalid glob '[abc.go' should return an error") } } + +// --- DropPaths (--exclude-file / --exclude-dir) ----------------------------- + +// mustDrop mirrors mustKeep for the denylist side. +func mustDrop(t *testing.T, d Diff, files, dirs []string) Diff { + t.Helper() + got, err := DropPaths(d, files, dirs) + if err != nil { + t.Fatalf("DropPaths(%v, %v) unexpected error: %v", files, dirs, err) + } + return got +} + +func TestDropPaths_NoFiltersReturnsInput(t *testing.T) { + d := sample() + if got := mustDrop(t, d, nil, nil); !reflect.DeepEqual(got, d) { + t.Errorf("DropPaths with no filters should be identity; got %v", got) + } + if got := mustDrop(t, d, []string{}, []string{}); !reflect.DeepEqual(got, d) { + t.Errorf("DropPaths with empty slices should be identity") + } +} + +func TestDropPaths_FileDenylist(t *testing.T) { + d := sample() + got := mustDrop(t, d, []string{"routes/web.php", "app/Models/User.go"}, nil) + want := []string{ + "app/Http/Controllers/API.php", + "database/seeder/UserSeeder.php", + "database/seeder/RoleSeeder.php", + "tests/unit_test.go", + "renamed.go", + } + if !reflect.DeepEqual(paths(got), want) { + t.Errorf("file denylist = %v, want %v", paths(got), want) + } +} + +func TestDropPaths_DirDenylistPrefixMatch(t *testing.T) { + d := sample() + got := mustDrop(t, d, nil, []string{"database/seeder"}) + want := []string{ + "app/Http/Controllers/API.php", + "app/Models/User.go", + "routes/web.php", + "tests/unit_test.go", + "renamed.go", + } + if !reflect.DeepEqual(paths(got), want) { + t.Errorf("dir denylist = %v, want %v", paths(got), want) + } +} + +func TestDropPaths_DirDenylistDoesNotMatchSibling(t *testing.T) { + d := Diff{Files: []FileDiff{ + {Path: "database/seeder/file.php"}, + {Path: "database/seedother/file.php"}, + }} + got := mustDrop(t, d, nil, []string{"database/seeder"}) + want := []string{"database/seedother/file.php"} + if !reflect.DeepEqual(paths(got), want) { + t.Errorf("dir prefix drop = %v, want %v (no substring leakage)", paths(got), want) + } +} + +func TestDropPaths_Glob(t *testing.T) { + d := sample() + got := mustDrop(t, d, []string{"*.go"}, nil) + want := []string{ + "app/Http/Controllers/API.php", + "routes/web.php", + "database/seeder/UserSeeder.php", + "database/seeder/RoleSeeder.php", + } + if !reflect.DeepEqual(paths(got), want) { + t.Errorf("glob denylist = %v, want %v", paths(got), want) + } +} + +func TestDropPaths_AnchoredGlob(t *testing.T) { + d := sample() + got := mustDrop(t, d, nil, []string{"database/**"}) + want := []string{ + "app/Http/Controllers/API.php", + "app/Models/User.go", + "routes/web.php", + "tests/unit_test.go", + "renamed.go", + } + if !reflect.DeepEqual(paths(got), want) { + t.Errorf("anchored glob denylist = %v, want %v", paths(got), want) + } +} + +func TestDropPaths_OldPathConsidered(t *testing.T) { + // A rename is dropped by either its new or its pre-rename path, matching + // KeepPaths' two-sided candidate check. + d := Diff{Files: []FileDiff{{Path: "renamed.go", OldPath: "legacy/old.go"}}} + if got := mustDrop(t, d, nil, []string{"legacy"}); len(got.Files) != 0 { + t.Errorf("pre-rename path should be considered; got %v", paths(got)) + } +} + +func TestDropPaths_IsExactInverseOfKeep(t *testing.T) { + // The two share one matcher, so for any pattern set every file must land + // in exactly one side. This is the invariant that keeps them from drifting. + d := sample() + patterns := [][2][]string{ + {{"*.go"}, nil}, + {nil, {"database/seeder"}}, + {{"routes/web.php"}, {"app/Models"}}, + {{"app/**"}, {"tests"}}, + } + for _, p := range patterns { + kept := mustKeep(t, d, p[0], p[1]) + dropped := mustDrop(t, d, p[0], p[1]) + if len(kept.Files)+len(dropped.Files) != len(d.Files) { + t.Errorf("keep(%d)+drop(%d) != input(%d) for %v/%v", + len(kept.Files), len(dropped.Files), len(d.Files), p[0], p[1]) + } + } +} + +func TestDropPaths_AfterKeepExclusionWins(t *testing.T) { + // The pipeline order: KeepPaths narrows, then DropPaths removes. An + // exclusion inside an inclusion must win. + d := Diff{Files: []FileDiff{ + {Path: "internal/cli/root.go"}, + {Path: "internal/diff/filter.go"}, + {Path: "cmd/main.go"}, + }} + kept := mustKeep(t, d, nil, []string{"internal"}) + got := mustDrop(t, kept, nil, []string{"internal/cli"}) + want := []string{"internal/diff/filter.go"} + if !reflect.DeepEqual(paths(got), want) { + t.Errorf("keep-then-drop = %v, want %v", paths(got), want) + } +} + +func TestDropPaths_RecountsLineTotals(t *testing.T) { + d := Diff{Files: []FileDiff{ + {Path: "a.go", Hunks: []Hunk{{Lines: []HunkLine{{Kind: LineAdd}, {Kind: LineDel}}}}}, + {Path: "b.go", Hunks: []Hunk{{Lines: []HunkLine{{Kind: LineAdd}}}}}, + }} + got := mustDrop(t, d, []string{"a.go"}, nil) + if got.AddedLines() != 1 || got.DeletedLines() != 0 { + t.Errorf("counters = +%d/-%d, want +1/-0", got.AddedLines(), got.DeletedLines()) + } +} + +func TestDropPaths_InvalidGlobReturnsError(t *testing.T) { + d := sample() + if _, err := DropPaths(d, []string{"[abc.go"}, nil); err == nil { + t.Fatalf("invalid glob '[abc.go' should return an error") + } +} diff --git a/internal/git/log.go b/internal/git/log.go index de8d1c2..604e407 100644 --- a/internal/git/log.go +++ b/internal/git/log.go @@ -8,20 +8,34 @@ import ( "fmt" "os/exec" "strings" + "time" ) -// CommitMeta is one commit's human-relevant metadata, used by the -// `commitbrief summary` command to attribute logical changes to the -// commit(s) that introduced them. It carries no diff body — the cumulative -// range diff is fetched separately via the Diff() passthrough — only the -// short hash, the author's subject/body (the "commit message" the summary -// is asked to take into account), and the paths the commit touched (so the -// model can map a logical area back to the commit responsible for it). +// CommitMeta is one commit's human-relevant metadata. It carries no diff +// body — patches are fetched separately (via the Diff() passthrough for a +// range, or PatchesFor for a selected commit set) — only the identity, the +// author's subject/body (the "commit message"), and the paths the commit +// touched (so the model can map a logical area back to the commit +// responsible for it). +// +// Two producers fill it, and they populate different subsets: +// +// - RangeCommits (the `commitbrief summary` manifest) sets Short, Subject, +// Body, Files. The identity/date fields stay zero — the manifest never +// needed them. +// - SelectCommits (the commit-level filters) sets every field, because +// author/committer/date matching happens on this struct. type CommitMeta struct { - Short string // abbreviated hash, e.g. "a1b2c3d" - Subject string // first line of the commit message - Body string // remainder of the commit message (may be empty) - Files []string // post-change paths touched by this commit + Hash string // full 40-hex hash; empty for RangeCommits records + Short string // abbreviated hash, e.g. "a1b2c3d" + Author string // author name (%an) + AuthorEmail string // author email (%ae) + Committer string // committer name (%cn) + CommitterEmail string // committer email (%ce) + Date time.Time // author date (%aI), parsed from strict ISO 8601 + Subject string // first line of the commit message + Body string // remainder of the commit message (may be empty) + Files []string // post-change paths touched by this commit } // maxRangeCommits bounds how many commits RangeCommits feeds into a summary diff --git a/internal/git/repo.go b/internal/git/repo.go index bdadc90..e087b39 100644 --- a/internal/git/repo.go +++ b/internal/git/repo.go @@ -14,6 +14,11 @@ const ( OriginRange Origin = "range" OriginBranch Origin = "branch" OriginDiff Origin = "diff" // `commitbrief diff ` passthrough + // OriginFiltered marks a diff assembled from an explicitly selected set + // of commits (the --author / --start-date / --end-date / --text filters). + // Unlike every other origin its content is a CONCATENATION of per-commit + // patches, so the same path may appear as several file entries. + OriginFiltered Origin = "filtered" ) type Diff struct { diff --git a/internal/git/select.go b/internal/git/select.go new file mode 100644 index 0000000..b40e189 --- /dev/null +++ b/internal/git/select.go @@ -0,0 +1,591 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package git + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "sort" + "strconv" + "strings" + "time" +) + +// Commit-level filtering (ADR-0035). +// +// `git diff` has no --author/--since/--until/--grep — those are `git log` +// options — so a filter that selects *commits* cannot ride the normal +// diff-acquisition path. This file implements the alternative: pick the +// matching commits first, then assemble a diff from exactly those commits. +// +// Three phases, each a single `git` invocation: +// +// A. metadata walk — `git log` over the rev range, no patches, so the +// output stays small even on a long history. +// A′. ref-name match — only when Text is set: branches whose NAME matches +// contribute the commits unique to them. +// B. patch fetch — `git show` for exactly the selected hashes. +// +// Date and merge filtering push down to git (unambiguous). Author, committer +// and message matching happen HERE, in Go, deliberately: `git log --author=A +// --grep=B` ORs the two by default and --all-match would then also AND +// multiple --author values together. Doing it ourselves gives the semantics +// users expect — AND across filter kinds, OR within one kind — with no +// dependence on that surprising git behavior. + +const ( + // DefaultMaxCommits bounds how many matching commits are folded into one + // review. A broad filter (`--author me` on a year-old repo) would + // otherwise assemble a diff far past any model's context window. + DefaultMaxCommits = 200 + + // walkLimit bounds phase A itself. Even without patches, an unbounded + // `git log` on a 100k-commit repo is megabytes of metadata for nothing. + // Hitting it sets Selection.WalkTruncated so the caller can say so out + // loud rather than silently reviewing a subset. + walkLimit = 10000 + + // showBatch caps how many hashes go into one `git show` argv, keeping us + // clear of the platform argument-length limit on a large selection. + showBatch = 100 + + // maxRefs bounds the ref list fed to phase A′'s rev-list exclusion set. + maxRefs = 1000 +) + +// CommitFilter is the commit-level narrowing requested on the command line. +// The zero value selects nothing and reports Active() == false, which is how +// the pipeline decides to keep using the ordinary `git diff` path. +type CommitFilter struct { + // Authors / Committers match case-insensitively against BOTH the name and + // the email, so `--author ayse` and `--author ayse@example.com` both work. + // Multiple values are OR'd; the two kinds are AND'd with each other. + Authors []string + Committers []string + + // Since / Until bound the author date. Zero means unbounded. Until is + // expected to already carry the end-of-day time (the CLI expands + // --end-date so the named day is inclusive). + Since time.Time + Until time.Time + + // Text matches the commit subject+body, and additionally the NAME of a + // branch/remote ref — a ref whose short name contains Text contributes + // the commits unique to it (phase A′). + Text string + + // Merges includes merge commits in the selection. Off by default: a + // merge's changes are already carried by the commits it merges, and its + // patch is noise. + Merges bool + + // MaxCommits caps the selection; 0 means DefaultMaxCommits. + MaxCommits int + + // Rev is the revision range to walk, e.g. ["main..HEAD"]. Empty means + // ["HEAD"] — the implicit history walk. + Rev []string +} + +// Active reports whether any *selecting* filter is set. Merges and MaxCommits +// are deliberately excluded: they only modify a selection, they never create +// one, so `--merges` alone must not silently turn a staged review into a +// history walk. The CLI rejects a modifier used on its own. +func (f CommitFilter) Active() bool { + return len(f.Authors) > 0 || + len(f.Committers) > 0 || + !f.Since.IsZero() || + !f.Until.IsZero() || + f.Text != "" +} + +func (f CommitFilter) limit() int { + if f.MaxCommits > 0 { + return f.MaxCommits + } + return DefaultMaxCommits +} + +func (f CommitFilter) revs() []string { + if len(f.Rev) == 0 { + return []string{"HEAD"} + } + return f.Rev +} + +// Selection is the outcome of a commit walk: the matching commits plus enough +// accounting for the caller to report honestly what was and wasn't covered. +type Selection struct { + Commits []CommitMeta // newest first + + // Walked is how many commits phase A inspected. + Walked int + // Truncated is set when more commits matched than MaxCommits allowed. + Truncated bool + // WalkTruncated is set when phase A itself hit walkLimit, meaning older + // history was never inspected at all. + WalkTruncated bool +} + +// Record/field separators as git format escapes. They expand to the same +// RS/US control characters log.go uses, but written as `%x1e`/`%x1f` rather +// than raw bytes: git only treats a --format value as a user format when it +// contains a `%`, so `--format=` is rejected outright as an unknown +// builtin format name. +const ( + fmtRecordSep = "%x1e" + fmtFieldSep = "%x1f" +) + +// selectRecordFormat pins the phase-A per-commit layout. The trailing field +// separator closes the format so the --name-status block git appends lands in +// its own field. +const selectRecordFormat = "--format=" + fmtRecordSep + + "%H" + fmtFieldSep + + "%h" + fmtFieldSep + + "%an" + fmtFieldSep + + "%ae" + fmtFieldSep + + "%cn" + fmtFieldSep + + "%ce" + fmtFieldSep + + "%aI" + fmtFieldSep + + "%s" + fmtFieldSep + + "%b" + fmtFieldSep + +// showRecordFormat emits nothing but the record separator, so a `git show` +// block is the separator followed directly by the patch. +const showRecordFormat = "--format=" + fmtRecordSep + +// selectRecordFields is how many US-separated fields selectRecordFormat plus +// the --name-status block produce. +const selectRecordFields = 10 + +// FilteredDiff selects the commits matching f and returns their concatenated +// patches together with the selection metadata. It is read-only: `git log`, +// `git for-each-ref`, `git rev-list`, `git show`. +func FilteredDiff(ctx context.Context, repoRoot string, f CommitFilter) (Diff, Selection, error) { + sel, err := SelectCommits(ctx, repoRoot, f) + if err != nil { + return Diff{}, Selection{}, err + } + if len(sel.Commits) == 0 { + return Diff{ + Origin: OriginFiltered, + Args: selectionArgs(f, sel), + }, sel, nil + } + content, err := PatchesFor(ctx, repoRoot, sel.Commits) + if err != nil { + return Diff{}, Selection{}, err + } + return Diff{ + Content: content, + Origin: OriginFiltered, + Args: selectionArgs(f, sel), + }, sel, nil +} + +// selectionArgs surfaces what the filter resolved to, for renderers and +// cache-key debug output — mirroring what the Diff*() helpers do with their +// own inputs. +func selectionArgs(f CommitFilter, sel Selection) map[string]string { + return map[string]string{ + "rev": strings.Join(f.revs(), " "), + "commits": strconv.Itoa(len(sel.Commits)), + } +} + +// SelectCommits runs phases A and A′ and returns the matching commits, newest +// first, capped at f.MaxCommits. +func SelectCommits(ctx context.Context, repoRoot string, f CommitFilter) (Selection, error) { + bin, err := exec.LookPath("git") + if err != nil { + return Selection{}, ErrNoGitCLI + } + + walked, err := walkCommits(ctx, bin, repoRoot, f, f.revs()) + if err != nil { + return Selection{}, err + } + sel := Selection{Walked: len(walked), WalkTruncated: len(walked) >= walkLimit} + + matched := make([]CommitMeta, 0, len(walked)) + seen := make(map[string]struct{}, len(walked)) + for _, c := range walked { + if !f.matches(c) { + continue + } + if _, dup := seen[c.Hash]; dup { + continue + } + seen[c.Hash] = struct{}{} + matched = append(matched, c) + } + + // Phase A′: branches whose NAME matches --text contribute their own + // commits, which the rev-range walk above may never have visited. + if f.Text != "" { + fromRefs, refErr := commitsFromMatchingRefs(ctx, bin, repoRoot, f) + if refErr != nil { + return Selection{}, refErr + } + sel.Walked += len(fromRefs) + for _, c := range fromRefs { + if _, dup := seen[c.Hash]; dup { + continue + } + // The ref name already satisfied the text predicate; the + // identity/date filters still apply. + if !f.matchesIdentity(c) || !f.matchesDate(c) { + continue + } + seen[c.Hash] = struct{}{} + matched = append(matched, c) + } + } + + // Newest first, hash as a deterministic tie-break so two commits sharing + // a timestamp never reorder between runs (which would churn the cache key). + sort.SliceStable(matched, func(i, j int) bool { + if matched[i].Date.Equal(matched[j].Date) { + return matched[i].Hash < matched[j].Hash + } + return matched[i].Date.After(matched[j].Date) + }) + + if limit := f.limit(); len(matched) > limit { + matched = matched[:limit] + sel.Truncated = true + } + sel.Commits = matched + return sel, nil +} + +// matches applies the full predicate: identity AND date AND text. +func (f CommitFilter) matches(c CommitMeta) bool { + return f.matchesIdentity(c) && f.matchesDate(c) && f.matchesText(c) +} + +func (f CommitFilter) matchesIdentity(c CommitMeta) bool { + if !matchAny(f.Authors, c.Author, c.AuthorEmail) { + return false + } + return matchAny(f.Committers, c.Committer, c.CommitterEmail) +} + +// matchesDate re-checks the bounds in Go even though they were pushed down to +// git. `git log --since` compares against the COMMITTER date while our filter +// is documented against the AUTHOR date, so the pushdown is a cheap +// pre-narrowing and this is the authoritative check. +func (f CommitFilter) matchesDate(c CommitMeta) bool { + if c.Date.IsZero() { + return true + } + if !f.Since.IsZero() && c.Date.Before(f.Since) { + return false + } + if !f.Until.IsZero() && c.Date.After(f.Until) { + return false + } + return true +} + +func (f CommitFilter) matchesText(c CommitMeta) bool { + if f.Text == "" { + return true + } + needle := strings.ToLower(f.Text) + return strings.Contains(strings.ToLower(c.Subject), needle) || + strings.Contains(strings.ToLower(c.Body), needle) +} + +// matchAny reports whether any needle is a case-insensitive substring of any +// haystack. An empty needle list means "unconstrained" and matches everything. +func matchAny(needles []string, haystacks ...string) bool { + if len(needles) == 0 { + return true + } + for _, n := range needles { + n = strings.ToLower(strings.TrimSpace(n)) + if n == "" { + continue + } + for _, h := range haystacks { + if strings.Contains(strings.ToLower(h), n) { + return true + } + } + } + return false +} + +// walkCommits is phase A: metadata only, no patches. +func walkCommits(ctx context.Context, bin, repoRoot string, f CommitFilter, revs []string) ([]CommitMeta, error) { + args := []string{"log", "--no-color", "--name-status", selectRecordFormat, + fmt.Sprintf("-n%d", walkLimit)} + args = append(args, dateArgs(f)...) + if !f.Merges { + args = append(args, "--no-merges") + } + args = append(args, revs...) + + out, err := runGit(ctx, bin, repoRoot, args) + if err != nil { + return nil, err + } + return parseSelectCommits(out), nil +} + +// hydrateCommits fetches metadata for an explicit hash list (phase A′'s +// output), using --no-walk so git prints exactly those commits. +func hydrateCommits(ctx context.Context, bin, repoRoot string, hashes []string) ([]CommitMeta, error) { + if len(hashes) == 0 { + return nil, nil + } + var all []CommitMeta + for _, batch := range chunk(hashes, showBatch) { + args := []string{"log", "--no-color", "--no-walk", "--name-status", selectRecordFormat} + args = append(args, batch...) + out, err := runGit(ctx, bin, repoRoot, args) + if err != nil { + return nil, err + } + all = append(all, parseSelectCommits(out)...) + } + return all, nil +} + +// commitsFromMatchingRefs is phase A′. Refs whose SHORT NAME contains the text +// contribute the commits that exist on them and on no other ref — the closest +// git can get to "what happened on that branch". +// +// Best-effort by nature: a squash- or rebase-merged branch no longer owns its +// commits, so this finds nothing for it. That is a property of git history, +// not a bug here, and the docs say so. +func commitsFromMatchingRefs(ctx context.Context, bin, repoRoot string, f CommitFilter) ([]CommitMeta, error) { + refs, err := listRefs(ctx, bin, repoRoot) + if err != nil { + // A repo with no refs at all (fresh init) is not an error condition + // for the caller — it just means no branch matched. + return nil, nil + } + needle := strings.ToLower(f.Text) + var matching, others []string + for _, r := range refs { + if strings.Contains(strings.ToLower(r), needle) { + matching = append(matching, r) + } else { + others = append(others, r) + } + } + if len(matching) == 0 { + return nil, nil + } + if len(others) > maxRefs { + others = others[:maxRefs] + } + + args := []string{"rev-list", fmt.Sprintf("-n%d", f.limit())} + args = append(args, dateArgs(f)...) + if !f.Merges { + args = append(args, "--no-merges") + } + args = append(args, matching...) + if len(others) > 0 { + args = append(args, "--not") + args = append(args, others...) + } + + out, err := runGit(ctx, bin, repoRoot, args) + if err != nil { + return nil, err + } + var hashes []string + for _, line := range strings.Split(out, "\n") { + if h := strings.TrimSpace(line); h != "" { + hashes = append(hashes, h) + } + } + return hydrateCommits(ctx, bin, repoRoot, hashes) +} + +// listRefs returns the short names of every local branch and remote-tracking +// branch. Tags are excluded on purpose: `--text` is about branch names, and a +// tag is not a line of work. +func listRefs(ctx context.Context, bin, repoRoot string) ([]string, error) { + out, err := runGit(ctx, bin, repoRoot, []string{ + "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes", + }) + if err != nil { + return nil, err + } + var refs []string + for _, line := range strings.Split(out, "\n") { + name := strings.TrimSpace(line) + // `origin/HEAD` is a symbolic alias, not a branch of its own. + if name == "" || strings.HasSuffix(name, "/HEAD") { + continue + } + refs = append(refs, name) + } + return refs, nil +} + +// dateArgs pushes the date bounds down to git. `git log --since/--until` +// compares the committer date, which is a superset filter for our +// author-date semantics in every ordinary history and a cheap pre-narrowing +// in the rest; matchesDate does the authoritative check. +func dateArgs(f CommitFilter) []string { + var args []string + if !f.Since.IsZero() { + args = append(args, "--since="+f.Since.Format(gitDateLayout)) + } + if !f.Until.IsZero() { + args = append(args, "--until="+f.Until.Format(gitDateLayout)) + } + return args +} + +// gitDateLayout is the unambiguous form git parses without approxidate +// guesswork, including the offset so the caller's local midnight is preserved. +const gitDateLayout = "2006-01-02T15:04:05-07:00" + +// PatchesFor is phase B: the concatenated patches of the given commits, in the +// order given. +// +// `git show` is used rather than `git log -p` because we need per-commit +// framing we control. A raw `git log -p` interleaves the commit message +// indented by four spaces, and internal/diff's parser reads a leading-space +// line as hunk CONTEXT — a message body would be silently absorbed into the +// previous hunk. Emitting a bare record separator as the format and slicing +// each block from its first `diff --git` removes that whole class of problem. +// +// `-m --first-parent` is what makes a merge commit produce a patch at all +// (git shows nothing for a merge otherwise); it is a no-op on ordinary +// commits. +func PatchesFor(ctx context.Context, repoRoot string, commits []CommitMeta) (string, error) { + if len(commits) == 0 { + return "", nil + } + bin, err := exec.LookPath("git") + if err != nil { + return "", ErrNoGitCLI + } + hashes := make([]string, 0, len(commits)) + for _, c := range commits { + if c.Hash != "" { + hashes = append(hashes, c.Hash) + } + } + + var sb strings.Builder + for _, batch := range chunk(hashes, showBatch) { + args := []string{"show", "--no-color", "--no-ext-diff", "-m", "--first-parent", + showRecordFormat} + args = append(args, batch...) + out, err := runGit(ctx, bin, repoRoot, args) + if err != nil { + return "", err + } + for _, block := range strings.Split(out, logRecordSep) { + patch := patchBody(block) + if patch == "" { + continue + } + sb.WriteString(patch) + if !strings.HasSuffix(patch, "\n") { + sb.WriteString("\n") + } + } + } + return sb.String(), nil +} + +// patchBody slices a `git show` block from its first `diff --git` line to the +// end, dropping the format/record preamble. A commit with no textual change +// (an empty commit, or a merge that resolved to nothing) yields "". +func patchBody(block string) string { + const marker = "diff --git " + if strings.HasPrefix(block, marker) { + return block + } + if i := strings.Index(block, "\n"+marker); i >= 0 { + return block[i+1:] + } + return "" +} + +// parseSelectCommits turns phase A / A′ output into CommitMeta records. Split +// on RS yields one block per commit (the leading element, before the first RS, +// is empty); each block splits on US into the fixed field list plus the +// trailing --name-status block. Malformed blocks are skipped rather than +// aborting — a single unparseable record must not fail the whole run. +func parseSelectCommits(out string) []CommitMeta { + blocks := strings.Split(out, logRecordSep) + commits := make([]CommitMeta, 0, len(blocks)) + for _, block := range blocks { + if strings.TrimSpace(block) == "" { + continue + } + fields := strings.SplitN(block, logFieldSep, selectRecordFields) + if len(fields) < selectRecordFields-1 { + continue + } + hash := strings.TrimSpace(fields[0]) + if hash == "" { + continue + } + c := CommitMeta{ + Hash: hash, + Short: strings.TrimSpace(fields[1]), + Author: strings.TrimSpace(fields[2]), + AuthorEmail: strings.TrimSpace(fields[3]), + Committer: strings.TrimSpace(fields[4]), + CommitterEmail: strings.TrimSpace(fields[5]), + Subject: strings.TrimSpace(fields[7]), + Body: strings.TrimSpace(fields[8]), + } + if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(fields[6])); err == nil { + c.Date = ts + } + if len(fields) == selectRecordFields { + c.Files = parseNameStatus(fields[9]) + } + commits = append(commits, c) + } + return commits +} + +func chunk(items []string, size int) [][]string { + if len(items) == 0 { + return nil + } + var out [][]string + for i := 0; i < len(items); i += size { + end := i + size + if end > len(items) { + end = len(items) + } + out = append(out, items[i:end]) + } + return out +} + +// runGit is the single exec seam for this file, mirroring CLIRepo.run's error +// shape so a git failure reads the same wherever it surfaces. +func runGit(ctx context.Context, bin, repoRoot string, args []string) (string, error) { + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = repoRoot + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) + } + return stdout.String(), nil +} diff --git a/internal/git/select_test.go b/internal/git/select_test.go new file mode 100644 index 0000000..b9c4912 --- /dev/null +++ b/internal/git/select_test.go @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package git + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// The selection layer is pure git-CLI interop, so these fixtures are built +// with the real `git` binary rather than go-git: only the CLI lets us pin +// author identity, committer identity and author date per commit, and build a +// genuine merge commit — all of which are exactly what we need to filter on. + +type filterRepo struct { + dir string +} + +func (r *filterRepo) git(t *testing.T, env []string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = r.dir + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} + +// commitAs writes a file and commits it as the given identity on the given +// author date (YYYY-MM-DD). Committer identity defaults to the author unless +// committerName is non-empty. +func (r *filterRepo) commitAs(t *testing.T, name, email, date, file, msg, committerName string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(r.dir, file)), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(r.dir, file), []byte(msg+"\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", file, err) + } + stamp := date + "T12:00:00+00:00" + cName, cEmail := name, email + if committerName != "" { + cName, cEmail = committerName, strings.ToLower(committerName)+"@example.com" + } + env := []string{ + "GIT_AUTHOR_NAME=" + name, + "GIT_AUTHOR_EMAIL=" + email, + "GIT_AUTHOR_DATE=" + stamp, + "GIT_COMMITTER_NAME=" + cName, + "GIT_COMMITTER_EMAIL=" + cEmail, + "GIT_COMMITTER_DATE=" + stamp, + } + r.git(t, nil, "add", file) + r.git(t, env, "commit", "-m", msg) +} + +// newFilterRepo builds a history purpose-made for the filter tests: +// +// main: C1(alice 2026-01-10) — C2(bob 2026-02-10) — C3(alice 2026-03-10, committed by carol) +// payments: branches off C1, carries C4(bob 2026-02-20) — merged back as M +// +// so every filter kind has both a hit and a miss to prove against. +func newFilterRepo(t *testing.T) *filterRepo { + t.Helper() + requireGitCLI(t) + r := &filterRepo{dir: t.TempDir()} + r.git(t, nil, "init", "-q", "-b", "main") + r.git(t, nil, "config", "user.name", "Test") + r.git(t, nil, "config", "user.email", "test@example.com") + r.git(t, nil, "config", "commit.gpgsign", "false") + + r.commitAs(t, "Alice", "alice@example.com", "2026-01-10", "a.txt", "feat: add invoice calc", "") + r.commitAs(t, "Bob", "bob@example.com", "2026-02-10", "b.txt", "fix: token refresh", "") + + // Feature branch off the first commit, so its commit is unique to it. + r.git(t, nil, "checkout", "-q", "-b", "payments/stripe", "HEAD~1") + r.commitAs(t, "Bob", "bob@example.com", "2026-02-20", "c.txt", "chore: bump sdk", "") + r.git(t, nil, "checkout", "-q", "main") + + r.commitAs(t, "Alice", "alice@example.com", "2026-03-10", "d.txt", "docs: readme", "Carol") + return r +} + +func (r *filterRepo) merge(t *testing.T, ref string) { + t.Helper() + env := []string{ + "GIT_AUTHOR_NAME=Dave", "GIT_AUTHOR_EMAIL=dave@example.com", + "GIT_AUTHOR_DATE=2026-04-01T12:00:00+00:00", + "GIT_COMMITTER_NAME=Dave", "GIT_COMMITTER_EMAIL=dave@example.com", + "GIT_COMMITTER_DATE=2026-04-01T12:00:00+00:00", + } + r.git(t, env, "merge", "--no-ff", "-m", "Merge branch "+ref, ref) +} + +func day(s string) time.Time { + ts, err := time.Parse("2006-01-02", s) + if err != nil { + panic(err) + } + return ts +} + +// endOfDay mirrors what the CLI does to --end-date so the named day is +// inclusive. +func endOfDay(s string) time.Time { + return day(s).Add(24*time.Hour - time.Second) +} + +func subjects(sel Selection) []string { + out := make([]string, 0, len(sel.Commits)) + for _, c := range sel.Commits { + out = append(out, c.Subject) + } + return out +} + +func mustSelect(t *testing.T, dir string, f CommitFilter) Selection { + t.Helper() + sel, err := SelectCommits(context.Background(), dir, f) + if err != nil { + t.Fatalf("SelectCommits: %v", err) + } + return sel +} + +func TestCommitFilterActive(t *testing.T) { + cases := []struct { + name string + f CommitFilter + want bool + }{ + {"zero", CommitFilter{}, false}, + {"author", CommitFilter{Authors: []string{"alice"}}, true}, + {"committer", CommitFilter{Committers: []string{"carol"}}, true}, + {"since", CommitFilter{Since: day("2026-01-01")}, true}, + {"until", CommitFilter{Until: day("2026-01-01")}, true}, + {"text", CommitFilter{Text: "invoice"}, true}, + // Modifiers never activate on their own — otherwise `--merges` would + // silently turn a staged review into a history walk. + {"merges only", CommitFilter{Merges: true}, false}, + {"max only", CommitFilter{MaxCommits: 5}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.f.Active(); got != tc.want { + t.Fatalf("Active() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestSelectCommitsByAuthor(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Authors: []string{"alice"}}) + want := []string{"docs: readme", "feat: add invoice calc"} + if got := subjects(sel); !equalStrings(got, want) { + t.Fatalf("subjects = %v, want %v", got, want) + } +} + +func TestSelectCommitsByAuthorEmail(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Authors: []string{"bob@example.com"}}) + if got := subjects(sel); !equalStrings(got, []string{"fix: token refresh"}) { + t.Fatalf("subjects = %v, want [fix: token refresh]", got) + } +} + +func TestSelectCommitsMultipleAuthorsAreOred(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Authors: []string{"alice", "bob"}}) + if len(sel.Commits) != 3 { + t.Fatalf("expected all 3 main-branch commits, got %v", subjects(sel)) + } +} + +func TestSelectCommitsAuthorAndDateAreAnded(t *testing.T) { + r := newFilterRepo(t) + // Alice has commits in January and March; the window keeps only January. + sel := mustSelect(t, r.dir, CommitFilter{ + Authors: []string{"alice"}, + Since: day("2026-01-01"), + Until: endOfDay("2026-01-31"), + }) + if got := subjects(sel); !equalStrings(got, []string{"feat: add invoice calc"}) { + t.Fatalf("subjects = %v, want [feat: add invoice calc]", got) + } +} + +// The named --end-date day must be included. git's bare `--until=` stops +// at that day's midnight, which would silently drop it. +func TestSelectCommitsEndDateIsInclusive(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Until: endOfDay("2026-02-10")}) + if got := subjects(sel); !equalStrings(got, []string{"fix: token refresh", "feat: add invoice calc"}) { + t.Fatalf("subjects = %v, want the Jan + Feb 10 commits", got) + } +} + +func TestSelectCommitsStartDateIsInclusive(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Since: day("2026-03-10")}) + if got := subjects(sel); !equalStrings(got, []string{"docs: readme"}) { + t.Fatalf("subjects = %v, want [docs: readme]", got) + } +} + +func TestSelectCommitsByCommitter(t *testing.T) { + r := newFilterRepo(t) + // Carol committed only the docs commit (authored by Alice). + sel := mustSelect(t, r.dir, CommitFilter{Committers: []string{"carol"}}) + if got := subjects(sel); !equalStrings(got, []string{"docs: readme"}) { + t.Fatalf("subjects = %v, want [docs: readme]", got) + } +} + +func TestSelectCommitsByMessageText(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Text: "invoice"}) + if got := subjects(sel); !equalStrings(got, []string{"feat: add invoice calc"}) { + t.Fatalf("subjects = %v, want [feat: add invoice calc]", got) + } +} + +func TestSelectCommitsTextIsCaseInsensitive(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Text: "INVOICE"}) + if len(sel.Commits) != 1 { + t.Fatalf("case-insensitive match failed, got %v", subjects(sel)) + } +} + +// --text also matches BRANCH NAMES: `payments/stripe` is not on HEAD, and its +// commit's message says nothing about payments, yet it must be selected. +func TestSelectCommitsByBranchName(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Text: "payments"}) + if got := subjects(sel); !equalStrings(got, []string{"chore: bump sdk"}) { + t.Fatalf("subjects = %v, want [chore: bump sdk] from the payments/stripe branch", got) + } +} + +func TestSelectCommitsBranchNameAndMessageUnion(t *testing.T) { + r := newFilterRepo(t) + // "token" hits a message; add a branch whose name also contains it. + r.git(t, nil, "branch", "token-work", "HEAD") + sel := mustSelect(t, r.dir, CommitFilter{Text: "token"}) + if len(sel.Commits) == 0 { + t.Fatal("expected at least the message match") + } + found := false + for _, s := range subjects(sel) { + if s == "fix: token refresh" { + found = true + } + } + if !found { + t.Fatalf("message match missing from %v", subjects(sel)) + } +} + +func TestSelectCommitsBranchNameFiltersStillApply(t *testing.T) { + r := newFilterRepo(t) + // The payments/stripe commit is Bob's; asking for Alice's must exclude it + // even though the branch name matches. + sel := mustSelect(t, r.dir, CommitFilter{Text: "payments", Authors: []string{"alice"}}) + if len(sel.Commits) != 0 { + t.Fatalf("identity filter must still apply to ref matches, got %v", subjects(sel)) + } +} + +func TestSelectCommitsExcludesMergesByDefault(t *testing.T) { + r := newFilterRepo(t) + r.merge(t, "payments/stripe") + sel := mustSelect(t, r.dir, CommitFilter{Authors: []string{"dave"}}) + if len(sel.Commits) != 0 { + t.Fatalf("merge commit should be excluded by default, got %v", subjects(sel)) + } +} + +func TestSelectCommitsIncludesMergesWhenAsked(t *testing.T) { + r := newFilterRepo(t) + r.merge(t, "payments/stripe") + sel := mustSelect(t, r.dir, CommitFilter{Authors: []string{"dave"}, Merges: true}) + if len(sel.Commits) != 1 { + t.Fatalf("expected the merge commit, got %v", subjects(sel)) + } +} + +func TestSelectCommitsTruncatesAtMaxCommits(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Since: day("2026-01-01"), MaxCommits: 2}) + if !sel.Truncated { + t.Fatal("expected Truncated") + } + if len(sel.Commits) != 2 { + t.Fatalf("expected 2 commits, got %d", len(sel.Commits)) + } + // Newest first, so the cap keeps the most recent work. + if sel.Commits[0].Subject != "docs: readme" { + t.Fatalf("expected newest-first ordering, got %v", subjects(sel)) + } +} + +func TestSelectCommitsNoMatchIsNotAnError(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Authors: []string{"nobody"}}) + if len(sel.Commits) != 0 { + t.Fatalf("expected no matches, got %v", subjects(sel)) + } + if sel.Truncated { + t.Fatal("empty selection must not report truncation") + } +} + +func TestSelectCommitsHonorsExplicitRange(t *testing.T) { + r := newFilterRepo(t) + // Only the newest commit is in HEAD~1..HEAD, so Alice's January commit is + // out of range even though she matches. + sel := mustSelect(t, r.dir, CommitFilter{Authors: []string{"alice"}, Rev: []string{"HEAD~1..HEAD"}}) + if got := subjects(sel); !equalStrings(got, []string{"docs: readme"}) { + t.Fatalf("subjects = %v, want [docs: readme]", got) + } +} + +func TestSelectCommitsPopulatesMetadata(t *testing.T) { + r := newFilterRepo(t) + sel := mustSelect(t, r.dir, CommitFilter{Text: "invoice"}) + if len(sel.Commits) != 1 { + t.Fatalf("expected 1 commit, got %d", len(sel.Commits)) + } + c := sel.Commits[0] + if len(c.Hash) != 40 { + t.Errorf("Hash = %q, want a 40-hex sha", c.Hash) + } + if c.Short == "" { + t.Error("Short is empty") + } + if c.Author != "Alice" || c.AuthorEmail != "alice@example.com" { + t.Errorf("author = %q <%q>, want Alice ", c.Author, c.AuthorEmail) + } + if c.Committer != "Alice" { + t.Errorf("committer = %q, want Alice", c.Committer) + } + if c.Date.UTC().Format("2006-01-02") != "2026-01-10" { + t.Errorf("date = %v, want 2026-01-10", c.Date) + } + if !equalStrings(c.Files, []string{"a.txt"}) { + t.Errorf("files = %v, want [a.txt]", c.Files) + } +} + +func TestFilteredDiffConcatenatesPerCommitPatches(t *testing.T) { + r := newFilterRepo(t) + d, sel, err := FilteredDiff(context.Background(), r.dir, CommitFilter{Authors: []string{"alice"}}) + if err != nil { + t.Fatalf("FilteredDiff: %v", err) + } + if d.Origin != OriginFiltered { + t.Errorf("origin = %q, want %q", d.Origin, OriginFiltered) + } + if d.Args["commits"] != "2" { + t.Errorf("args[commits] = %q, want 2", d.Args["commits"]) + } + if len(sel.Commits) != 2 { + t.Fatalf("expected 2 commits, got %d", len(sel.Commits)) + } + // Both commits' files must appear, and nothing from Bob's. + if !strings.Contains(d.Content, "d.txt") || !strings.Contains(d.Content, "a.txt") { + t.Errorf("expected both Alice patches, got:\n%s", d.Content) + } + if strings.Contains(d.Content, "b.txt") { + t.Errorf("Bob's commit leaked into the diff:\n%s", d.Content) + } + // No `git show` preamble may survive — the content starts at a patch. + if !strings.HasPrefix(d.Content, "diff --git ") { + t.Errorf("content should start with a patch header, got:\n%.120s", d.Content) + } + if strings.Contains(d.Content, "Author:") || strings.Contains(d.Content, "commit "+sel.Commits[0].Hash) { + t.Errorf("commit headers leaked into the patch text:\n%s", d.Content) + } +} + +func TestFilteredDiffEmptySelectionYieldsEmptyDiff(t *testing.T) { + r := newFilterRepo(t) + d, sel, err := FilteredDiff(context.Background(), r.dir, CommitFilter{Authors: []string{"nobody"}}) + if err != nil { + t.Fatalf("FilteredDiff: %v", err) + } + if !d.Empty() { + t.Errorf("expected an empty diff, got %q", d.Content) + } + if len(sel.Commits) != 0 { + t.Errorf("expected no commits, got %d", len(sel.Commits)) + } +} + +func TestPatchBodyStripsPreamble(t *testing.T) { + cases := []struct { + name string + block string + want string + }{ + {"already a patch", "diff --git a/x b/x\n@@\n", "diff --git a/x b/x\n@@\n"}, + {"leading blank", "\ndiff --git a/x b/x\n", "diff --git a/x b/x\n"}, + {"no patch at all", "\n", ""}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := patchBody(tc.block); got != tc.want { + t.Fatalf("patchBody(%q) = %q, want %q", tc.block, got, tc.want) + } + }) + } +} + +func TestParseSelectCommitsSkipsMalformed(t *testing.T) { + good := logRecordSep + + strings.Join([]string{ + "1111111111111111111111111111111111111111", "1111111", + "Alice", "alice@example.com", "Alice", "alice@example.com", + "2026-01-10T12:00:00+00:00", "feat: x", "body", + }, logFieldSep) + logFieldSep + "\nM\tx.go\n" + out := logRecordSep + "deadbeef" + good // first record has no field separators + + got := parseSelectCommits(out) + if len(got) != 1 { + t.Fatalf("expected 1 well-formed record, got %d: %#v", len(got), got) + } + if got[0].Author != "Alice" || got[0].Date.IsZero() || !equalStrings(got[0].Files, []string{"x.go"}) { + t.Fatalf("record not parsed as expected: %#v", got[0]) + } +} + +func TestChunk(t *testing.T) { + got := chunk([]string{"a", "b", "c", "d", "e"}, 2) + if len(got) != 3 || len(got[0]) != 2 || len(got[2]) != 1 { + t.Fatalf("chunk mismatch: %#v", got) + } + if chunk(nil, 2) != nil { + t.Fatal("chunk(nil) should be nil") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index b9fe3af..031e713 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -38,6 +38,14 @@ guard.secrets.aborted_user: "aborted: pre-send secret scanner" guard.secrets.aborted_non_interactive: "Aborted (non-interactive); pass --allow-secrets to override." guard.secret_patterns.invalid: "Invalid guard.secret_patterns entry: %s" filter.glob.invalid: "Invalid --file/--dir glob pattern: %s" +filter.date.invalid: "Invalid %s value %q; use the YYYY-MM-DD form (e.g. 2026-03-01)." +filter.date.range_inverted: "--start-date %s is after --end-date %s; the range selects nothing." +filter.commit.scope_conflict: "%s select commits, so they can't be combined with --staged or --unstaged (neither has commits yet)." +filter.commit.unsupported_range: "Can't derive a commit range from %q; commit filters need plain refs (HEAD, main..feature, HEAD~3 HEAD), not flags or a `--` pathspec." +filter.commit.modifier_only: "--merges and --max-commits only shape a commit-filtered review; add one of %s." +filter.commit.selected: "%d commit(s) matched (of %d walked)" +filter.commit.truncated: "⚠ more commits matched than --max-commits %d allows; reviewing the most recent ones only." +filter.commit.walk_truncated: "⚠ the history walk hit its limit; older commits were never inspected. Narrow the range or add --start-date." guard.injection.warning: "⚠ Possible prompt-injection phrasing in %s (line(s) %s); it joins the system prompt. Review it — continuing (warn only)." cost.estimate: "⚠ Estimated cost: $%.4f (threshold: $%.4f)" @@ -182,6 +190,7 @@ remote.self_pr_blocked: "remote pr: you are the author of this PR; GitHub does n remote.too_volatile: "remote pr: the PR head changed twice during review; aborted — try again once it settles." remote.repo_required: "remote pr: not inside a git repository and --repo was not given; pass --repo owner/repo." remote.output_flags: "remote pr: --json / --markdown / --output / --copy / --compact don't apply when posting to GitHub. Add --no-post to review the PR locally and print instead." +remote.commit_filter_unsupported: "remote pr: %s can't be used here — the PR diff comes from `gh pr diff`, not from local git history. Use `commitbrief diff ` with the filters instead." remote.fail_on_ignored: "ℹ --fail-on is ignored in remote-pr mode; the GitHub verdict is controlled by --request-changes-on." remote.no_post_request_changes_ignored: "ℹ --request-changes-on is ignored with --no-post (no GitHub verdict is submitted)." remote.no_post_context_ignored: "ℹ --with-context is ignored with --no-post (it would read the local tree, not the PR's branch)." @@ -221,7 +230,8 @@ commit.type_invalid: "invalid commit type %q; valid types: %s" commit.generate_invalid: "--generate must be a positive number." commit.generate_too_many: "--generate is capped at %d." commit.flag_conflict_output: "commit can't be combined with --json, --markdown, or --output." -commit.flag_conflict_filter: "commit can't be combined with --file or --dir (the message would describe a subset but commit the whole index)." +commit.flag_conflict_filter: "commit can't be combined with --file, --dir, --exclude-file or --exclude-dir (the message would describe a subset but commit the whole index)." +commit.flag_conflict_commit_filter: "commit can't be combined with %s; it describes the staged index, which has no commits yet." summary.flag_conflict_format: "summary emits plain text; it can't be combined with --json or --markdown (use --output to write to a file)." summary.flag_conflict_review: "summary produces no findings; it can't be combined with --suggest-commit, --fail-on, or --min-severity." diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index 0daccbb..65fe098 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -38,6 +38,14 @@ guard.secrets.aborted_user: "iptal edildi: pre-send secret scanner" guard.secrets.aborted_non_interactive: "İptal edildi (etkileşimsiz); zorlamak için --allow-secrets kullanın." guard.secret_patterns.invalid: "Geçersiz guard.secret_patterns girdisi: %s" filter.glob.invalid: "Geçersiz --file/--dir glob deseni: %s" +filter.date.invalid: "Geçersiz %s değeri %q; YYYY-AA-GG biçimini kullanın (örn. 2026-03-01)." +filter.date.range_inverted: "--start-date %s, --end-date %s tarihinden sonra; bu aralık hiçbir şey seçmez." +filter.commit.scope_conflict: "%s commit seçer; bu yüzden --staged veya --unstaged ile birlikte kullanılamaz (ikisinin de henüz commit'i yok)." +filter.commit.unsupported_range: "%q ifadesinden commit aralığı çıkarılamıyor; commit filtreleri düz ref bekler (HEAD, main..feature, HEAD~3 HEAD) — flag ya da `--` pathspec değil." +filter.commit.modifier_only: "--merges ve --max-commits yalnızca commit filtreli bir review'ı biçimlendirir; şunlardan birini ekleyin: %s." +filter.commit.selected: "%d commit eşleşti (%d commit tarandı)" +filter.commit.truncated: "⚠ eşleşen commit sayısı --max-commits %d sınırını aşıyor; yalnızca en yeniler inceleniyor." +filter.commit.walk_truncated: "⚠ geçmiş taraması sınıra ulaştı; daha eski commitler hiç incelenmedi. Aralığı daraltın ya da --start-date ekleyin." guard.injection.warning: "⚠ %s içinde olası prompt-injection ifadesi (%s. satır); sistem promptuna ekleniyor. Gözden geçirin — devam ediliyor (yalnızca uyarı)." cost.estimate: "⚠ Tahmini maliyet: $%.4f (eşik: $%.4f)" @@ -180,6 +188,7 @@ remote.self_pr_blocked: "remote pr: bu PR'ın yazarı sizsiniz; GitHub kendi PR' remote.too_volatile: "remote pr: review sırasında PR head'i iki kez değişti; iptal edildi — sakinleşince tekrar deneyin." remote.repo_required: "remote pr: bir git deposu içinde değilsiniz ve --repo verilmedi; --repo owner/repo geçin." remote.output_flags: "remote pr: GitHub'a gönderirken --json / --markdown / --output / --copy / --compact geçerli değil. PR'ı lokal review edip basmak için --no-post ekleyin." +remote.commit_filter_unsupported: "remote pr: %s burada kullanılamaz — PR diff'i lokal git geçmişinden değil `gh pr diff` çıktısından gelir. Bunun yerine filtreleri `commitbrief diff ` ile kullanın." remote.fail_on_ignored: "ℹ remote-pr modunda --fail-on yok sayılır; GitHub verdict'i --request-changes-on ile belirlenir." remote.no_post_request_changes_ignored: "ℹ --no-post ile --request-changes-on yok sayılır (GitHub verdict'i gönderilmez)." remote.no_post_context_ignored: "ℹ --no-post ile --with-context yok sayılır (PR'ın branch'ini değil lokal ağacı okurdu)." @@ -219,7 +228,8 @@ commit.type_invalid: "geçersiz commit tipi %q; geçerli tipler: %s" commit.generate_invalid: "--generate pozitif bir sayı olmalı." commit.generate_too_many: "--generate en fazla %d olabilir." commit.flag_conflict_output: "commit; --json, --markdown veya --output ile birlikte kullanılamaz." -commit.flag_conflict_filter: "commit; --file veya --dir ile birlikte kullanılamaz (mesaj bir alt kümeyi anlatırken commit tüm index'i kapsar)." +commit.flag_conflict_filter: "commit; --file, --dir, --exclude-file veya --exclude-dir ile birlikte kullanılamaz (mesaj bir alt kümeyi anlatırken commit tüm index'i kapsar)." +commit.flag_conflict_commit_filter: "commit; %s ile birlikte kullanılamaz; staged index'i anlatır ve orada henüz commit yoktur." summary.flag_conflict_format: "summary düz metin üretir; --json veya --markdown ile birlikte kullanılamaz (dosyaya yazmak için --output kullanın)." summary.flag_conflict_review: "summary bulgu üretmez; --suggest-commit, --fail-on veya --min-severity ile birlikte kullanılamaz." diff --git a/internal/render/json.go b/internal/render/json.go index 8b88d65..252e4e1 100644 --- a/internal/render/json.go +++ b/internal/render/json.go @@ -68,6 +68,13 @@ type jsonMeta struct { // live-call-only (a cache replay reports neither). RetryCount int `json:"retry_count,omitempty"` DegradeReason string `json:"degrade_reason,omitempty"` + + // Commit-filter accounting (ADR-0035). Same omitempty discipline again: + // emitted only when the commit-level filters selected a commit set, so a + // staged/unstaged/range review's meta block is byte-for-byte what schema + // v1 always produced. It tells a consumer how many commits' patches the + // reviewed diff was assembled from. + FilteredCommits int `json:"filtered_commits,omitempty"` } type jsonUsage struct { @@ -96,17 +103,18 @@ func JSON(w io.Writer, p Payload) error { Content: content, Findings: findings, Meta: jsonMeta{ - Provider: p.Meta.Provider, - Model: p.Meta.Model, - Lang: p.Meta.Lang, - Cost: p.Meta.Cost, - LatencyMS: p.Meta.Latency.Milliseconds(), - Cached: p.Meta.Cached, - Timestamp: p.Meta.Timestamp, - Baselined: p.Meta.Baselined, - Suppressed: p.Meta.Suppressed, - RetryCount: p.Meta.Retries, - DegradeReason: p.Meta.DegradeReason, + Provider: p.Meta.Provider, + Model: p.Meta.Model, + Lang: p.Meta.Lang, + Cost: p.Meta.Cost, + LatencyMS: p.Meta.Latency.Milliseconds(), + Cached: p.Meta.Cached, + Timestamp: p.Meta.Timestamp, + Baselined: p.Meta.Baselined, + Suppressed: p.Meta.Suppressed, + RetryCount: p.Meta.Retries, + DegradeReason: p.Meta.DegradeReason, + FilteredCommits: p.Meta.FilteredCommits, Usage: jsonUsage{ InputTokens: p.Meta.Usage.InputTokens, OutputTokens: p.Meta.Usage.OutputTokens, diff --git a/internal/render/render.go b/internal/render/render.go index f03fa41..66d6a32 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -92,4 +92,10 @@ type Meta struct { // cache replay, which made no provider call. Retries int DegradeReason string + + // FilteredCommits is how many commits the commit-level filters selected + // (ADR-0035), i.e. how many commits' patches make up the reviewed diff. + // Zero — and therefore omitted from JSON — for every ordinary + // staged/unstaged/range review, which has no commit set to report. + FilteredCommits int } diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 313b99a..f878298 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -552,3 +552,40 @@ func TestFormatDurationBuckets(t *testing.T) { } } } + +func TestJSONFilteredCommitsOmittedWhenZero(t *testing.T) { + // A staged/unstaged/range review has no commit set, so the meta block must + // stay byte-for-byte what schema v1 always produced. + p := samplePayload() + var w bytes.Buffer + if err := JSON(&w, p); err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(w.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if _, ok := doc["meta"].(map[string]any)["filtered_commits"]; ok { + t.Error("filtered_commits must be omitted when zero (schema-v1 byte stability)") + } +} + +func TestJSONFilteredCommitsPresentWhenSet(t *testing.T) { + p := samplePayload() + p.Meta.FilteredCommits = 7 + var w bytes.Buffer + if err := JSON(&w, p); err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(w.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if got := doc["meta"].(map[string]any)["filtered_commits"]; got != float64(7) { + t.Errorf("filtered_commits = %v, want 7", got) + } + // Additive optional field — the schema version must not move. + if doc["schema"] != float64(1) { + t.Errorf("schema = %v, want 1 (additive change must not bump)", doc["schema"]) + } +} diff --git a/man/commitbrief-cache-clear.1 b/man/commitbrief-cache-clear.1 index 69bf549..6f6f995 100644 --- a/man/commitbrief-cache-clear.1 +++ b/man/commitbrief-cache-clear.1 @@ -22,6 +22,10 @@ Remove cached LLM responses for this repo \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Remove cached LLM responses for this repo \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Remove cached LLM responses for this repo \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Remove cached LLM responses for this repo \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Remove cached LLM responses for this repo \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-cache-inspect.1 b/man/commitbrief-cache-inspect.1 index 7ba0e25..ff2a095 100644 --- a/man/commitbrief-cache-inspect.1 +++ b/man/commitbrief-cache-inspect.1 @@ -26,6 +26,10 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -34,6 +38,10 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -46,6 +54,18 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -66,6 +86,14 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -114,10 +142,18 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-cache-prune.1 b/man/commitbrief-cache-prune.1 index 6c4174d..5fa5a72 100644 --- a/man/commitbrief-cache-prune.1 +++ b/man/commitbrief-cache-prune.1 @@ -38,6 +38,10 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -46,6 +50,10 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -58,6 +66,18 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -78,6 +98,14 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -118,10 +146,18 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-cache-stats.1 b/man/commitbrief-cache-stats.1 index 34162ce..846a5d8 100644 --- a/man/commitbrief-cache-stats.1 +++ b/man/commitbrief-cache-stats.1 @@ -22,6 +22,10 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-cache.1 b/man/commitbrief-cache.1 index 7591fa8..03e02ed 100644 --- a/man/commitbrief-cache.1 +++ b/man/commitbrief-cache.1 @@ -22,6 +22,10 @@ Inspect and manage the local response cache \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Inspect and manage the local response cache \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Inspect and manage the local response cache \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Inspect and manage the local response cache \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Inspect and manage the local response cache \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-commit.1 b/man/commitbrief-commit.1 index 159b9ac..6f6233f 100644 --- a/man/commitbrief-commit.1 +++ b/man/commitbrief-commit.1 @@ -36,6 +36,10 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -44,6 +48,10 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -56,6 +64,18 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -76,6 +96,14 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -124,10 +152,18 @@ Needs an interactive terminal to confirm (or to pick from --generate alternative \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-completion-bash.1 b/man/commitbrief-completion-bash.1 index b4b4d50..caf5d75 100644 --- a/man/commitbrief-completion-bash.1 +++ b/man/commitbrief-completion-bash.1 @@ -53,6 +53,10 @@ You will need to start a new shell for this setup to take effect. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -61,6 +65,10 @@ You will need to start a new shell for this setup to take effect. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -73,6 +81,18 @@ You will need to start a new shell for this setup to take effect. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -93,6 +113,14 @@ You will need to start a new shell for this setup to take effect. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -141,10 +169,18 @@ You will need to start a new shell for this setup to take effect. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-completion-fish.1 b/man/commitbrief-completion-fish.1 index 79a7763..c425e1a 100644 --- a/man/commitbrief-completion-fish.1 +++ b/man/commitbrief-completion-fish.1 @@ -43,6 +43,10 @@ You will need to start a new shell for this setup to take effect. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -51,6 +55,10 @@ You will need to start a new shell for this setup to take effect. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -63,6 +71,18 @@ You will need to start a new shell for this setup to take effect. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -83,6 +103,14 @@ You will need to start a new shell for this setup to take effect. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -131,10 +159,18 @@ You will need to start a new shell for this setup to take effect. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-completion-powershell.1 b/man/commitbrief-completion-powershell.1 index e781921..12227d4 100644 --- a/man/commitbrief-completion-powershell.1 +++ b/man/commitbrief-completion-powershell.1 @@ -37,6 +37,10 @@ to your powershell profile. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -45,6 +49,10 @@ to your powershell profile. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -57,6 +65,18 @@ to your powershell profile. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -77,6 +97,14 @@ to your powershell profile. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -125,10 +153,18 @@ to your powershell profile. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-completion-zsh.1 b/man/commitbrief-completion-zsh.1 index a2c9672..4f87ceb 100644 --- a/man/commitbrief-completion-zsh.1 +++ b/man/commitbrief-completion-zsh.1 @@ -57,6 +57,10 @@ You will need to start a new shell for this setup to take effect. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -65,6 +69,10 @@ You will need to start a new shell for this setup to take effect. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -77,6 +85,18 @@ You will need to start a new shell for this setup to take effect. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -97,6 +117,14 @@ You will need to start a new shell for this setup to take effect. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -145,10 +173,18 @@ You will need to start a new shell for this setup to take effect. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-completion.1 b/man/commitbrief-completion.1 index dd6551f..ed48c8e 100644 --- a/man/commitbrief-completion.1 +++ b/man/commitbrief-completion.1 @@ -23,6 +23,10 @@ See each sub-command's help for details on how to use the generated script. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -31,6 +35,10 @@ See each sub-command's help for details on how to use the generated script. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -43,6 +51,18 @@ See each sub-command's help for details on how to use the generated script. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -63,6 +83,14 @@ See each sub-command's help for details on how to use the generated script. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -111,10 +139,18 @@ See each sub-command's help for details on how to use the generated script. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-compress.1 b/man/commitbrief-compress.1 index 88e2692..b6251b1 100644 --- a/man/commitbrief-compress.1 +++ b/man/commitbrief-compress.1 @@ -39,6 +39,10 @@ an ISO timestamp before the file is replaced. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -47,6 +51,10 @@ an ISO timestamp before the file is replaced. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -59,6 +67,18 @@ an ISO timestamp before the file is replaced. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -79,6 +99,14 @@ an ISO timestamp before the file is replaced. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -127,10 +155,18 @@ an ISO timestamp before the file is replaced. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-config-get.1 b/man/commitbrief-config-get.1 index 8c58c5f..a802b53 100644 --- a/man/commitbrief-config-get.1 +++ b/man/commitbrief-config-get.1 @@ -29,6 +29,10 @@ Examples: \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -37,6 +41,10 @@ Examples: \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -49,6 +57,18 @@ Examples: \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -69,6 +89,14 @@ Examples: \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -117,10 +145,18 @@ Examples: \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-config-set.1 b/man/commitbrief-config-set.1 index dc084e3..335f1f3 100644 --- a/man/commitbrief-config-set.1 +++ b/man/commitbrief-config-set.1 @@ -37,6 +37,10 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -45,6 +49,10 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -57,6 +65,18 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -77,6 +97,14 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -125,10 +153,18 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-config-show.1 b/man/commitbrief-config-show.1 index 5b276f3..a3e1e7c 100644 --- a/man/commitbrief-config-show.1 +++ b/man/commitbrief-config-show.1 @@ -22,6 +22,10 @@ Print the merged configuration (API keys masked) \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Print the merged configuration (API keys masked) \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Print the merged configuration (API keys masked) \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Print the merged configuration (API keys masked) \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Print the merged configuration (API keys masked) \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-config.1 b/man/commitbrief-config.1 index 60be57c..8afd261 100644 --- a/man/commitbrief-config.1 +++ b/man/commitbrief-config.1 @@ -22,6 +22,10 @@ Show, get, or set individual configuration values \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Show, get, or set individual configuration values \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Show, get, or set individual configuration values \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Show, get, or set individual configuration values \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Show, get, or set individual configuration values \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-diff.1 b/man/commitbrief-diff.1 index 8d9a7a1..64e7fce 100644 --- a/man/commitbrief-diff.1 +++ b/man/commitbrief-diff.1 @@ -22,6 +22,10 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-doctor.1 b/man/commitbrief-doctor.1 index 512578b..10d66e7 100644 --- a/man/commitbrief-doctor.1 +++ b/man/commitbrief-doctor.1 @@ -34,6 +34,10 @@ run produces no output. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -42,6 +46,10 @@ run produces no output. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -54,6 +62,18 @@ run produces no output. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -74,6 +94,14 @@ run produces no output. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -118,10 +146,18 @@ run produces no output. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-dry-run.1 b/man/commitbrief-dry-run.1 index 01a2fcc..316db7e 100644 --- a/man/commitbrief-dry-run.1 +++ b/man/commitbrief-dry-run.1 @@ -30,6 +30,10 @@ Build prompt and report what would be sent; no API call \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -38,6 +42,10 @@ Build prompt and report what would be sent; no API call \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -50,6 +58,18 @@ Build prompt and report what would be sent; no API call \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -70,6 +90,14 @@ Build prompt and report what would be sent; no API call \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -118,10 +146,18 @@ Build prompt and report what would be sent; no API call \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-guard.1 b/man/commitbrief-guard.1 index 0461d21..e564b61 100644 --- a/man/commitbrief-guard.1 +++ b/man/commitbrief-guard.1 @@ -41,6 +41,10 @@ The policy (.commitbrief/policy.yml) caps how many findings of each severity a c \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -49,6 +53,10 @@ The policy (.commitbrief/policy.yml) caps how many findings of each severity a c \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -61,6 +69,18 @@ The policy (.commitbrief/policy.yml) caps how many findings of each severity a c \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -81,6 +101,14 @@ The policy (.commitbrief/policy.yml) caps how many findings of each severity a c \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -129,10 +157,18 @@ The policy (.commitbrief/policy.yml) caps how many findings of each severity a c \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-init.1 b/man/commitbrief-init.1 index 132c71e..6b04eb6 100644 --- a/man/commitbrief-init.1 +++ b/man/commitbrief-init.1 @@ -35,6 +35,10 @@ to overwrite the existing file(s) too. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -43,6 +47,10 @@ to overwrite the existing file(s) too. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -55,6 +63,18 @@ to overwrite the existing file(s) too. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -75,6 +95,14 @@ to overwrite the existing file(s) too. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -123,10 +151,18 @@ to overwrite the existing file(s) too. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-install-hook.1 b/man/commitbrief-install-hook.1 index 6de5d9d..22c626d 100644 --- a/man/commitbrief-install-hook.1 +++ b/man/commitbrief-install-hook.1 @@ -54,6 +54,10 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -62,6 +66,10 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -74,6 +82,18 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -94,6 +114,14 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -142,10 +170,18 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-list.1 b/man/commitbrief-list.1 index 366a798..7f92e1a 100644 --- a/man/commitbrief-list.1 +++ b/man/commitbrief-list.1 @@ -22,6 +22,10 @@ Print the command reference \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Print the command reference \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Print the command reference \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Print the command reference \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Print the command reference \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-mcp.1 b/man/commitbrief-mcp.1 index cc972f9..3de5169 100644 --- a/man/commitbrief-mcp.1 +++ b/man/commitbrief-mcp.1 @@ -25,6 +25,10 @@ Wire it into a host (e.g. Claude Desktop / an agent runtime) as a stdio MCP serv \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -33,6 +37,10 @@ Wire it into a host (e.g. Claude Desktop / an agent runtime) as a stdio MCP serv \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -45,6 +53,18 @@ Wire it into a host (e.g. Claude Desktop / an agent runtime) as a stdio MCP serv \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -65,6 +85,14 @@ Wire it into a host (e.g. Claude Desktop / an agent runtime) as a stdio MCP serv \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -113,10 +141,18 @@ Wire it into a host (e.g. Claude Desktop / an agent runtime) as a stdio MCP serv \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-providers-list.1 b/man/commitbrief-providers-list.1 index a16a4f1..75a5c3d 100644 --- a/man/commitbrief-providers-list.1 +++ b/man/commitbrief-providers-list.1 @@ -22,6 +22,10 @@ Show configured providers (active marker, model, API key status) \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Show configured providers (active marker, model, API key status) \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Show configured providers (active marker, model, API key status) \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Show configured providers (active marker, model, API key status) \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Show configured providers (active marker, model, API key status) \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-providers-test.1 b/man/commitbrief-providers-test.1 index 6b9a9d3..f9f4880 100644 --- a/man/commitbrief-providers-test.1 +++ b/man/commitbrief-providers-test.1 @@ -22,6 +22,10 @@ Ping a configured provider to verify the API key and reachability \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ Ping a configured provider to verify the API key and reachability \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ Ping a configured provider to verify the API key and reachability \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ Ping a configured provider to verify the API key and reachability \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ Ping a configured provider to verify the API key and reachability \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-providers-use.1 b/man/commitbrief-providers-use.1 index aa9c59e..18d9385 100644 --- a/man/commitbrief-providers-use.1 +++ b/man/commitbrief-providers-use.1 @@ -26,6 +26,10 @@ Switch the active default provider (no API keys changed) \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -34,6 +38,10 @@ Switch the active default provider (no API keys changed) \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -46,6 +54,18 @@ Switch the active default provider (no API keys changed) \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -66,6 +86,14 @@ Switch the active default provider (no API keys changed) \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -114,10 +142,18 @@ Switch the active default provider (no API keys changed) \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-providers.1 b/man/commitbrief-providers.1 index 3f6e801..f78a11a 100644 --- a/man/commitbrief-providers.1 +++ b/man/commitbrief-providers.1 @@ -22,6 +22,10 @@ List, switch, and test configured LLM providers \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -30,6 +34,10 @@ List, switch, and test configured LLM providers \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -42,6 +50,18 @@ List, switch, and test configured LLM providers \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -62,6 +82,14 @@ List, switch, and test configured LLM providers \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -110,10 +138,18 @@ List, switch, and test configured LLM providers \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-remote-pr.1 b/man/commitbrief-remote-pr.1 index 8e8c8b1..e876c7e 100644 --- a/man/commitbrief-remote-pr.1 +++ b/man/commitbrief-remote-pr.1 @@ -39,6 +39,10 @@ See ADR-0016. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -47,6 +51,10 @@ See ADR-0016. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -59,6 +67,18 @@ See ADR-0016. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -79,6 +99,14 @@ See ADR-0016. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -127,10 +155,18 @@ See ADR-0016. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-remote.1 b/man/commitbrief-remote.1 index 72e1e5b..264f558 100644 --- a/man/commitbrief-remote.1 +++ b/man/commitbrief-remote.1 @@ -26,6 +26,10 @@ they don't produce structured findings). \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -34,6 +38,10 @@ they don't produce structured findings). \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -46,6 +54,18 @@ they don't produce structured findings). \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -66,6 +86,14 @@ they don't produce structured findings). \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -114,10 +142,18 @@ they don't produce structured findings). \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-setup.1 b/man/commitbrief-setup.1 index 2f64a00..b76f92d 100644 --- a/man/commitbrief-setup.1 +++ b/man/commitbrief-setup.1 @@ -44,6 +44,10 @@ the chosen name already shadows a command on your PATH you are warned first. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -52,6 +56,10 @@ the chosen name already shadows a command on your PATH you are warned first. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -64,6 +72,18 @@ the chosen name already shadows a command on your PATH you are warned first. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -84,6 +104,14 @@ the chosen name already shadows a command on your PATH you are warned first. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -132,10 +160,18 @@ the chosen name already shadows a command on your PATH you are warned first. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-summary.1 b/man/commitbrief-summary.1 index fecdd7c..61d6418 100644 --- a/man/commitbrief-summary.1 +++ b/man/commitbrief-summary.1 @@ -36,6 +36,10 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -44,6 +48,10 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -56,6 +64,18 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -76,6 +96,14 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -124,10 +152,18 @@ Output is plain text; use -o/--output to write it to a file. The pre-send guard, \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief-upgrade.1 b/man/commitbrief-upgrade.1 index f46e290..0d09e7d 100644 --- a/man/commitbrief-upgrade.1 +++ b/man/commitbrief-upgrade.1 @@ -46,6 +46,10 @@ and no telemetry. \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -54,6 +58,10 @@ and no telemetry. \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -66,6 +74,18 @@ and no telemetry. \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -86,6 +106,14 @@ and no telemetry. \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -134,10 +162,18 @@ and no telemetry. \fB--show-prompt\fP[=false] print the exact system + user prompt that would be sent, then exit (no provider call, no cost) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB--update-baseline\fP[=false] rewrite .commitbrief/baseline.json from the current findings (accepts them all) instead of filtering this run; user-private, gitignored (ADR-0027) diff --git a/man/commitbrief.1 b/man/commitbrief.1 index 57b6f47..3fce791 100644 --- a/man/commitbrief.1 +++ b/man/commitbrief.1 @@ -17,6 +17,10 @@ Local LLM-powered code review of git diffs \fB--allow-secrets\fP[=false] bypass the pre-send secret scanner (use with care) +.PP +\fB--author\fP=[] + review only commits authored by these people (repeatable; matches name or email, case-insensitive). Switches the scope to a commit walk + .PP \fB--cli\fP="" use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli @@ -25,6 +29,10 @@ Local LLM-powered code review of git diffs \fB--color\fP="auto" color output: auto, always, never +.PP +\fB--committer\fP=[] + review only commits committed by these people (repeatable; matches name or email, case-insensitive) + .PP \fB--compact\fP[=false] one-line per finding (dense review output) @@ -37,6 +45,18 @@ Local LLM-powered code review of git diffs \fB-d\fP, \fB--dir\fP=[] review only files under these directories or matching dir globs (e.g. \fBinternal/**\fR; repeatable, one pattern per flag); combines with the active scope flag +.PP +\fB--end-date\fP="" + review only commits on or before this date (YYYY-MM-DD, inclusive) + +.PP +\fB--exclude-dir\fP=[] + skip files under these directories or matching dir globs (repeatable, one pattern per flag); applied after --dir so an exclusion wins + +.PP +\fB--exclude-file\fP=[] + skip these files or globs (repeatable, one pattern per flag); same matching rules as --file, applied after it so an exclusion wins + .PP \fB--fail-on\fP="" exit 1 if any finding meets/exceeds severity (critical|high|medium|low|info|any|none) @@ -61,6 +81,14 @@ Local LLM-powered code review of git diffs \fB--markdown\fP[=false] emit plain markdown (no ANSI) +.PP +\fB--max-commits\fP=0 + cap how many matching commits enter the review (0 = 200); only meaningful with another commit filter + +.PP +\fB--merges\fP[=false] + include merge commits in a commit-filtered review (excluded by default); only meaningful with another commit filter + .PP \fB--min-severity\fP="" hide findings below this severity in the rendered output (critical|high|medium|low|info); --json and --fail-on still see the full set @@ -113,10 +141,18 @@ Local LLM-powered code review of git diffs \fB-s\fP, \fB--staged\fP[=false] review staged changes (default) +.PP +\fB--start-date\fP="" + review only commits on or after this date (YYYY-MM-DD, inclusive) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) +.PP +\fB--text\fP="" + review only commits whose message contains this text, plus commits unique to a branch whose name contains it (case-insensitive) + .PP \fB-u\fP, \fB--unstaged\fP[=false] review unstaged changes