diff --git a/README.md b/README.md index 7a8d877..aa3e549 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/openant-cli/cmd/diff.go b/apps/openant-cli/cmd/diff.go index 94e9d9c..e5026fa 100644 --- a/apps/openant-cli/cmd/diff.go +++ b/apps/openant-cli/cmd/diff.go @@ -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 or --pr ") + if scanDiffBase == "" && scanPR == 0 && !scanStaged { + output.PrintError("openant diff requires --diff-base , --pr , or --staged") os.Exit(2) } runScan(cmd, args) diff --git a/apps/openant-cli/cmd/diff_shared.go b/apps/openant-cli/cmd/diff_shared.go index 7e46f31..635d901 100644 --- a/apps/openant-cli/cmd/diff_shared.go +++ b/apps/openant-cli/cmd/diff_shared.go @@ -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 == "" { @@ -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") @@ -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 { + 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))) } diff --git a/apps/openant-cli/cmd/diff_shared_test.go b/apps/openant-cli/cmd/diff_shared_test.go new file mode 100644 index 0000000..cd71c84 --- /dev/null +++ b/apps/openant-cli/cmd/diff_shared_test.go @@ -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()") + } +} diff --git a/apps/openant-cli/cmd/mode.go b/apps/openant-cli/cmd/mode.go index 50af2af..a14705b 100644 --- a/apps/openant-cli/cmd/mode.go +++ b/apps/openant-cli/cmd/mode.go @@ -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 @@ -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 { @@ -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( diff --git a/apps/openant-cli/cmd/mode_test.go b/apps/openant-cli/cmd/mode_test.go index 9844afb..311da74 100644 --- a/apps/openant-cli/cmd/mode_test.go +++ b/apps/openant-cli/cmd/mode_test.go @@ -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: ""}, } @@ -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"}) diff --git a/apps/openant-cli/cmd/scan.go b/apps/openant-cli/cmd/scan.go index 2a646b5..ec7f5f3 100644 --- a/apps/openant-cli/cmd/scan.go +++ b/apps/openant-cli/cmd/scan.go @@ -50,6 +50,7 @@ var ( scanIncremental bool scanDiffBase string scanPR int + scanStaged bool scanDiffScope string ) @@ -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") } @@ -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 } manifestPath, err := prepareDiffManifest(repoPath, scanOutput, manifestOpts) if err != nil { @@ -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 { @@ -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, diff --git a/apps/openant-cli/internal/git/diff.go b/apps/openant-cli/internal/git/diff.go index f1351ac..6c7e628 100644 --- a/apps/openant-cli/internal/git/diff.go +++ b/apps/openant-cli/internal/git/diff.go @@ -103,6 +103,16 @@ func runGit(repoPath string, stderr *bytes.Buffer, args ...string) error { return nil } +// StagedRef is the synthetic base ref written into the manifest for staged +// scans. The on-disk SHA fields hold HEAD (the staged-against commit) and a +// zero placeholder for "head" since the index has no SHA. +const StagedRef = "STAGED" + +// stagedHeadSHA is the placeholder HeadSHA written for staged manifests. +// We can't resolve the index to a SHA without writing a tree, and downstream +// consumers only read it as a string identifier. +const stagedHeadSHA = "0000000000000000000000000000000000000000" + // ChangedFiles returns the files changed between baseSHA and HEAD using the // symmetric diff BASE...HEAD. Rename detection is enabled; the new-side path // is returned for renamed files. @@ -127,6 +137,63 @@ func ChangedFiles(repoPath, baseSHA string) ([]string, error) { return files, nil } +// StagedChangedFiles returns the files with staged (index) changes against +// HEAD. Rename detection is enabled; the new-side path is returned for +// renamed files. Untracked files are not included — `git add` them first +// to bring them into the index. +func StagedChangedFiles(repoPath string) ([]string, error) { + cmd := exec.Command("git", "-C", repoPath, "diff", + "--cached", "--name-only", "--find-renames") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git diff --cached --name-only: %w: %s", err, strings.TrimSpace(stderr.String())) + } + var files []string + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + files = append(files, line) + } + } + sort.Strings(files) + return files, nil +} + +// StagedHunksForFile returns the new-side [start, end] line ranges for +// staged changes to a single file. Pure-deletion hunks (count=0 on the new +// side) are skipped. +func StagedHunksForFile(repoPath, file string) ([][2]int, error) { + cmd := exec.Command("git", "-C", repoPath, "diff", + "--cached", "--unified=0", "--", file) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git diff --cached --unified=0 -- %s: %w: %s", file, err, strings.TrimSpace(stderr.String())) + } + var ranges [][2]int + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + m := hunkHeaderRe.FindStringSubmatch(scanner.Text()) + if m == nil { + continue + } + start, _ := strconv.Atoi(m[1]) + count := 1 + if m[2] != "" { + count, _ = strconv.Atoi(m[2]) + } + if count == 0 { + continue + } + ranges = append(ranges, [2]int{start, start + count - 1}) + } + return ranges, nil +} + // hunkHeaderRe captures the new-side start and count from a unified-diff // hunk header. `@@ -a[,b] +c[,d] @@` — capture group 1 is c, group 2 is d. // Count defaults to 1 when omitted. @@ -203,3 +270,44 @@ func BuildManifest(repoPath, baseRef, scope string, prNumber int) (*Manifest, er m.Hunks = hunks return m, nil } + +// BuildStagedManifest assembles a Manifest from the staged (index) diff +// against HEAD. Used by `openant scan --staged` and `openant diff --staged` +// for pre-commit hook style scanning. scope must be a valid scope. The +// BaseRef is set to StagedRef, BaseSHA is HEAD, HeadSHA is a zero placeholder +// since the index has no SHA. +func BuildStagedManifest(repoPath, scope string) (*Manifest, error) { + if !IsValidScope(scope) { + return nil, fmt.Errorf("invalid scope %q", scope) + } + headSHA, err := gitRevParse(repoPath, "HEAD") + if err != nil { + return nil, fmt.Errorf("resolve HEAD: %w", err) + } + files, err := StagedChangedFiles(repoPath) + if err != nil { + return nil, err + } + m := &Manifest{ + BaseRef: StagedRef, + BaseSHA: headSHA, + HeadSHA: stagedHeadSHA, + Scope: scope, + ChangedFiles: files, + } + if scope == ScopeChangedFiles { + return m, nil + } + hunks := make(map[string][][2]int, len(files)) + for _, f := range files { + r, err := StagedHunksForFile(repoPath, f) + if err != nil { + return nil, err + } + if len(r) > 0 { + hunks[f] = r + } + } + m.Hunks = hunks + return m, nil +} diff --git a/apps/openant-cli/internal/git/diff_test.go b/apps/openant-cli/internal/git/diff_test.go index df581ba..401a674 100644 --- a/apps/openant-cli/internal/git/diff_test.go +++ b/apps/openant-cli/internal/git/diff_test.go @@ -200,6 +200,141 @@ func TestManifestRoundTrip(t *testing.T) { } } +func TestStagedChangedFilesAndHunks(t *testing.T) { + dir := t.TempDir() + initTestRepo(t, dir) + + // Commit 1: initial state. + writeFile(t, dir, "a.txt", "one\ntwo\nthree\n") + writeFile(t, dir, "b.txt", "alpha\nbeta\n") + runCmd(t, dir, "git", "add", ".") + runCmd(t, dir, "git", "commit", "-q", "-m", "init") + + // Stage some changes against the index but do not commit. + writeFile(t, dir, "a.txt", "one\ntwo MODIFIED\nthree\nfour ADDED\n") + writeFile(t, dir, "c.txt", "fresh\n") + runCmd(t, dir, "git", "add", "a.txt", "c.txt") + + // Working-tree-only edit on b.txt — must NOT appear in --cached output. + writeFile(t, dir, "b.txt", "alpha\nbeta\nWORKTREE-ONLY\n") + + files, err := StagedChangedFiles(dir) + if err != nil { + t.Fatalf("StagedChangedFiles: %v", err) + } + want := []string{"a.txt", "c.txt"} + sort.Strings(files) + if !reflect.DeepEqual(files, want) { + t.Errorf("StagedChangedFiles = %v, want %v (b.txt is worktree-only, must not appear)", files, want) + } + + hunksA, err := StagedHunksForFile(dir, "a.txt") + if err != nil { + t.Fatalf("StagedHunksForFile a.txt: %v", err) + } + if len(hunksA) == 0 { + t.Fatalf("expected staged hunks on a.txt, got none") + } + for _, h := range hunksA { + if h[0] < 1 || h[1] < h[0] { + t.Errorf("bad staged hunk range %v", h) + } + } + + hunksC, err := StagedHunksForFile(dir, "c.txt") + if err != nil { + t.Fatalf("StagedHunksForFile c.txt: %v", err) + } + if len(hunksC) != 1 || hunksC[0][0] != 1 { + t.Errorf("unexpected staged hunks on c.txt: %v", hunksC) + } +} + +func TestBuildStagedManifest(t *testing.T) { + dir := t.TempDir() + initTestRepo(t, dir) + + writeFile(t, dir, "a.txt", "one\ntwo\n") + runCmd(t, dir, "git", "add", ".") + runCmd(t, dir, "git", "commit", "-q", "-m", "init") + + writeFile(t, dir, "a.txt", "one\ntwo changed\nthree\n") + runCmd(t, dir, "git", "add", "a.txt") + + m, err := BuildStagedManifest(dir, ScopeChangedFunctions) + if err != nil { + t.Fatalf("BuildStagedManifest: %v", err) + } + if m.BaseRef != StagedRef { + t.Errorf("BaseRef = %q, want %q", m.BaseRef, StagedRef) + } + if m.Scope != ScopeChangedFunctions { + t.Errorf("Scope = %q, want %q", m.Scope, ScopeChangedFunctions) + } + if len(m.ChangedFiles) != 1 || m.ChangedFiles[0] != "a.txt" { + t.Errorf("ChangedFiles = %v, want [a.txt]", m.ChangedFiles) + } + if m.BaseSHA == "" || m.BaseSHA == stagedHeadSHA { + t.Errorf("BaseSHA should be HEAD, got %q", m.BaseSHA) + } + if m.HeadSHA != stagedHeadSHA { + t.Errorf("HeadSHA = %q, want %q", m.HeadSHA, stagedHeadSHA) + } + if len(m.Hunks["a.txt"]) == 0 { + t.Errorf("expected hunks for a.txt, got %v", m.Hunks) + } +} + +func TestBuildStagedManifestChangedFilesScopeOmitsHunks(t *testing.T) { + dir := t.TempDir() + initTestRepo(t, dir) + + writeFile(t, dir, "a.txt", "x\n") + runCmd(t, dir, "git", "add", ".") + runCmd(t, dir, "git", "commit", "-q", "-m", "init") + + writeFile(t, dir, "a.txt", "x\ny\n") + runCmd(t, dir, "git", "add", "a.txt") + + m, err := BuildStagedManifest(dir, ScopeChangedFiles) + if err != nil { + t.Fatalf("BuildStagedManifest: %v", err) + } + if m.Hunks != nil { + t.Errorf("hunks should be nil for changed_files scope, got %v", m.Hunks) + } +} + +func TestBuildStagedManifestEmptyIndex(t *testing.T) { + dir := t.TempDir() + initTestRepo(t, dir) + + writeFile(t, dir, "a.txt", "x\n") + runCmd(t, dir, "git", "add", ".") + runCmd(t, dir, "git", "commit", "-q", "-m", "init") + + // Nothing staged. + m, err := BuildStagedManifest(dir, ScopeChangedFunctions) + if err != nil { + t.Fatalf("BuildStagedManifest with empty index: %v", err) + } + if len(m.ChangedFiles) != 0 { + t.Errorf("expected no changed files, got %v", m.ChangedFiles) + } +} + +func TestBuildStagedManifestRejectsBadScope(t *testing.T) { + dir := t.TempDir() + initTestRepo(t, dir) + writeFile(t, dir, "a.txt", "x\n") + runCmd(t, dir, "git", "add", ".") + runCmd(t, dir, "git", "commit", "-q", "-m", "init") + + if _, err := BuildStagedManifest(dir, "everything"); err == nil { + t.Fatal("expected error for invalid scope, got nil") + } +} + func TestIsValidScope(t *testing.T) { valid := []string{ScopeChangedFiles, ScopeChangedFunctions, ScopeCallers} for _, s := range valid {