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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable changes to Diffsmith are documented here. Format follows
`docs/dev-plan/release-plan.md` § Release Notes Shape; versioning is
Semantic Versioning per the same doc.

## Unreleased

### Added

- `--include <pattern>` flag (repeatable, on `review`, `inbox`, and
bare `diffsmith`): the allowlist counterpart of `--exclude`. Keeps
only files matching at least one pattern and drops the rest before
the prompt is built, for reviews that should focus on one area of a
large diff. Same gitignore-lite rules as `--exclude` (trailing `/` =
directory tree at any depth; no `/` = basename glob; otherwise
full-path glob), and renames are kept when either side matches.
`--include` runs first, then `--exclude` carves exceptions out of the
kept set (`--include 'internal/' --exclude 'internal/gen/'`). The
narrowing is surfaced in the run summary (or stderr for
`--print-prompt`/`--dry-run`), malformed globs fail up front, and an
`--include` that matches no changed file is a clean error before any
model call. The adapters' over-budget hint now names both flags.

## v0.2.2 — 2026-06-12

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ To sharpen findings, diffsmith also sends the PR/MR description and the acceptan

Large diffs can exceed the per-model input budget (1 MiB by default). Pass `--exclude <pattern>` (repeatable) to drop noise files from the review — lockfiles, vendored deps, generated code — before the prompt is built: a trailing `/` excludes a directory tree at any depth (`vendor/`), a pattern without `/` matches basenames anywhere (`*.lock`), anything else is a full-path glob (`internal/gen/*.go`). Exclusions are surfaced in the run summary, never silent, and excluding every changed file is an error rather than an empty review.

`--include <pattern>` (repeatable) is the allowlist counterpart: it keeps only the matching files and drops the rest, using the same pattern rules. It runs first, then `--exclude` carves exceptions out of the kept set — so `--include 'internal/' --exclude 'internal/gen/'` reviews everything under `internal/` except the generated tree. Like exclusions, the narrowing is surfaced in the run summary, and an `--include` that matches no changed file is an error rather than an empty review.

After review, `p` in the TUI marks findings for upstream posting. On quit, diffsmith asks for explicit `y` confirmation, then posts approved findings as inline review threads on the PR/MR. Findings whose `(file, line)` already has a diffsmith thread upstream are skipped with a summary line; pass `--repost` to bypass that dedup gate.

## Install
Expand Down
65 changes: 59 additions & 6 deletions internal/app/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,36 @@ func registerModelFlowFlags(cmd *cobra.Command, flags *reviewFlags) {
cmd.Flags().IntVar(&flags.inputBudget, "input-budget", 0, "override the per-adapter prompt-size cap in bytes (default: 1 MiB per adapter; 0 keeps the default)")
cmd.Flags().DurationVar(&flags.modelTimeout, "model-timeout", 10*time.Minute, "per-model wall-clock cap; a model exceeding it is cancelled and dropped from the review (0 disables)")
cmd.Flags().BoolVar(&flags.noContext, "no-context", false, "do not send the PR/MR description or fetch linked-issue acceptance criteria to the model (diff-only review)")
cmd.Flags().StringArrayVar(&flags.include, "include", nil, "review only files matching these patterns (repeatable); applied before --exclude, which then carves exceptions out of the kept set. Same pattern rules as --exclude: trailing '/' = directory tree at any depth ('internal/'); no '/' = basename glob ('*.go'); otherwise full-path glob ('internal/app/*.go')")
cmd.Flags().StringArrayVar(&flags.exclude, "exclude", nil, "exclude files from the review diff (repeatable). Patterns: trailing '/' = directory tree at any depth ('vendor/'); no '/' = basename glob ('*.lock'); otherwise full-path glob ('internal/gen/*.go')")
}

// applyIncludes narrows the fetched input to files matching --include,
// mutating it in place. It is the allowlist counterpart of applyExcludes
// and runs BEFORE it (see runReviewByURL) so --exclude carves exceptions
// out of the kept set — the gitignore precedence the flag help promises.
// The returned note ("" when --include was absent or matched everything)
// must reach the user so the narrowing is never silent. Matching nothing
// is an error: the review would be vacuous and a model call a waste.
func applyIncludes(input *review.ReviewInput, patterns []string) (string, error) {
total := len(input.Files)
origRawLen := len(input.RawDiff)
kept, keptRaw, dropped, err := diff.Include(input.Files, input.RawDiff, patterns)
if err != nil {
return "", err
}
if len(dropped) == 0 {
// No patterns, or every file matched: nothing was narrowed away.
return "", nil
}
if len(kept) == 0 {
return "", fmt.Errorf("no changed file(s) matched --include; nothing left to review")
}
input.Files, input.RawDiff = kept, keptRaw
return fmt.Sprintf("--include kept %d of %d changed file(s), dropped %d bytes of diff",
len(kept), total, origRawLen-len(keptRaw)), nil
}

// applyExcludes filters the fetched input per --exclude, mutating it in
// place. The returned note ("" when nothing was excluded) must reach the
// user — run summary on the TUI path, stderr on the bypass path — so
Expand Down Expand Up @@ -139,6 +166,11 @@ func renameMapFromFiles(files []*diff.DiffFile) map[string]string {
type reviewFlags struct {
dryRun bool
printPrompt bool
// include holds --include patterns applied to the fetched diff
// before --exclude (and before context enrichment / prompt build),
// on every entry point. Empty means "review everything". See
// applyIncludes for semantics.
include []string
// exclude holds --exclude patterns applied to the fetched diff
// before context enrichment and prompt build, on every entry
// point. See applyExcludes for semantics.
Expand Down Expand Up @@ -250,7 +282,15 @@ func runReviewByURL(ctx context.Context, cmd *cobra.Command, url string, flags *
return err
}
// Filter before enrichment so --print-prompt and --dry-run
// reflect exactly what a real run would send.
// reflect exactly what a real run would send. --include narrows
// first, then --exclude carves exceptions out of the kept set.
includeNote, err := applyIncludes(input, flags.include)
if err != nil {
return err
}
if includeNote != "" {
fmt.Fprintf(cmd.ErrOrStderr(), "diffsmith: %s\n", includeNote)
}
excludeNote, err := applyExcludes(input, flags.exclude)
if err != nil {
return err
Expand Down Expand Up @@ -334,11 +374,18 @@ func runReviewByURL(ctx context.Context, cmd *cobra.Command, url string, flags *
return
}

// --exclude runs before enrichment and prompt build. Unlike
// context enrichment this IS fatal on error: a bad pattern or
// an everything-excluded result means the user's filter intent
// can't be honored, and reviewing the unfiltered diff anyway
// would silently ignore it.
// --include then --exclude run before enrichment and prompt
// build. Unlike context enrichment these ARE fatal on error: a
// bad pattern, a nothing-included, or an everything-excluded
// result means the user's filter intent can't be honored, and
// reviewing the unfiltered diff anyway would silently ignore it.
// --include narrows first; --exclude carves exceptions out of
// the kept set.
includeNote, includeErr := applyIncludes(input, flags.include)
if includeErr != nil {
send(tui.LoadErrorMsg{Err: includeErr})
return
}
excludeNote, excludeErr := applyExcludes(input, flags.exclude)
if excludeErr != nil {
send(tui.LoadErrorMsg{Err: excludeErr})
Expand All @@ -353,9 +400,15 @@ func runReviewByURL(ctx context.Context, cmd *cobra.Command, url string, flags *
send(tui.PhaseStatusMsg("Fetching PR/issue context…"))
}
contextNotes := enrichWithContext(ctx, fetcher, input, flags.noContext)
// Prepend filter notes so they lead the summary, in the order
// the filters ran: --include first, then --exclude. (Prepend
// exclude, then include, so include lands at the front.)
if excludeNote != "" {
contextNotes = append([]string{excludeNote}, contextNotes...)
}
if includeNote != "" {
contextNotes = append([]string{includeNote}, contextNotes...)
}

send(tui.PhaseStatusMsg("Reviewing with selected models…"))
outcomes := runModelsInParallel(ctx, selected.All, input, send, flags.modelTimeout)
Expand Down
126 changes: 126 additions & 0 deletions internal/app/review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,132 @@ func TestReviewPipelineExcludeNoteReachesRunSummary(t *testing.T) {
}
}

// TestReviewDryRunAppliesInclude: --include must narrow the diff on the
// bypass path (dry-run / print-prompt) too, and surface the narrowing so
// it is never silent.
func TestReviewDryRunAppliesInclude(t *testing.T) {
stub := &stubProvider{
supports: func(string) bool { return true },
fetchInput: reviewInputWithTwoFileDiff(t),
}
root, out := newTestRoot(stub)
root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--dry-run", "--include", "*.go"})

if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
got := out.String()
if !strings.Contains(got, "fetched 1 file(s)") {
t.Errorf("dry-run should count post-include files; got:\n%s", got)
}
if !strings.Contains(got, "--include kept 1 of 2") {
t.Errorf("narrowing must be surfaced, never silent; got:\n%s", got)
}
}

func TestReviewPrintPromptAppliesInclude(t *testing.T) {
stub := &stubProvider{
supports: func(string) bool { return true },
fetchInput: reviewInputWithTwoFileDiff(t),
}
root, out := newTestRoot(stub)
root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--print-prompt", "--include", "*.go"})

if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
got := out.String()
if strings.Contains(got, "package-lock.json") {
t.Error("non-included file leaked into the printed prompt")
}
if !strings.Contains(got, "auth/session.go") {
t.Error("included file missing from the printed prompt")
}
}

// TestReviewNoFilesIncludedFailsBeforeModelCall: an --include that
// matches nothing is a clean pre-model error naming the flag, not an
// empty review or a model call on an empty diff.
func TestReviewNoFilesIncludedFailsBeforeModelCall(t *testing.T) {
stubProv := &stubProvider{
supports: func(string) bool { return true },
fetchInput: reviewInputWithTwoFileDiff(t),
}
mockModel := &stubModel{name: "codex", reviewResult: &review.ModelReviewResult{Model: "codex"}}
withFakePicker(t, map[string]model.Model{"codex": mockModel})
withFakeTUI(t, func(*tui.Model) error { return nil })

root, _ := newTestRootWithModels(stubProv, map[string]model.Model{"codex": mockModel})
root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--include", "*.rs"})

err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "no changed file(s) matched --include") {
t.Fatalf("want nothing-included error naming --include; got %v", err)
}
if mockModel.reviewHit {
t.Error("model must not be invoked when no file is included")
}
}

// TestReviewPipelineIncludeNoteReachesRunSummary: on the full TUI path
// the include note must ride the context-notes channel into the
// post-session run summary.
func TestReviewPipelineIncludeNoteReachesRunSummary(t *testing.T) {
stubProv := &stubProvider{
supports: func(string) bool { return true },
fetchInput: reviewInputWithTwoFileDiff(t),
}
mockModel := &stubModel{
name: "codex",
reviewResult: &review.ModelReviewResult{
Model: "codex",
Findings: []review.FindingCandidate{{
File: "auth/session.go", Line: 13, Severity: "high",
Title: "t", Evidence: "e", SuggestedComment: "c", FixHint: "f", Confidence: 0.9,
}},
},
}
withFakePicker(t, map[string]model.Model{"codex": mockModel})
withFakeTUI(t, func(*tui.Model) error { return nil })

root, out := newTestRootWithModels(stubProv, map[string]model.Model{"codex": mockModel})
root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--include", "*.go"})

if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if got := out.String(); !strings.Contains(got, "--include kept 1 of 2") {
t.Errorf("run summary missing inclusion note; got:\n%s", got)
}
}

// TestReviewIncludeThenExcludeCompose: --include runs first, then
// --exclude carves exceptions out of the included set (gitignore-style
// precedence). --include '*' keeps both files; --exclude '*.json' then
// drops the lockfile, leaving only the source file in the prompt.
func TestReviewIncludeThenExcludeCompose(t *testing.T) {
stub := &stubProvider{
supports: func(string) bool { return true },
fetchInput: reviewInputWithTwoFileDiff(t),
}
root, out := newTestRoot(stub)
root.SetArgs([]string{
"review", "https://github.com/owner/repo/pull/42", "--print-prompt",
"--include", "*", "--exclude", "*.json",
})

if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
got := out.String()
if strings.Contains(got, "package-lock.json") {
t.Error("--exclude should carve the lockfile out of the included set")
}
if !strings.Contains(got, "auth/session.go") {
t.Error("included, non-excluded file missing from the printed prompt")
}
}

// fetcherStubProvider is a stubProvider that also implements
// review.LinkedIssueFetcher, so command-level tests can drive the
// context-enrichment paths through the real pipeline.
Expand Down
61 changes: 61 additions & 0 deletions internal/diff/include.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package diff

import (
"fmt"
"path"
"strings"
)

// Include is the allowlist inverse of Exclude: it keeps only the files
// matching at least one of the gitignore-lite patterns, dropping the rest
// from both the parsed file list and the raw unified diff. It returns the
// kept files, the rebuilt raw diff, and the dropped paths (in diff
// order). With no patterns it returns its inputs unchanged — an absent
// --include means "review everything", not "review nothing".
//
// Pattern rules are identical to Exclude (see matchPattern):
// - trailing "/" — directory tree, at any depth ("internal/", "auth/")
// - no "/" — basename glob via path.Match ("*.go")
// - otherwise — full-path glob via path.Match ("internal/app/*.go")
//
// A renamed file is kept when either its old or new path matches, the
// same OldPath/Path union Exclude uses — so a rename can't smuggle a file
// out of an allowlist any more than it can dodge a blocklist.
//
// Raw-segment correspondence is positional, exactly as in Exclude: a
// segment/file count mismatch is an error, never a guess.
func Include(files []*DiffFile, rawDiff string, patterns []string) (kept []*DiffFile, keptRaw string, dropped []string, err error) {
if len(patterns) == 0 {
return files, rawDiff, nil, nil
}
for _, pat := range patterns {
// Surface malformed globs immediately, naming --include — a
// typo'd pattern that silently matches nothing would filter the
// whole diff away under an allowlist.
if !strings.HasSuffix(pat, "/") {
if _, err := path.Match(pat, "probe"); err != nil {
return nil, "", nil, fmt.Errorf("invalid --include pattern %q: %w", pat, err)
}
}
}

segments := splitRawSegments(rawDiff)
if len(segments) != len(files) {
return nil, "", nil, fmt.Errorf("raw diff has %d segment(s) but %d parsed file(s); refusing to filter against a mismatched diff", len(segments), len(files))
}

var rawKept strings.Builder
for i, f := range files {
keep, err := fileMatchesAny(f, patterns)
if err != nil {
return nil, "", nil, err
}
if !keep {
dropped = append(dropped, f.Path)
continue
}
kept = append(kept, f)
rawKept.WriteString(segments[i])
}
return kept, rawKept.String(), dropped, nil
}
Loading
Loading