Skip to content
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,29 @@ Or run the full pipeline in one command:
openant scan --verify
```

### Incremental and diff-based scans

For repositories where a full scan is too slow or expensive, OpenAnt can
restrict the pipeline to units whose bodies overlap a git diff hunk:

```bash
openant scan --diff-base origin/main # diff vs a ref
openant scan --pr 123 # diff vs the base of a GitHub PR
openant scan --staged # diff vs HEAD using the staged index
openant scan --incremental # diff vs the last successful scan
```

`--staged` reads `git diff --cached` and is intended for pre-commit hooks or
local "scan what I'm about to commit" runs. The base is HEAD; the head is the
index, so files staged with `git add` are scanned and worktree-only edits are
not.

The shorter `openant diff` form takes the same flags, e.g.:

```bash
openant diff --staged --skip-dynamic-test
```

### Working with multiple projects

The pipeline operates on one project at a time. Running `openant init` sets the newly initialized project as the active one, so all subsequent commands target it by default.
Expand Down
9 changes: 5 additions & 4 deletions apps/openant-cli/cmd/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@ import (

var diffCmd = &cobra.Command{
Use: "diff [repository-path]",
Short: "Scan only the code changed vs a base ref or GitHub PR",
Short: "Scan only the code changed vs a base ref, GitHub PR, or the staged index",
Long: `Diff runs the full scan pipeline but filters to units whose bodies
overlap a git diff hunk. One of --diff-base or --pr is required.
overlap a git diff hunk. One of --diff-base, --pr, or --staged is required.

Examples:
openant diff --diff-base origin/main
openant diff --pr 123
openant diff --staged # pre-commit hook usage
openant diff --diff-base HEAD~5 --diff-scope callers --verify

All scan flags (--level, --workers, --verify, etc.) work the same here.`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
if scanDiffBase == "" && scanPR == 0 {
output.PrintError("openant diff requires --diff-base <ref> or --pr <N>")
if scanDiffBase == "" && scanPR == 0 && !scanStaged {
output.PrintError("openant diff requires --diff-base <ref>, --pr <N>, or --staged")
os.Exit(2)
}
runScan(cmd, args)
Expand Down
61 changes: 43 additions & 18 deletions apps/openant-cli/cmd/diff_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,33 @@ import (
"github.com/knostic/open-ant-cli/internal/output"
)

// diffOpts collects the three diff-mode flags that scan/parse/diff all share.
// diffOpts collects the diff-mode flags that scan/parse/diff all share.
type diffOpts struct {
base string
pr int
staged bool
scope string
}

// isSet reports whether any diff flag was provided.
func (o diffOpts) isSet() bool {
return o.base != "" || o.pr > 0
return o.base != "" || o.pr > 0 || o.staged
}

// validate enforces flag rules common to all entry points.
func (o diffOpts) validate() error {
if o.base != "" && o.pr > 0 {
return fmt.Errorf("--diff-base and --pr are mutually exclusive")
set := 0
if o.base != "" {
set++
}
if o.pr > 0 {
set++
}
if o.staged {
set++
}
if set > 1 {
return fmt.Errorf("--diff-base, --pr, and --staged are mutually exclusive")
}
if o.isSet() {
if o.scope == "" {
Expand Down Expand Up @@ -60,21 +71,30 @@ func prepareDiffManifest(repoPath, outputDir string, opts diffOpts) (string, err
return "", fmt.Errorf("create output dir %s: %w", outputDir, err)
}

baseRef := opts.base
if opts.pr > 0 {
fetched, err := git.FetchPR(repoPath, opts.pr, nil)
var m *git.Manifest
if opts.staged {
built, err := git.BuildStagedManifest(repoPath, opts.scope)
if err != nil {
return "", err
return "", fmt.Errorf("build staged diff manifest: %w", err)
}
baseRef = fetched
if !quiet {
fmt.Fprintf(os.Stderr, "PR #%d: base=%s (fetched and checked out pr-head)\n", opts.pr, baseRef)
m = built
} else {
baseRef := opts.base
if opts.pr > 0 {
fetched, err := git.FetchPR(repoPath, opts.pr, nil)
if err != nil {
return "", err
}
baseRef = fetched
if !quiet {
fmt.Fprintf(os.Stderr, "PR #%d: base=%s (fetched and checked out pr-head)\n", opts.pr, baseRef)
}
}
}

m, err := git.BuildManifest(repoPath, baseRef, opts.scope, opts.pr)
if err != nil {
return "", fmt.Errorf("build diff manifest: %w", err)
built, err := git.BuildManifest(repoPath, baseRef, opts.scope, opts.pr)
if err != nil {
return "", fmt.Errorf("build diff manifest: %w", err)
}
m = built
}

manifestPath := filepath.Join(outputDir, "diff_manifest.json")
Expand All @@ -83,8 +103,13 @@ func prepareDiffManifest(repoPath, outputDir string, opts diffOpts) (string, err
}

if !quiet {
output.PrintKeyValue("Diff base", fmt.Sprintf("%s (%s)", m.BaseRef, shortSHA(m.BaseSHA)))
output.PrintKeyValue("Diff head", shortSHA(m.HeadSHA))
if m.BaseRef == git.StagedRef {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — UX · Python report consumers don't mirror this STAGED special-case

Issue: Nice that the CLI output renders Diff head: index (staged) for staged manifests. But the same head_sha = 0000…0000 placeholder flows into libs/openant-core/generate_report.py:109-121 (_build_header_subtitle) and libs/openant-core/export_csv.py:54-65 (_format_diff_banner), both of which call _short_sha(head_sha)00000000. End-users will see Incremental {repo} · 5e1d2a4..00000000 · … in the HTML report and CSV banner.

Suggestion: Mirror this special-case on the Python side — a one-line guard if base_ref == "STAGED": head = "index" in both _build_header_subtitle and _format_diff_banner keeps the report symmetric with the CLI output.

output.PrintKeyValue("Diff base", fmt.Sprintf("HEAD (%s)", shortSHA(m.BaseSHA)))
output.PrintKeyValue("Diff head", "index (staged)")
} else {
output.PrintKeyValue("Diff base", fmt.Sprintf("%s (%s)", m.BaseRef, shortSHA(m.BaseSHA)))
output.PrintKeyValue("Diff head", shortSHA(m.HeadSHA))
}
output.PrintKeyValue("Diff scope", m.Scope)
output.PrintKeyValue("Changed files", fmt.Sprintf("%d", len(m.ChangedFiles)))
}
Expand Down
48 changes: 48 additions & 0 deletions apps/openant-cli/cmd/diff_shared_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cmd

import (
"strings"
"testing"
)

func TestDiffOptsValidateStaged(t *testing.T) {
cases := []struct {
name string
opts diffOpts
wantErr string
}{
{name: "staged only", opts: diffOpts{staged: true, scope: "changed_functions"}},
{name: "staged + base", opts: diffOpts{staged: true, base: "origin/main", scope: "changed_functions"}, wantErr: "mutually exclusive"},
{name: "staged + pr", opts: diffOpts{staged: true, pr: 9, scope: "changed_functions"}, wantErr: "mutually exclusive"},
{name: "base + pr", opts: diffOpts{base: "x", pr: 9, scope: "changed_functions"}, wantErr: "mutually exclusive"},
{name: "staged empty scope", opts: diffOpts{staged: true}, wantErr: "must not be empty"},
{name: "staged invalid scope", opts: diffOpts{staged: true, scope: "everything"}, wantErr: "invalid --diff-scope"},
{name: "nothing set is fine", opts: diffOpts{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.opts.validate()
if tc.wantErr == "" {
if err != nil {
t.Errorf("expected no error, got %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("expected error containing %q, got %q", tc.wantErr, err.Error())
}
})
}
}

func TestDiffOptsIsSetStaged(t *testing.T) {
if !(diffOpts{staged: true}).isSet() {
t.Error("staged=true should be isSet()")
}
if (diffOpts{}).isSet() {
t.Error("zero diffOpts should not be isSet()")
}
}
22 changes: 16 additions & 6 deletions apps/openant-cli/cmd/mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,20 @@ type modeOpts struct {
incremental bool
diffBase string
pr int
staged bool
scope string
projectName string
repoPath string
}

// modeDecision is the resolved mode for a scan-run. Fields beyond Kind
// are only set when Kind == ScanKindDiff.
// are only set when Kind == ScanKindDiff. Staged is true when the diff
// runs against the index (vs a committed ref).
type modeDecision struct {
Kind string
Base string // ref used for diff (may be a SHA or named ref)
Scope string
Kind string
Base string // ref used for diff (may be a SHA or named ref)
Scope string
Staged bool
}

// errBaselineNonInteractive is returned when init/scan is invoked without
Expand Down Expand Up @@ -68,6 +71,10 @@ func selectMode(o modeOpts) (modeDecision, error) {
scope = git.ScopeChangedFunctions
}

if o.staged {
return modeDecision{Kind: config.ScanKindDiff, Scope: scope, Staged: true}, nil
}

if o.pr > 0 {
baseRef, err := git.FetchPR(o.repoPath, o.pr, nil)
if err != nil {
Expand Down Expand Up @@ -122,11 +129,14 @@ func validateModeFlags(o modeOpts) error {
if o.incremental {
diffFlagCount++
}
if o.staged {
diffFlagCount++
}
if o.full && diffFlagCount > 0 {
return errors.New("--full cannot be combined with --incremental, --diff-base, or --pr")
return errors.New("--full cannot be combined with --incremental, --diff-base, --pr, or --staged")
}
if diffFlagCount > 1 {
return errors.New("--incremental, --diff-base, and --pr are mutually exclusive")
return errors.New("--incremental, --diff-base, --pr, and --staged are mutually exclusive")
}
if o.scope != "" && !git.IsValidScope(o.scope) {
return fmt.Errorf(
Expand Down
26 changes: 26 additions & 0 deletions apps/openant-cli/cmd/mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ func TestValidateModeFlags(t *testing.T) {
{name: "incremental + diffBase", opts: modeOpts{incremental: true, diffBase: "x"}, wantErr: "mutually exclusive"},
{name: "incremental + pr", opts: modeOpts{incremental: true, pr: 1}, wantErr: "mutually exclusive"},

{name: "staged alone", opts: modeOpts{staged: true}, wantErr: ""},
{name: "staged + diffBase", opts: modeOpts{staged: true, diffBase: "x"}, wantErr: "mutually exclusive"},
{name: "staged + pr", opts: modeOpts{staged: true, pr: 1}, wantErr: "mutually exclusive"},
{name: "staged + incremental", opts: modeOpts{staged: true, incremental: true}, wantErr: "mutually exclusive"},
{name: "full + staged", opts: modeOpts{full: true, staged: true}, wantErr: "cannot be combined"},

{name: "invalid scope", opts: modeOpts{scope: "everything"}, wantErr: "invalid --diff-scope"},
{name: "valid scope", opts: modeOpts{scope: "callers"}, wantErr: ""},
}
Expand Down Expand Up @@ -101,6 +107,26 @@ func TestSelectModeIncrementalUsesLatestSuccess(t *testing.T) {
}
}

func TestSelectModeStagedSetsStagedFlag(t *testing.T) {
withTempHome(t)
got, err := selectMode(modeOpts{staged: true, projectName: "p"})
if err != nil {
t.Fatalf("selectMode: %v", err)
}
if got.Kind != config.ScanKindDiff {
t.Errorf("expected diff, got %s", got.Kind)
}
if !got.Staged {
t.Error("expected Staged=true")
}
if got.Base != "" {
t.Errorf("staged decision should leave Base empty, got %q", got.Base)
}
if got.Scope == "" {
t.Error("expected default scope to be applied")
}
}

func TestSelectModeIncrementalErrorsWithoutBaseline(t *testing.T) {
withTempHome(t)
_, err := selectMode(modeOpts{incremental: true, projectName: "fresh"})
Expand Down
6 changes: 5 additions & 1 deletion apps/openant-cli/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ var (
scanIncremental bool
scanDiffBase string
scanPR int
scanStaged bool
scanDiffScope string
)

Expand Down Expand Up @@ -78,6 +79,7 @@ func registerScanFlags(cmd *cobra.Command) {
cmd.Flags().BoolVar(&scanIncremental, "incremental", false, "Incremental against the last successful scan on this project")
cmd.Flags().StringVar(&scanDiffBase, "diff-base", "", "Incremental mode: filter pipeline to units overlapping diff vs this ref (e.g. origin/main, HEAD~5)")
cmd.Flags().IntVar(&scanPR, "pr", 0, "Incremental mode against a GitHub PR number (requires gh; mutex with --diff-base)")
cmd.Flags().BoolVar(&scanStaged, "staged", false, "Incremental mode against the staged index vs HEAD (pre-commit hook usage; mutex with --diff-base/--pr)")
cmd.Flags().StringVar(&scanDiffScope, "diff-scope", "changed_functions", "Diff scope: changed_files, changed_functions, callers")
}

Expand Down Expand Up @@ -132,6 +134,7 @@ func runScan(cmd *cobra.Command, args []string) {
if decision.Kind == config.ScanKindDiff {
manifestOpts.base = decision.Base
manifestOpts.scope = decision.Scope
manifestOpts.staged = decision.Staged
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — Architecture · Staged is consumed here but never persisted to ScanMeta

Issue: decision.Staged flows into manifestOpts.staged for this run, but the meta written a few lines down at scan.go:300-311 only persists Kind=diff, Base="", Scope=... — the staged marker is lost. Two consequences:

  1. Crashed-resume silently degrades to full. If a staged scan writes meta.status=running and then crashes, a subsequent openant scan (no flags) hits the resume path at scan.go:272-276, which returns modeDecision{Kind: "diff", Base: "", Scope: ...} (Staged defaults to false). In runScan, prepareDiffManifest then sees an unset diffOpts, isSet() returns false, and the diff manifest is silently skipped — falling back to a full scan without telling the user.
  2. Audit trail is wrong. Terminal meta after a successful staged scan is indistinguishable from a malformed --diff-base run with empty base.

Suggestion: Either (a) add a Staged bool field (with a staged json tag, omitempty) to ScanMeta and propagate through both the writer and the resume path, or (b) skip persisting meta for staged runs entirely (since staged is meant to be ephemeral). (b) is simpler and matches the "pre-commit ephemeral" framing in the description.

}
manifestPath, err := prepareDiffManifest(repoPath, scanOutput, manifestOpts)
if err != nil {
Expand Down Expand Up @@ -263,7 +266,7 @@ func finalizeScanMetaIfProject(ctx *projectContext, status string) {
// reflecting the decision so step verbs and finalizeScanMetaIfProject
// have something to read/update.
func resolveScanMode(ctx *projectContext, repoPath string) (modeDecision, error) {
flagsPassed := scanFull || scanIncremental || scanDiffBase != "" || scanPR > 0
flagsPassed := scanFull || scanIncremental || scanDiffBase != "" || scanPR > 0 || scanStaged

// Reuse init's pending decision when no flags override it.
if !flagsPassed && ctx != nil && ctx.Project != nil {
Expand All @@ -283,6 +286,7 @@ func resolveScanMode(ctx *projectContext, repoPath string) (modeDecision, error)
incremental: scanIncremental,
diffBase: scanDiffBase,
pr: scanPR,
staged: scanStaged,
scope: scanDiffScope,
projectName: projectName,
repoPath: repoPath,
Expand Down
Loading