From 09252a29fe7f9a4fac297f197ffed0c1b7356206 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 19 May 2026 12:58:17 +0000 Subject: [PATCH 01/14] init: import scripts/testselect from coder/coder and split main.go Imports the testselect Go test-plan generator from coder/coder@bf6a0d953f (branch ethan/go-test-flake-detector) and splits the single ~1.6k-line main.go into ten focused files inside package main: - cli.go: entrypoint, flag parsing, command orchestration - config.go: config / commandConfig types and defaults - request.go: runRequest, diffRange, revision validation - gitexec.go: gitRunner / gitFetcher types and exec.Command impl - diff.go: git diff parsing, change kinds, hunks, line ranges - snapshot.go: AST snapshot parsing, fileSnapshot, sharedDecl, fallbacks - broadening.go: per-kind broadening rules (broadeningScope) - selection.go: per-change selection logic - inventory.go: inventoryCache for package/directory test discovery - plan.go: plan construction, matrix and summary rendering githubactions.go and publish.go are imported unchanged. Tests (githubactions_test.go, integration_test.go, main_test.go) are kept as single files initially. go vet, go build, go test, and go test -race are all green. --- .gitignore | 8 + README.md | 59 ++ broadening.go | 56 + cli.go | 109 ++ config.go | 58 ++ diff.go | 303 ++++++ gitexec.go | 69 ++ githubactions.go | 342 ++++++ githubactions_test.go | 286 +++++ go.mod | 14 + go.sum | 12 + integration_test.go | 187 ++++ inventory.go | 152 +++ main_test.go | 2297 +++++++++++++++++++++++++++++++++++++++++ plan.go | 312 ++++++ publish.go | 111 ++ request.go | 53 + selection.go | 294 ++++++ snapshot.go | 327 ++++++ 19 files changed, 5049 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 broadening.go create mode 100644 cli.go create mode 100644 config.go create mode 100644 diff.go create mode 100644 gitexec.go create mode 100644 githubactions.go create mode 100644 githubactions_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 integration_test.go create mode 100644 inventory.go create mode 100644 main_test.go create mode 100644 plan.go create mode 100644 publish.go create mode 100644 request.go create mode 100644 selection.go create mode 100644 snapshot.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c38a693 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Go build artifacts +/testselect +*.test +*.out + +# Editor scratch +*.swp +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..0189829 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# testselect + +`testselect` is the Go test-plan generator that drives the `flake-go` CI +workflow in `coder/coder`. Given a base/head git revision pair (or a +GitHub Actions event), it walks the diff, parses each changed test +file, picks the smallest set of tests to rerun, and emits a workflow +matrix plus a human-readable Markdown summary. + +## Building and running + +```sh +go build ./ +./testselect --help +``` + +Typical invocation against the local working tree: + +```sh +./testselect \ + --repo-root . \ + --base-sha origin/main \ + --head-sha HEAD \ + --out-matrix ./flake-matrix.json \ + --out-summary - +``` + +In GitHub Actions: + +```sh +go run ./ \ + --repo-root . \ + --github-actions \ + --out-matrix "$RUNNER_TEMP/flake-matrix.json" +``` + +## File layout + +The binary is a single `package main`, split into focused files: + +| File | Responsibility | +| --------------- | ------------------------------------------------------------------- | +| `cli.go` | `main`, flag parsing, command orchestration (`run`, `runCommand`). | +| `config.go` | `config` / `commandConfig` types and defaults. | +| `request.go` | `runRequest`, `diffRange`, revision validation. | +| `gitexec.go` | `gitRunner` / `gitFetcher` types and the real `exec.Command` impl. | +| `diff.go` | Reading and parsing `git diff`, change kinds, hunks, line ranges. | +| `snapshot.go` | AST snapshot parsing, `fileSnapshot`, `sharedDecl`, fallbacks. | +| `broadening.go` | Per-kind broadening rules (`broadeningScope`). | +| `selection.go` | Per-change selection logic (`selectChange`, broaden vs narrow). | +| `inventory.go` | `inventoryCache` for package/directory test discovery. | +| `plan.go` | Plan construction, matrix and summary rendering (`buildExecutionPlan`, `selectTestPlan`). | +| `githubactions.go` | GitHub Actions request builder and history preparation. | +| `publish.go` | Single sink for matrix and summary outputs. | + +## Testing + +```sh +go test ./... +``` diff --git a/broadening.go b/broadening.go new file mode 100644 index 0000000..b72176b --- /dev/null +++ b/broadening.go @@ -0,0 +1,56 @@ +package main + +type broadeningScope uint8 + +const ( + broadeningNone broadeningScope = iota + broadeningPackage + broadeningDirectory +) + +func broadeningScopeForOldHunk(decls []sharedDecl, candidate lineRange) broadeningScope { + for _, decl := range decls { + if decl.Range.overlaps(candidate) { + return decl.broadeningScope() + } + } + return broadeningNone +} + +func broadeningScopeForNewHunk(decls []sharedDecl, oldSnapshot *fileSnapshot, candidate lineRange) broadeningScope { + for _, decl := range decls { + if !decl.Range.overlaps(candidate) { + continue + } + if scope := decl.broadeningScopeOnNewSide(oldSnapshot); scope != broadeningNone { + return scope + } + } + return broadeningNone +} + +func (decl sharedDecl) broadeningScope() broadeningScope { + switch decl.Kind { + case sharedDeclInit, sharedDeclTestMain: + // Go builds package and package_test files into one test binary. + // Init and TestMain changes can affect every test in the directory. + return broadeningDirectory + case sharedDeclImport, sharedDeclVar, sharedDeclConst, sharedDeclType, sharedDeclHelper: + return broadeningPackage + } + return broadeningNone +} + +func (decl sharedDecl) broadeningScopeOnNewSide(oldSnapshot *fileSnapshot) broadeningScope { + switch decl.Kind { + case sharedDeclImport: + return broadeningPackage + case sharedDeclInit, sharedDeclTestMain: + return broadeningDirectory + case sharedDeclVar, sharedDeclConst, sharedDeclType, sharedDeclHelper: + if oldSnapshot != nil && oldSnapshot.hasSharedKey(decl.Keys) { + return broadeningPackage + } + } + return broadeningNone +} diff --git a/cli.go b/cli.go new file mode 100644 index 0000000..3ea50fc --- /dev/null +++ b/cli.go @@ -0,0 +1,109 @@ +// Command testselect produces deterministic Go test plans for the +// flake-go workflow. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + + "golang.org/x/xerrors" +) + +func main() { + cfg := defaultCommandConfig() + flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + flags.StringVar(&cfg.RepoRoot, "repo-root", cfg.RepoRoot, "repository root") + flags.StringVar(&cfg.BaseSHA, "base-sha", cfg.BaseSHA, "base revision to diff against") + flags.StringVar(&cfg.HeadSHA, "head-sha", cfg.HeadSHA, "head revision to diff against") + flags.StringVar(&cfg.OutMatrix, "out-matrix", cfg.OutMatrix, "path to write workflow matrix JSON") + flags.StringVar(&cfg.OutSummary, "out-summary", cfg.OutSummary, "path to write Markdown summary, or - for stdout") + flags.BoolVar(&cfg.GitHubActions, "github-actions", cfg.GitHubActions, "read diff range and output paths from GitHub Actions environment") + flags.StringVar(&cfg.GitHubEventName, "github-event-name", cfg.GitHubEventName, "override GITHUB_EVENT_NAME") + flags.StringVar(&cfg.GitHubEventPath, "github-event-path", cfg.GitHubEventPath, "override GITHUB_EVENT_PATH") + flags.StringVar(&cfg.GitHubRepository, "github-repository", cfg.GitHubRepository, "override GITHUB_REPOSITORY") + flags.StringVar(&cfg.GitHubOutput, "github-output", cfg.GitHubOutput, "override GITHUB_OUTPUT") + flags.StringVar(&cfg.GitHubStepSummary, "github-step-summary", cfg.GitHubStepSummary, "override GITHUB_STEP_SUMMARY") + if err := flags.Parse(os.Args[1:]); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if err := runCommand(context.Background(), cfg, os.Stdout, os.Stderr, execGit, execGitFetch); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context, cfg config, stdout, stderr io.Writer, git gitRunner) error { + req, err := explicitRunRequest(cfg) + if err != nil { + return err + } + return executeRunRequest(ctx, req, stdout, stderr, git, nil) +} + +func runCommand(ctx context.Context, cfg commandConfig, stdout, stderr io.Writer, git gitRunner, fetch gitFetcher) error { + var ( + req runRequest + err error + ) + if cfg.GitHubActions { + req, err = githubActionsRunRequest(ctx, cfg, git) + } else { + req, err = explicitRunRequest(cfg.config) + } + if err != nil { + return err + } + return executeRunRequest(ctx, req, stdout, stderr, git, fetch) +} + +func explicitRunRequest(cfg config) (runRequest, error) { + cfg = cfg.withDefaults() + if cfg.BaseSHA == "" { + return runRequest{}, xerrors.New("--base-sha is required") + } + if cfg.OutMatrix == "" { + return runRequest{}, xerrors.New("--out-matrix is required") + } + if err := validateRevision("--base-sha", cfg.BaseSHA); err != nil { + return runRequest{}, err + } + if err := validateRevision("--head-sha", cfg.HeadSHA); err != nil { + return runRequest{}, err + } + return runRequest{ + RepoRoot: cfg.RepoRoot, + Range: diffRange{ + BaseSHA: cfg.BaseSHA, + HeadSHA: cfg.HeadSHA, + }, + Sinks: outputSinks{ + OutMatrix: cfg.OutMatrix, + OutSummary: cfg.OutSummary, + }, + }, nil +} + +func executeRunRequest(ctx context.Context, req runRequest, stdout, stderr io.Writer, git gitRunner, fetch gitFetcher) error { + if err := ensureRangeAvailable(ctx, &req, git, fetch); err != nil { + return err + } + selectorCfg := config{ + RepoRoot: req.RepoRoot, + BaseSHA: req.Range.BaseSHA, + HeadSHA: req.Range.HeadSHA, + } + changedFiles, result, err := selectTestPlan(ctx, selectorCfg, git) + if err != nil { + return err + } + summary := renderSummary(changedFiles, result.Summary) + if err := publishPlan(req.Sinks, result.Matrix, summary, stdout, req.OutputSizeLimit); err != nil { + return err + } + _, _ = fmt.Fprintf(stderr, "selected %d package targets from %d changed test files\n", len(result.Matrix.Include), len(changedFiles)) + return nil +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..ed8b029 --- /dev/null +++ b/config.go @@ -0,0 +1,58 @@ +package main + +import ( + "cmp" +) + +const ( + defaultRepoRoot = "." + defaultHeadSHA = "HEAD" + defaultOutSummary = "-" + defaultTargetCount = "10" + runOnceTargetCount = "1" + + // Package-wide and matrix-wide caps keep the detector cheap by + // running broad fallback targets once instead of repeatedly. + maxMatrixEntries = 20 + maxBroadenedTests = 50 + maxOverflowSummaries = 10 +) + +type config struct { + RepoRoot string + BaseSHA string + HeadSHA string + OutMatrix string + OutSummary string +} + +func defaultConfig() config { + return config{ + RepoRoot: defaultRepoRoot, + HeadSHA: defaultHeadSHA, + OutSummary: defaultOutSummary, + } +} + +func (cfg config) withDefaults() config { + cfg.RepoRoot = cmp.Or(cfg.RepoRoot, defaultRepoRoot) + cfg.HeadSHA = cmp.Or(cfg.HeadSHA, defaultHeadSHA) + cfg.OutSummary = cmp.Or(cfg.OutSummary, defaultOutSummary) + return cfg +} + +type commandConfig struct { + config + + GitHubActions bool + GitHubEventName string + GitHubEventPath string + GitHubRepository string + GitHubOutput string + GitHubStepSummary string + Env map[string]string +} + +func defaultCommandConfig() commandConfig { + return commandConfig{config: defaultConfig()} +} diff --git a/diff.go b/diff.go new file mode 100644 index 0000000..ea3785b --- /dev/null +++ b/diff.go @@ -0,0 +1,303 @@ +package main + +import ( + "cmp" + "context" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + + "golang.org/x/xerrors" +) + +var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) + +type changeKind string + +const ( + changeAdded changeKind = "A" + changeDeleted changeKind = "D" + changeModified changeKind = "M" + changeRenamed changeKind = "R" + changeType changeKind = "T" +) + +type testFileChange struct { + Kind changeKind + OldPath string + NewPath string +} + +func (change testFileChange) displayPath() string { + return cmp.Or(change.NewPath, change.OldPath) +} + +func (change testFileChange) oldRevisionPath() string { + return cmp.Or(change.OldPath, change.NewPath) +} + +func (change testFileChange) newRevisionPath() string { + return cmp.Or(change.NewPath, change.OldPath) +} + +func (change testFileChange) pathspecs() []string { + oldPath := change.oldRevisionPath() + newPath := change.newRevisionPath() + if oldPath == "" { + return []string{newPath} + } + if newPath == "" || newPath == oldPath { + return []string{oldPath} + } + return []string{oldPath, newPath} +} + +// lineRange uses End < Start to represent an empty span from a zero-count diff +// hunk. hasLines reports whether the span contains any real source lines. +type lineRange struct { + Start int + End int +} + +type diffHunk struct { + Old lineRange + New lineRange +} + +func newSideOnlyHunks(hunks []diffHunk) []diffHunk { + trimmed := make([]diffHunk, 0, len(hunks)) + for _, hunk := range hunks { + hunk.Old = lineRange{} + trimmed = append(trimmed, hunk) + } + return trimmed +} + +func readChangeFile(ctx context.Context, cfg config, git gitRunner, revision, filePath string) ([]byte, bool, error) { + if filePath == "" || !isRunnableTestFilePath(filePath) { + return nil, false, nil + } + return readFileAtRevision(ctx, cfg, git, revision, filePath) +} + +func listChangedTestFiles(ctx context.Context, cfg config, git gitRunner) ([]testFileChange, error) { + result, err := git( + ctx, + cfg.RepoRoot, + "diff", + "--name-status", + "-z", + "--find-renames", + "--diff-filter=ADMRT", + diffRangeSpec(cfg), + ) + if err != nil { + return nil, err + } + if result.Stdout == "" { + return nil, nil + } + + fields := strings.Split(result.Stdout, "\x00") + changes := make([]testFileChange, 0) + for index := 0; index < len(fields); { + status := fields[index] + index++ + if status == "" { + continue + } + kind, err := parseChangeKind(status) + if err != nil { + return nil, err + } + switch kind { + case changeRenamed: + if index+1 >= len(fields) { + return nil, xerrors.Errorf("rename status %q is missing paths", status) + } + oldPath := cleanGitPath(fields[index]) + newPath := cleanGitPath(fields[index+1]) + index += 2 + change := testFileChange{Kind: kind, OldPath: oldPath, NewPath: newPath} + if !isRunnableTestFilePath(change.OldPath) && !isRunnableTestFilePath(change.NewPath) { + continue + } + changes = append(changes, change) + default: + if index >= len(fields) { + return nil, xerrors.Errorf("status %q is missing a path", status) + } + path := cleanGitPath(fields[index]) + index++ + change := testFileChange{Kind: kind, OldPath: path, NewPath: path} + switch kind { + case changeAdded: + change.OldPath = "" + case changeDeleted: + change.NewPath = "" + } + if !isRunnableTestFilePath(change.displayPath()) { + continue + } + changes = append(changes, change) + } + } + slices.SortFunc(changes, func(left, right testFileChange) int { + return cmp.Compare(left.displayPath(), right.displayPath()) + }) + return changes, nil +} + +func parseChangeKind(status string) (changeKind, error) { + switch { + case strings.HasPrefix(status, string(changeAdded)): + return changeAdded, nil + case strings.HasPrefix(status, string(changeDeleted)): + return changeDeleted, nil + case strings.HasPrefix(status, string(changeModified)): + return changeModified, nil + case strings.HasPrefix(status, string(changeRenamed)): + return changeRenamed, nil + case strings.HasPrefix(status, string(changeType)): + return changeType, nil + default: + return "", xerrors.Errorf("unsupported diff status %q", status) + } +} + +func cleanGitPath(path string) string { + return filepath.ToSlash(filepath.Clean(path)) +} + +func isRunnableTestFilePath(path string) bool { + return strings.HasSuffix(path, "_test.go") && isRunnableGoTestPath(path) +} + +func isRunnableGoTestPath(path string) bool { + cleanPath := cleanGitPath(path) + baseName := filepath.Base(cleanPath) + if strings.HasPrefix(baseName, ".") || strings.HasPrefix(baseName, "_") { + return false + } + for segment := range strings.SplitSeq(filepath.ToSlash(filepath.Dir(cleanPath)), "/") { + if segment == "." || segment == "" { + continue + } + if segment == "testdata" || segment == "vendor" || strings.HasPrefix(segment, ".") || strings.HasPrefix(segment, "_") { + return false + } + } + return true +} + +func listDiffHunks(ctx context.Context, cfg config, git gitRunner, change testFileChange) ([]diffHunk, error) { + args := []string{"diff", "--unified=0", "--no-color", "--find-renames", diffRangeSpec(cfg), "--"} + args = append(args, change.pathspecs()...) + result, err := git(ctx, cfg.RepoRoot, args...) + if err != nil { + return nil, err + } + return parseDiffHunks(result.Stdout) +} + +func parseDiffHunks(diff string) ([]diffHunk, error) { + hunks := make([]diffHunk, 0) + for line := range strings.Lines(diff) { + line = strings.TrimSuffix(line, "\n") + matches := hunkHeaderPattern.FindStringSubmatch(line) + if matches == nil { + continue + } + oldRange, err := parseRange(matches[1], matches[2]) + if err != nil { + return nil, err + } + newRange, err := parseRange(matches[3], matches[4]) + if err != nil { + return nil, err + } + hunks = append(hunks, diffHunk{Old: oldRange, New: newRange}) + } + return hunks, nil +} + +func parseRange(startText, countText string) (lineRange, error) { + start, err := parseNonNegativeInt(startText) + if err != nil { + return lineRange{}, err + } + count := 1 + if countText != "" { + count, err = parseNonNegativeInt(countText) + if err != nil { + return lineRange{}, err + } + } + if count == 0 { + if start == 0 { + start = 1 + } + return lineRange{Start: start, End: start - 1}, nil + } + return lineRange{Start: start, End: start + count - 1}, nil +} + +func parseNonNegativeInt(value string) (int, error) { + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, xerrors.Errorf("parse integer %q: %w", value, err) + } + if parsed < 0 { + return 0, xerrors.Errorf("negative value %q", value) + } + return parsed, nil +} + +func readFileAtRevision(ctx context.Context, cfg config, git gitRunner, revision, filePath string) ([]byte, bool, error) { + if err := ensureRevisionExists(ctx, cfg, git, revision); err != nil { + return nil, false, err + } + fileExists, err := fileExistsAtRevision(ctx, cfg, git, revision, filePath) + if err != nil { + return nil, false, err + } + if !fileExists { + return nil, false, nil + } + + result, err := git(ctx, cfg.RepoRoot, "show", revision+":"+filePath) + if err != nil { + return nil, false, xerrors.Errorf("read %s at %s: %w", filePath, revision, err) + } + return []byte(result.Stdout), true, nil +} + +func fileExistsAtRevision(ctx context.Context, cfg config, git gitRunner, revision, filePath string) (bool, error) { + result, err := git(ctx, cfg.RepoRoot, "ls-tree", "-z", "--name-only", revision, "--", filePath) + if err != nil { + return false, xerrors.Errorf("check whether %s exists at %s: %w", filePath, revision, err) + } + cleanPath := cleanGitPath(filePath) + for part := range strings.SplitSeq(result.Stdout, "\x00") { + if part == "" { + continue + } + if cleanGitPath(part) == cleanPath { + return true, nil + } + } + return false, nil +} + +func (r lineRange) hasLines() bool { + return r.Start > 0 && r.End >= r.Start +} + +func (r lineRange) overlaps(other lineRange) bool { + if !r.hasLines() || !other.hasLines() { + return false + } + return r.Start <= other.End && other.Start <= r.End +} diff --git a/gitexec.go b/gitexec.go new file mode 100644 index 0000000..280bb61 --- /dev/null +++ b/gitexec.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "strings" + + "golang.org/x/xerrors" +) + +type gitResult struct { + Stdout string + Stderr string + ExitCode int +} + +type gitRunner func(ctx context.Context, dir string, args ...string) (gitResult, error) + +type gitFetcher func(ctx context.Context, dir string, spec fetchSpec) (gitResult, error) + +func ensureRevisionExists(ctx context.Context, cfg config, git gitRunner, revision string) error { + _, err := git(ctx, cfg.RepoRoot, "cat-file", "-e", revision+"^{commit}") + if err != nil { + return xerrors.Errorf("revision %s is not available: %w", revision, err) + } + return nil +} + +func execGit(ctx context.Context, dir string, args ...string) (gitResult, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + result := gitResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: 0, + } + if err == nil { + return result, nil + } + result.ExitCode = exitCode(err) + message := strings.TrimSpace(result.Stderr) + if message == "" { + message = strings.TrimSpace(result.Stdout) + } + if strings.Contains(message, "no merge base") { + return result, xerrors.Errorf("git %s: %s. Ensure both revisions have full history before diffing %q", strings.Join(args, " "), message, args[len(args)-1]) + } + if message == "" { + message = err.Error() + } + return result, xerrors.Errorf("git %s: %s", strings.Join(args, " "), message) +} + +func exitCode(err error) int { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + return exitErr.ExitCode() + } + return -1 +} diff --git a/githubactions.go b/githubactions.go new file mode 100644 index 0000000..1ec21fd --- /dev/null +++ b/githubactions.go @@ -0,0 +1,342 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/xerrors" +) + +// defaultDispatchBaseRef follows coder/coder's default branch name. +const defaultDispatchBaseRef = "main" + +var repoFullNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$`) + +type githubEvent struct { + PullRequest struct { + Base struct { + SHA string `json:"sha"` + Ref string `json:"ref"` + Repo struct { + FullName string `json:"full_name"` + } `json:"repo"` + } `json:"base"` + Head struct { + SHA string `json:"sha"` + } `json:"head"` + } `json:"pull_request"` + Inputs struct { + BaseSHA string `json:"base_sha"` + HeadSHA string `json:"head_sha"` + } `json:"inputs"` +} + +func githubActionsRunRequest(ctx context.Context, cfg commandConfig, git gitRunner) (runRequest, error) { + baseCfg := cfg.config.withDefaults() + if baseCfg.OutMatrix == "" { + return runRequest{}, xerrors.New("--out-matrix is required") + } + + eventName := cfg.githubValue("GITHUB_EVENT_NAME", cfg.GitHubEventName) + if eventName == "" { + return runRequest{}, xerrors.New("GITHUB_EVENT_NAME is required") + } + eventPath := cfg.githubValue("GITHUB_EVENT_PATH", cfg.GitHubEventPath) + if eventPath == "" { + return runRequest{}, xerrors.New("GITHUB_EVENT_PATH is required") + } + githubOutput := cfg.githubValue("GITHUB_OUTPUT", cfg.GitHubOutput) + if githubOutput == "" { + return runRequest{}, xerrors.New("GITHUB_OUTPUT is required") + } + githubRepository := cfg.githubValue("GITHUB_REPOSITORY", cfg.GitHubRepository) + if err := validateRepoFullName("GITHUB_REPOSITORY", githubRepository); err != nil { + return runRequest{}, err + } + stepSummary := cfg.githubValue("GITHUB_STEP_SUMMARY", cfg.GitHubStepSummary) + + event, err := readGitHubEvent(eventPath) + if err != nil { + return runRequest{}, err + } + currentHead, err := currentHeadSHA(ctx, baseCfg.RepoRoot, git) + if err != nil { + return runRequest{}, err + } + + req := runRequest{ + RepoRoot: baseCfg.RepoRoot, + Range: diffRange{ + HeadSHA: currentHead, + }, + Sinks: outputSinks{ + OutMatrix: baseCfg.OutMatrix, + OutSummary: baseCfg.OutSummary, + GitHubOutput: githubOutput, + GitHubStepSummary: stepSummary, + }, + } + + switch eventName { + case "pull_request": + return pullRequestRunRequest(req, event) + case "workflow_dispatch": + return workflowDispatchRunRequest(req, event) + default: + return runRequest{}, xerrors.Errorf("unsupported GitHub event %q", eventName) + } +} + +func pullRequestRunRequest(req runRequest, event githubEvent) (runRequest, error) { + baseSHA := event.PullRequest.Base.SHA + if err := validateRevision("pull_request.base.sha", baseSHA); err != nil { + return runRequest{}, err + } + baseRef := event.PullRequest.Base.Ref + if err := validateRef("pull_request.base.ref", baseRef); err != nil { + return runRequest{}, err + } + baseRepo := event.PullRequest.Base.Repo.FullName + if err := validateRepoFullName("pull_request.base.repo.full_name", baseRepo); err != nil { + return runRequest{}, err + } + expectedHead := event.PullRequest.Head.SHA + if expectedHead != "" { + if err := validateRevision("pull_request.head.sha", expectedHead); err != nil { + return runRequest{}, err + } + if req.Range.HeadSHA != expectedHead { + return runRequest{}, xerrors.Errorf("checked out HEAD %s does not match pull_request.head.sha %s; update actions/checkout ref to the pull request head commit", req.Range.HeadSHA, expectedHead) + } + } + + baseURL := githubRepoURL(baseRepo) + req.Range.BaseSHA = baseSHA + req.Prepare = []fetchSpec{ + {Remote: baseURL, Ref: branchFetchRef(baseRef)}, + {Remote: baseURL, Ref: baseSHA}, + } + return req, nil +} + +func workflowDispatchRunRequest(req runRequest, event githubEvent) (runRequest, error) { + if headSHA := event.Inputs.HeadSHA; headSHA != "" { + if err := validateRevision("workflow_dispatch.inputs.head_sha", headSHA); err != nil { + return runRequest{}, err + } + if req.Range.HeadSHA != headSHA { + return runRequest{}, xerrors.Errorf("checked out HEAD %s does not match workflow_dispatch.inputs.head_sha %s; update actions/checkout ref to the requested head commit", req.Range.HeadSHA, headSHA) + } + } + + baseSHA := event.Inputs.BaseSHA + mainFetch := fetchSpec{Remote: "origin", Ref: remoteTrackingFetchRef(defaultDispatchBaseRef)} + if baseSHA != "" { + if err := validateRevision("workflow_dispatch.inputs.base_sha", baseSHA); err != nil { + return runRequest{}, err + } + req.Range.BaseSHA = baseSHA + req.Prepare = []fetchSpec{mainFetch, {Remote: "origin", Ref: baseSHA}} + return req, nil + } + + req.Prepare = []fetchSpec{mainFetch} + req.MergeBaseRef = "origin/" + defaultDispatchBaseRef + return req, nil +} + +func readGitHubEvent(path string) (githubEvent, error) { + data, err := os.ReadFile(path) + if err != nil { + return githubEvent{}, xerrors.Errorf("read GitHub event payload %s: %w", path, err) + } + var event githubEvent + if err := json.Unmarshal(data, &event); err != nil { + return githubEvent{}, xerrors.Errorf("parse GitHub event payload %s: %w", path, err) + } + return event, nil +} + +func (cfg commandConfig) githubValue(envName, override string) string { + if override != "" { + return override + } + if cfg.Env != nil { + return cfg.Env[envName] + } + return os.Getenv(envName) +} + +func currentHeadSHA(ctx context.Context, repoRoot string, git gitRunner) (string, error) { + result, err := git(ctx, repoRoot, "rev-parse", "HEAD") + if err != nil { + return "", xerrors.Errorf("resolve checked out HEAD: %w", err) + } + head := strings.TrimSpace(result.Stdout) + if err := validateRevision("checked out HEAD", head); err != nil { + return "", err + } + return head, nil +} + +func ensureRangeAvailable(ctx context.Context, req *runRequest, git gitRunner, fetch gitFetcher) error { + if req.RepoRoot == "" { + req.RepoRoot = defaultRepoRoot + } + if err := validateRevision("head revision", req.Range.HeadSHA); err != nil { + return err + } + if req.Range.BaseSHA != "" { + return ensureConcreteRangeAvailable(ctx, req, git, fetch) + } + if req.MergeBaseRef == "" { + return xerrors.New("base revision is required") + } + if err := fetchSpecs(ctx, req, fetch); err != nil { + return err + } + + baseSHA, err := gitMergeBase(ctx, req.RepoRoot, git, req.Range.HeadSHA, req.MergeBaseRef) + if err != nil { + return xerrors.Errorf("failed to resolve merge-base between %s and %s after fetching base history: %w", req.Range.HeadSHA, req.MergeBaseRef, err) + } + if err := validateRevision("resolved base revision", baseSHA); err != nil { + return err + } + req.Range.BaseSHA = baseSHA + if _, err := gitMergeBase(ctx, req.RepoRoot, git, req.Range.BaseSHA, req.Range.HeadSHA); err != nil { + return xerrors.Errorf("unable to resolve a merge base for %s...%s after fetching base history: %w", req.Range.BaseSHA, req.Range.HeadSHA, err) + } + return nil +} + +func ensureConcreteRangeAvailable(ctx context.Context, req *runRequest, git gitRunner, fetch gitFetcher) error { + if err := validateRevision("base revision", req.Range.BaseSHA); err != nil { + return err + } + _, mergeErr := gitMergeBase(ctx, req.RepoRoot, git, req.Range.BaseSHA, req.Range.HeadSHA) + if mergeErr == nil { + return nil + } + if len(req.Prepare) == 0 { + return xerrors.Errorf("unable to resolve merge base for %s...%s: %w", req.Range.BaseSHA, req.Range.HeadSHA, mergeErr) + } + if fetch == nil { + return xerrors.New("history fetch is required but no fetcher was configured") + } + for _, spec := range req.Prepare { + if err := validateFetchSpec(spec); err != nil { + return err + } + if _, err := fetch(ctx, req.RepoRoot, spec); err != nil { + return xerrors.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err) + } + _, err := gitMergeBase(ctx, req.RepoRoot, git, req.Range.BaseSHA, req.Range.HeadSHA) + if err == nil { + return nil + } + mergeErr = err + } + return xerrors.Errorf("unable to resolve a merge base for %s...%s after fetching base history: %w", req.Range.BaseSHA, req.Range.HeadSHA, mergeErr) +} + +func fetchSpecs(ctx context.Context, req *runRequest, fetch gitFetcher) error { + if len(req.Prepare) == 0 { + return nil + } + if fetch == nil { + return xerrors.New("history fetch is required but no fetcher was configured") + } + for _, spec := range req.Prepare { + if err := validateFetchSpec(spec); err != nil { + return err + } + if _, err := fetch(ctx, req.RepoRoot, spec); err != nil { + return xerrors.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err) + } + } + return nil +} + +func validateFetchSpec(spec fetchSpec) error { + if spec.Remote == "" || spec.Ref == "" { + return xerrors.Errorf("invalid fetch spec: remote and ref are required") + } + return nil +} + +func gitMergeBase(ctx context.Context, repoRoot string, git gitRunner, left, right string) (string, error) { + result, err := git(ctx, repoRoot, "merge-base", left, right) + if err != nil { + return "", err + } + base := strings.TrimSpace(result.Stdout) + if base == "" { + return "", xerrors.Errorf("git merge-base %s %s returned no revision", left, right) + } + return base, nil +} + +func execGitFetch(ctx context.Context, dir string, spec fetchSpec) (gitResult, error) { + return execGit(ctx, dir, "fetch", "--no-tags", spec.Remote, spec.Ref) +} + +func validateRef(name, value string) error { + if value == "" { + return xerrors.Errorf("%s is required", name) + } + if strings.HasPrefix(value, "-") { + return xerrors.Errorf("%s must not start with '-': %q", name, value) + } + if !utf8.ValidString(value) || strings.ContainsRune(value, '\x00') { + return xerrors.Errorf("%s must not contain invalid bytes", name) + } + if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") { + return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + } + if strings.Contains(value, "..") || strings.Contains(value, "@{") || strings.HasSuffix(value, ".lock") { + return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + } + for _, r := range value { + if unicode.IsControl(r) || unicode.IsSpace(r) { + return xerrors.Errorf("%s must not contain control or whitespace characters: %q", name, value) + } + switch r { + case ':', '^', '~', '?', '*', '[', '\\': + return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + } + } + for segment := range strings.SplitSeq(value, "/") { + if segment == "" || strings.HasPrefix(segment, ".") { + return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + } + } + return nil +} + +func validateRepoFullName(name, value string) error { + if value == "" { + return xerrors.Errorf("%s is required", name) + } + if !repoFullNameRE.MatchString(value) || strings.Contains(value, "..") { + return xerrors.Errorf("%s must be a GitHub owner/repository name: %q", name, value) + } + return nil +} + +func githubRepoURL(fullName string) string { + return "https://github.com/" + fullName + ".git" +} + +func branchFetchRef(ref string) string { + return "refs/heads/" + ref +} + +func remoteTrackingFetchRef(ref string) string { + return branchFetchRef(ref) + ":refs/remotes/origin/" + ref +} diff --git a/githubactions_test.go b/githubactions_test.go new file mode 100644 index 0000000..08fe64d --- /dev/null +++ b/githubactions_test.go @@ -0,0 +1,286 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGitHubActionsRunRequestPullRequest(t *testing.T) { + t.Parallel() + + eventPath := writeGitHubEvent(t, `{ + "pull_request": { + "base": { + "sha": "base123", + "ref": "main", + "repo": {"full_name": "coder/coder"} + }, + "head": {"sha": "head123"} + }, + "ignored": true + }`) + req, err := githubActionsRunRequest(t.Context(), commandConfig{ + config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, + Env: map[string]string{ + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_EVENT_PATH": eventPath, + "GITHUB_OUTPUT": "output.txt", + "GITHUB_REPOSITORY": "coder/coder", + "GITHUB_STEP_SUMMARY": "summary.md", + "UNRELATED_EXTRA_ENV": "ignored", + }, + }, fakeGitRepo{headSHA: "head123"}.runner(t)) + require.NoError(t, err) + require.Equal(t, "/repo", req.RepoRoot) + require.Equal(t, diffRange{BaseSHA: "base123", HeadSHA: "head123"}, req.Range) + require.Equal(t, []fetchSpec{ + {Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}, + {Remote: "https://github.com/coder/coder.git", Ref: "base123"}, + }, req.Prepare) + require.Equal(t, "matrix.json", req.Sinks.OutMatrix) + require.Equal(t, "output.txt", req.Sinks.GitHubOutput) + require.Equal(t, "summary.md", req.Sinks.GitHubStepSummary) +} + +func TestGitHubActionsRunRequestVerifiesPullRequestHead(t *testing.T) { + t.Parallel() + + eventPath := writeGitHubEvent(t, `{ + "pull_request": { + "base": { + "sha": "base123", + "ref": "main", + "repo": {"full_name": "coder/coder"} + }, + "head": {"sha": "expected-head"} + } + }`) + _, err := githubActionsRunRequest(t.Context(), commandConfig{ + config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, + Env: map[string]string{ + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_EVENT_PATH": eventPath, + "GITHUB_OUTPUT": "output.txt", + "GITHUB_REPOSITORY": "coder/coder", + }, + }, fakeGitRepo{headSHA: "actual-head"}.runner(t)) + require.ErrorContains(t, err, "checked out HEAD actual-head does not match pull_request.head.sha expected-head") +} + +func TestGitHubActionsRunRequestWorkflowDispatchExplicitRange(t *testing.T) { + t.Parallel() + + eventPath := writeGitHubEvent(t, `{ + "inputs": { + "base_sha": "base123", + "head_sha": "head123" + } + }`) + req, err := githubActionsRunRequest(t.Context(), commandConfig{ + config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, + Env: map[string]string{ + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_EVENT_PATH": eventPath, + "GITHUB_OUTPUT": "output.txt", + "GITHUB_REPOSITORY": "coder/coder", + }, + }, fakeGitRepo{headSHA: "head123"}.runner(t)) + require.NoError(t, err) + require.Equal(t, diffRange{BaseSHA: "base123", HeadSHA: "head123"}, req.Range) + require.Equal(t, []fetchSpec{ + {Remote: "origin", Ref: "refs/heads/main:refs/remotes/origin/main"}, + {Remote: "origin", Ref: "base123"}, + }, req.Prepare) + require.Empty(t, req.MergeBaseRef) +} + +func TestEnsureRangeAvailableWorkflowDispatchDefaultBase(t *testing.T) { + t.Parallel() + + req := runRequest{ + RepoRoot: "/repo", + Range: diffRange{HeadSHA: "head123"}, + Prepare: []fetchSpec{{Remote: "origin", Ref: "refs/heads/main:refs/remotes/origin/main"}}, + MergeBaseRef: "origin/main", + } + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "base123": {}, + "head123": {}, + }, + mergeBases: map[string]string{ + gitKey("merge-base", "head123", "origin/main"): "base123", + }, + } + var fetches []fetchSpec + fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { + fetches = append(fetches, spec) + return gitResult{}, nil + } + err := ensureRangeAvailable(t.Context(), &req, repo.runner(t), fetch) + require.NoError(t, err) + require.Equal(t, "base123", req.Range.BaseSHA) + require.Equal(t, []fetchSpec{{Remote: "origin", Ref: "refs/heads/main:refs/remotes/origin/main"}}, fetches) +} + +func TestEnsureRangeAvailableFetchesLazily(t *testing.T) { + t.Parallel() + + req := runRequest{ + RepoRoot: "/repo", + Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, + Prepare: []fetchSpec{{Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}}, + } + repo := fakeGitRepo{revisions: map[string]map[string]string{"base123": {}, "head123": {}}} + fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { + t.Fatalf("unexpected fetch: %+v", spec) + return gitResult{}, nil + } + require.NoError(t, ensureRangeAvailable(t.Context(), &req, repo.runner(t), fetch)) +} + +func TestEnsureRangeAvailableFetchesWhenMergeBaseIsMissing(t *testing.T) { + t.Parallel() + + req := runRequest{ + RepoRoot: "/repo", + Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, + Prepare: []fetchSpec{ + {Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}, + {Remote: "https://github.com/coder/coder.git", Ref: "base123"}, + }, + } + mergeBaseCalls := 0 + git := func(_ context.Context, _ string, args ...string) (gitResult, error) { + require.Equal(t, []string{"merge-base", "base123", "head123"}, args) + mergeBaseCalls++ + if mergeBaseCalls == 1 { + return gitFailure(1, "fatal: no merge base") + } + return gitResult{Stdout: "base123\n"}, nil + } + var fetches []fetchSpec + fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { + fetches = append(fetches, spec) + return gitResult{}, nil + } + require.NoError(t, ensureRangeAvailable(t.Context(), &req, git, fetch)) + require.Equal(t, 2, mergeBaseCalls) + require.Equal(t, req.Prepare[:1], fetches) +} + +func TestGitHubActionsRunRequestValidatesInputsBeforeFetch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + eventName string + eventJSON string + want string + }{ + { + name: "bad base revision", + eventName: "pull_request", + eventJSON: `{"pull_request":{"base":{"sha":"-bad","ref":"main","repo":{"full_name":"coder/coder"}},"head":{"sha":"head123"}}}`, + want: "must not start with '-'", + }, + { + name: "bad base ref", + eventName: "pull_request", + eventJSON: `{"pull_request":{"base":{"sha":"base123","ref":"main:evil","repo":{"full_name":"coder/coder"}},"head":{"sha":"head123"}}}`, + want: "safe branch ref", + }, + { + name: "bad base repo", + eventName: "pull_request", + eventJSON: `{"pull_request":{"base":{"sha":"base123","ref":"main","repo":{"full_name":"../coder"}},"head":{"sha":"head123"}}}`, + want: "owner/repository", + }, + { + name: "bad dispatch head", + eventName: "workflow_dispatch", + eventJSON: `{"inputs":{"head_sha":"head:bad"}}`, + want: "must not contain ':'", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + eventPath := writeGitHubEvent(t, tc.eventJSON) + _, err := githubActionsRunRequest(t.Context(), commandConfig{ + config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, + Env: map[string]string{ + "GITHUB_EVENT_NAME": tc.eventName, + "GITHUB_EVENT_PATH": eventPath, + "GITHUB_OUTPUT": "output.txt", + "GITHUB_REPOSITORY": "coder/coder", + }, + }, fakeGitRepo{headSHA: "head123"}.runner(t)) + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestPublishPlanWritesCompactGitHubOutputs(t *testing.T) { + t.Parallel() + + root := t.TempDir() + matrixPath := filepath.Join(root, "matrix.json") + summaryPath := filepath.Join(root, "summary.md") + outputPath := filepath.Join(root, "output.txt") + stepSummaryPath := filepath.Join(root, "step-summary.md") + summary := "## Summary\n" + err := publishPlan(outputSinks{ + OutMatrix: matrixPath, + OutSummary: summaryPath, + GitHubOutput: outputPath, + GitHubStepSummary: stepSummaryPath, + }, matrixOutput{Include: []matrixEntry{{Package: "./pkg", RunRegex: "^(TestAlpha)(/.*)?$", TestCount: "10"}}}, summary, nil, 0) + require.NoError(t, err) + + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + wantMatrix := `{"include":[{"package":"./pkg","run_regex":"^(TestAlpha)(/.*)?$","test_count":"10"}]}` + require.Equal(t, wantMatrix+"\n", string(matrixData)) + + outputData, err := os.ReadFile(outputPath) + require.NoError(t, err) + require.Equal(t, "matrix="+wantMatrix+"\n", string(outputData)) + outputValue := strings.TrimSuffix(strings.TrimPrefix(string(outputData), "matrix="), "\n") + require.NotContains(t, outputValue, "\n") + + localSummary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Equal(t, summary, string(localSummary)) + stepSummary, err := os.ReadFile(stepSummaryPath) + require.NoError(t, err) + require.Equal(t, summary, string(stepSummary)) +} + +func TestPublishPlanWritesEmptyMatrixAndRejectsUnsafeOutput(t *testing.T) { + t.Parallel() + + matrixData, err := marshalMatrix(matrixOutput{}) + require.NoError(t, err) + require.Equal(t, `{"include":[]}`, string(matrixData)) + + err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "first\nsecond", 0) + require.ErrorContains(t, err, "single line") + + err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "too-long", 3) + require.ErrorContains(t, err, "above the 3 byte limit") +} + +func writeGitHubEvent(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "event.json") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f7737bf --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/coder/testselect + +go 1.26.2 + +require ( + github.com/stretchr/testify v1.10.0 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e9f82d4 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..5642d51 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRunWithRealGitHandlesAddedFileAtRevision(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + runGit(t, repoRoot, "init") + runGit(t, repoRoot, "config", "user.email", "test@example.com") + runGit(t, repoRoot, "config", "user.name", "Test User") + runGit(t, repoRoot, "commit", "--allow-empty", "-m", "base") + baseSHA := strings.TrimSpace(runGit(t, repoRoot, "rev-parse", "HEAD")) + + writeTestFile(t, repoRoot, "pkg/new_test.go", `package sample + +import "testing" + +func TestAdded(t *testing.T) { + t.Log("added") +} +`) + runGit(t, repoRoot, "add", ".") + runGit(t, repoRoot, "commit", "-m", "head") + headSHA := strings.TrimSpace(runGit(t, repoRoot, "rev-parse", "HEAD")) + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: baseSHA, HeadSHA: headSHA, OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, execGit) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./pkg", matrix.Include[0].Package) + require.Equal(t, "^(TestAdded)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "TestAdded") +} + +func TestRunWithRealGitHandlesDeletedSetupFile(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + runGit(t, repoRoot, "init") + runGit(t, repoRoot, "config", "user.email", "test@example.com") + runGit(t, repoRoot, "config", "user.name", "Test User") + writeTestFile(t, repoRoot, "pkg/setup_test.go", `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() +} +`) + writeTestFile(t, repoRoot, "pkg/alpha_test.go", `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + runGit(t, repoRoot, "add", ".") + runGit(t, repoRoot, "commit", "-m", "base") + baseSHA := strings.TrimSpace(runGit(t, repoRoot, "rev-parse", "HEAD")) + + runGit(t, repoRoot, "rm", "pkg/setup_test.go") + runGit(t, repoRoot, "commit", "-m", "head") + headSHA := strings.TrimSpace(runGit(t, repoRoot, "rev-parse", "HEAD")) + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: baseSHA, HeadSHA: headSHA, OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, execGit) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./pkg", matrix.Include[0].Package) + require.Equal(t, "^(TestAlpha)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "pkg/setup_test.go") + require.Contains(t, string(summary), "TestAlpha") +} + +func TestEnsureRangeAvailableWithRealGitFetchesMovedBase(t *testing.T) { + t.Parallel() + + requireGit(t) + root := t.TempDir() + workRoot := filepath.Join(root, "work") + bareRoot := filepath.Join(root, "upstream.git") + cloneRoot := filepath.Join(root, "clone") + require.NoError(t, os.MkdirAll(workRoot, 0o755)) + runGit(t, workRoot, "init") + runGit(t, workRoot, "config", "user.email", "test@example.com") + runGit(t, workRoot, "config", "user.name", "Test User") + writeTestFile(t, workRoot, "pkg/sample_test.go", `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("base") +} +`) + runGit(t, workRoot, "add", ".") + runGit(t, workRoot, "commit", "-m", "base") + runGit(t, workRoot, "branch", "-M", "main") + + runGit(t, workRoot, "checkout", "-b", "feature") + writeTestFile(t, workRoot, "pkg/sample_test.go", `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("feature") +} +`) + runGit(t, workRoot, "commit", "-am", "feature") + headSHA := strings.TrimSpace(runGit(t, workRoot, "rev-parse", "HEAD")) + + runGit(t, workRoot, "checkout", "main") + writeTestFile(t, workRoot, "README.md", "base branch moved\n") + runGit(t, workRoot, "add", "README.md") + runGit(t, workRoot, "commit", "-m", "move base") + baseSHA := strings.TrimSpace(runGit(t, workRoot, "rev-parse", "HEAD")) + + runGit(t, workRoot, "init", "--bare", bareRoot) + runGit(t, workRoot, "remote", "add", "origin", bareRoot) + runGit(t, workRoot, "push", "origin", "main", "feature") + runGit(t, root, "clone", "--single-branch", "--branch", "feature", "file://"+bareRoot, cloneRoot) + _, err := execGit(t.Context(), cloneRoot, "cat-file", "-e", baseSHA+"^{commit}") + require.Error(t, err) + + req := runRequest{ + RepoRoot: cloneRoot, + Range: diffRange{BaseSHA: baseSHA, HeadSHA: headSHA}, + Prepare: []fetchSpec{ + {Remote: "origin", Ref: remoteTrackingFetchRef(defaultDispatchBaseRef)}, + {Remote: "origin", Ref: baseSHA}, + }, + } + err = ensureRangeAvailable(t.Context(), &req, execGit, execGitFetch) + require.NoError(t, err) + changed := strings.TrimSpace(runGit(t, cloneRoot, "diff", "--name-only", baseSHA+"..."+headSHA)) + require.Equal(t, "pkg/sample_test.go", changed) +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skipf("git is not available on PATH: %v", err) + } +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + output, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %s failed: %s", strings.Join(args, " "), string(output)) + return string(output) +} diff --git a/inventory.go b/inventory.go new file mode 100644 index 0000000..8b395e4 --- /dev/null +++ b/inventory.go @@ -0,0 +1,152 @@ +package main + +import ( + "cmp" + "context" + "maps" + "path/filepath" + "slices" + "strings" + + "golang.org/x/xerrors" +) + +type inventoryCache struct { + cfg config + git gitRunner + fileLists map[string][]string + packages map[string]packageInventory +} + +func newInventoryCache(cfg config, git gitRunner) *inventoryCache { + return &inventoryCache{ + cfg: cfg, + git: git, + fileLists: map[string][]string{}, + packages: map[string]packageInventory{}, + } +} + +func (cache *inventoryCache) loadPackageInventory(ctx context.Context, revision string, key packageKey) (packageInventory, error) { + cacheKey := revision + "\x00" + key.Dir + "\x00" + key.Name + if inventory, ok := cache.packages[cacheKey]; ok { + return inventory, nil + } + + files, err := cache.listTestFilesInDir(ctx, revision, key.Dir) + if err != nil { + return packageInventory{}, err + } + inventory := packageInventory{ + Key: key, + Tests: map[string][]testDecl{}, + } + for _, filePath := range files { + data, exists, err := readFileAtRevision(ctx, cache.cfg, cache.git, revision, filePath) + if err != nil { + return packageInventory{}, err + } + if !exists { + continue + } + snapshot, err := parseOrFallbackSnapshot(data) + if err != nil { + return packageInventory{}, xerrors.Errorf("parse %s at %s: %w", filePath, revision, err) + } + if snapshot.packageName != key.Name { + continue + } + for testName, declRange := range snapshot.tests { + inventory.Tests[testName] = append(inventory.Tests[testName], testDecl{FilePath: filePath, Range: declRange}) + } + } + cache.packages[cacheKey] = inventory + return inventory, nil +} + +func (cache *inventoryCache) listTestFilesInDir(ctx context.Context, revision, dir string) ([]string, error) { + cleanDir := filepath.ToSlash(filepath.Clean(dir)) + cacheKey := revision + "\x00" + cleanDir + if files, ok := cache.fileLists[cacheKey]; ok { + return files, nil + } + pathspec := cleanDir + if pathspec == "" { + pathspec = "." + } + result, err := cache.git(ctx, cache.cfg.RepoRoot, "ls-tree", "-r", "-z", "--name-only", revision, "--", pathspec) + if err != nil { + return nil, err + } + files := make([]string, 0) + for part := range strings.SplitSeq(result.Stdout, "\x00") { + if part == "" { + continue + } + filePath := cleanGitPath(part) + if !isRunnableTestFilePath(filePath) { + continue + } + if filepath.ToSlash(filepath.Dir(filePath)) != cleanDir { + continue + } + files = append(files, filePath) + } + slices.Sort(files) + cache.fileLists[cacheKey] = files + return files, nil +} + +func (cache *inventoryCache) directoryWideSelections(ctx context.Context, revision, dir string, files map[string]struct{}) ([]*packageSelection, error) { + inventories, err := cache.loadDirectoryInventories(ctx, revision, dir) + if err != nil { + return nil, err + } + selections := make([]*packageSelection, 0, len(inventories)) + for _, inventory := range inventories { + selection := allPackageTestsSelectionForFiles(inventory, maps.Clone(files)) + if selection == nil { + continue + } + selections = append(selections, selection) + } + return selections, nil +} + +func (cache *inventoryCache) loadDirectoryInventories(ctx context.Context, revision, dir string) ([]packageInventory, error) { + files, err := cache.listTestFilesInDir(ctx, revision, dir) + if err != nil { + return nil, err + } + packageNames := map[string]struct{}{} + for _, filePath := range files { + data, exists, err := readFileAtRevision(ctx, cache.cfg, cache.git, revision, filePath) + if err != nil { + return nil, err + } + if !exists { + continue + } + snapshot, err := parseOrFallbackSnapshot(data) + if err != nil { + return nil, xerrors.Errorf("parse %s at %s: %w", filePath, revision, err) + } + packageNames[snapshot.packageName] = struct{}{} + } + keys := make([]packageKey, 0, len(packageNames)) + for packageName := range packageNames { + keys = append(keys, packageKey{Dir: filepath.ToSlash(filepath.Clean(dir)), Name: packageName}) + } + slices.SortFunc(keys, func(left, right packageKey) int { + return cmp.Compare(left.Name, right.Name) + }) + inventories := make([]packageInventory, 0, len(keys)) + for _, key := range keys { + inventory, err := cache.loadPackageInventory(ctx, revision, key) + if err != nil { + return nil, err + } + inventories = append(inventories, inventory) + } + return inventories, nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..f5a12d1 --- /dev/null +++ b/main_test.go @@ -0,0 +1,2297 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +func TestSelectTestsForSnapshots(t *testing.T) { + t.Parallel() + + const changedPath = "pkg/changed_test.go" + change := testFileChange{Kind: changeModified, OldPath: changedPath, NewPath: changedPath} + + tests := []struct { + name string + oldData []byte + newData []byte + inventory packageInventory + hunks []diffHunk + wantTests []string + wantBroadened bool + wantNoSelection bool + }{ + { + name: "body change selects only changed test", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`, `t.Log("before alpha")`), + New: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`, `t.Log("changed alpha")`), + }}, + wantTests: []string{"TestAlpha"}, + }, + { + name: "new top-level test selects only new test", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(7), + New: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, `t.Log("new beta")`), + }}, + wantTests: []string{"TestBeta"}, + }, + { + name: "existing helper change broadens across package", + oldData: []byte(`package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("before helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`), + newData: []byte(`package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("changed helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("changed helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + setup(t) +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("before helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, `t.Log("before helper")`), + New: singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("changed helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, `t.Log("changed helper")`), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "package variable change broadens across package", + oldData: []byte(`package sample + +import "testing" + +var packageValue = 1 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`), + newData: []byte(`package sample + +import "testing" + +var packageValue = 2 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +var packageValue = 2 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log(packageValue) +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +var packageValue = 1 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`, "var packageValue = 1"), + New: singleLineRange(t, `package sample + +import "testing" + +var packageValue = 2 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`, "var packageValue = 2"), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "additive import broadens package", + oldData: []byte(`package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`), + newData: []byte(`package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(singleLineRange(t, `package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, `"testing"`).Start), + New: singleLineRange(t, `package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, `"fmt"`), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "additive helper with new test stays narrow", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(7), + New: rangeSpan( + singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`, "func setupCase(t *testing.T) {"), + singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`, "setupCase(t)"), + ), + }}, + wantTests: []string{"TestBeta"}, + }, + { + name: "removed import broadens across package", + oldData: []byte(`package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + newData: []byte(`package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, `"fmt"`), + New: emptyRangeAt(singleLineRange(t, `package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, `"testing"`).Start), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "TestMain broadens across sibling files in same package", + oldData: []byte(`package sample + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} +`), + newData: []byte(`package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} +`, `os.Exit(m.Run())`), + New: singleLineRange(t, `package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`, `fmt.Println("setup")`), + }}, + wantTests: []string{"TestAlpha"}, + wantBroadened: true, + }, + { + name: "init broadens across sibling files in same package", + oldData: []byte(`package sample + +import "testing" + +func init() { + register("before") +} +`), + newData: []byte(`package sample + +import "testing" + +func init() { + register("after") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func init() { + register("after") +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func init() { + register("before") +} +`, `register("before")`), + New: singleLineRange(t, `package sample + +import "testing" + +func init() { + register("after") +} +`, `register("after")`), + }}, + wantTests: []string{"TestAlpha"}, + wantBroadened: true, + }, + { + name: "malformed changed file broadens package conservatively", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestGamma(t *testing.T) { + t.Log("gamma") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} +`, `t.Log("before alpha")`), + New: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, `t.Log("changed alpha")`), + }}, + wantTests: []string{"TestAlpha", "TestBeta", "TestGamma"}, + wantBroadened: true, + }, + { + name: "deleted helper uses old snapshot to broaden package", + oldData: []byte(`package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }), + hunks: []diffHunk{{ + Old: rangeSpan( + singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, "func setup(t *testing.T) {"), + singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, `t.Log("helper")`), + ), + New: emptyRangeAt(singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, `func TestAlpha(t *testing.T) {`).Start), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "brand-new file with additive hunk selects only new tests", + oldData: nil, + newData: []byte(`package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(1), + New: rangeSpan( + singleLineRange(t, `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, "func TestBeta(t *testing.T) {"), + singleLineRange(t, `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, `t.Log("new beta")`), + ), + }}, + wantTests: []string{"TestBeta"}, + }, + { + name: "dot imported testing is recognized", + oldData: []byte(`package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("before alpha") +} +`), + newData: []byte(`package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("changed alpha") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("changed alpha") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("before alpha") +} +`, `t.Log("before alpha")`), + New: singleLineRange(t, `package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("changed alpha") +} +`, `t.Log("changed alpha")`), + }}, + wantTests: []string{"TestAlpha"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + selection := selectTestsForSnapshots(change, tt.oldData, tt.newData, tt.inventory, tt.hunks) + if tt.wantNoSelection { + require.Nil(t, selection) + return + } + require.NotNil(t, selection) + require.Equal(t, tt.wantTests, selectionNames(selection)) + require.Equal(t, tt.wantBroadened, selection.Broadened) + }) + } +} + +func TestSelectTestsForSnapshotsTreatsTestMethodsAsSharedHelpers(t *testing.T) { + t.Parallel() + + change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} + oldData := []byte(`package sample + +import "testing" + +type suite struct{} + +func (suite) TestMethod(t *testing.T) { + t.Log("before method") +} + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + newData := []byte(`package sample + +import "testing" + +type suite struct{} + +func (suite) TestMethod(t *testing.T) { + t.Log("changed method") +} + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + "pkg/changed_test.go": string(newData), + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }) + selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + Old: singleLineRange(t, string(oldData), `t.Log("before method")`), + New: singleLineRange(t, string(newData), `t.Log("changed method")`), + }}) + require.NotNil(t, selection) + require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) + require.True(t, selection.Broadened) +} + +func TestSelectTestsForSnapshotsAdditiveSharedDeclsStayNarrow(t *testing.T) { + t.Parallel() + + change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} + basePrefix := `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +` + cases := []struct { + name string + declaration string + needle string + }{ + {name: "var", declaration: "var packageValue = 1\n", needle: "var packageValue = 1"}, + {name: "const", declaration: "const packageValue = 1\n", needle: "const packageValue = 1"}, + {name: "type", declaration: "type packageValue struct{}\n", needle: "type packageValue struct{}"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + oldData := []byte(basePrefix) + newData := []byte(basePrefix + tt.declaration + ` +func TestBeta(t *testing.T) { + t.Log("beta") +} +`) + inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + "pkg/changed_test.go": string(newData), + }) + selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + Old: emptyRangeAt(7), + New: rangeSpan( + singleLineRange(t, string(newData), tt.needle), + singleLineRange(t, string(newData), `t.Log("beta")`), + ), + }}) + require.NotNil(t, selection) + require.Equal(t, []string{"TestBeta"}, selectionNames(selection)) + require.False(t, selection.Broadened) + }) + } +} + +func TestSelectTestsForSnapshotsBroadensAddedImports(t *testing.T) { + t.Parallel() + + change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} + oldData := []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + newData := []byte(`package sample + +import ( + _ "example.com/sideeffect" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + "pkg/changed_test.go": string(newData), + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }) + selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + Old: emptyRangeAt(3), + New: singleLineRange(t, string(newData), `_ "example.com/sideeffect"`), + }}) + require.NotNil(t, selection) + require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) + require.True(t, selection.Broadened) +} + +func TestParseFileSnapshotRejectsLowercaseSuffixes(t *testing.T) { + t.Parallel() + + snapshot, err := parseFileSnapshot([]byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) {} +func Testify(t *testing.T) {} +func FuzzAlpha(f *testing.F) {} +func Fuzzbar(f *testing.F) {} +func Example() {} +func ExampleFoo() {} +func Examplefoo() {} +`)) + require.NoError(t, err) + require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) +} + +func TestFallbackTestNamesRejectsLowercaseSuffixes(t *testing.T) { + t.Parallel() + + data := []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) {} +func Testify(t *testing.T) {} +func FuzzAlpha(f *testing.F) {} +func Fuzzbar(f *testing.F) {} +func Example() {} +func ExampleFoo() {} +func Examplefoo() {} +`) + require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, fallbackTestNames(data)) +} + +func TestParseChangeKindAcceptsTypeChanges(t *testing.T) { + t.Parallel() + + kind, err := parseChangeKind("T") + require.NoError(t, err) + require.Equal(t, changeType, kind) +} + +func TestParseDiffHunks(t *testing.T) { + t.Parallel() + + hunks, err := parseDiffHunks(strings.Join([]string{ + "@@ -10 +12 @@", + "@@ -0,0 +5,3 @@", + "@@ -20,4 +30,6 @@", + "@@ malformed @@", + }, "\n")) + require.NoError(t, err) + require.Equal(t, []diffHunk{ + {Old: lineRange{Start: 10, End: 10}, New: lineRange{Start: 12, End: 12}}, + {Old: lineRange{Start: 1, End: 0}, New: lineRange{Start: 5, End: 7}}, + {Old: lineRange{Start: 20, End: 23}, New: lineRange{Start: 30, End: 35}}, + }, hunks) +} + +func TestParseNonNegativeInt(t *testing.T) { + t.Parallel() + + value, err := parseNonNegativeInt("0") + require.NoError(t, err) + require.Zero(t, value) + + value, err = parseNonNegativeInt("42") + require.NoError(t, err) + require.Equal(t, 42, value) + + _, err = parseNonNegativeInt("x") + require.Error(t, err) +} + +func TestRenderSummaryNoChangedFiles(t *testing.T) { + t.Parallel() + + summary := renderSummary(nil, summaryReport{}) + require.Contains(t, summary, "No changed `*_test.go` files were detected") +} + +func TestRenderSummaryNoRunnableTests(t *testing.T) { + t.Parallel() + + summary := renderSummary([]string{"pkg/changed_test.go"}, summaryReport{}) + require.Contains(t, summary, "no runnable top-level tests were selected") + require.Contains(t, summary, "pkg/changed_test.go") +} + +func TestBuildRunRegexRejectsUnsafeNames(t *testing.T) { + t.Parallel() + + _, err := buildRunRegex([]string{"TestAlpha", "TestO'Brien"}) + require.Error(t, err) +} + +func TestBuildExecutionPlanRunsAllForUnsafeTestNames(t *testing.T) { + t.Parallel() + + selection := &packageSelection{ + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{"TestAlpha": {}, "TestĪ›": {}}, + Files: map[string]struct{}{"pkg/sample_test.go": {}}, + } + result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, 1) + require.Empty(t, result.Matrix.Include[0].RunRegex) + require.Equal(t, "1", result.Matrix.Include[0].TestCount) + require.True(t, result.Summary.Entries[0].RunAll) + require.Contains(t, result.Summary.Entries[0].Notes[0], "cannot be passed safely") +} + +func TestBuildExecutionPlanRejectsUnsafePackagePaths(t *testing.T) { + t.Parallel() + + key := packageKey{Dir: "pkg$(echo bad)", Name: "sample"} + _, err := buildExecutionPlan(map[packageKey]*packageSelection{ + key: { + Key: key, + Tests: map[string]struct{}{"TestAlpha": {}}, + Files: map[string]struct{}{"pkg$(echo bad)/sample_test.go": {}}, + }, + }) + require.ErrorContains(t, err, "unsafe package path") +} + +func TestBuildExecutionPlanCapsBroadenedTarget(t *testing.T) { + t.Parallel() + + selection := &packageSelection{ + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{}, + Files: map[string]struct{}{"pkg/setup_test.go": {}}, + Broadened: true, + } + for index := range maxBroadenedTests + 1 { + selection.Tests[fmt.Sprintf("Test%03d", index)] = struct{}{} + } + result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, 1) + require.Equal(t, "1", result.Matrix.Include[0].TestCount) + require.Empty(t, result.Matrix.Include[0].RunRegex) + require.True(t, result.Summary.Entries[0].RunAll) + require.Contains(t, result.Summary.Entries[0].Notes[0], "above the 50-test cap") +} + +func TestBuildExecutionPlanCapsMatrixTargets(t *testing.T) { + t.Parallel() + + selections := map[packageKey]*packageSelection{} + for index := range maxMatrixEntries + maxOverflowSummaries + 2 { + key := packageKey{Dir: fmt.Sprintf("pkg%02d", index), Name: "sample"} + selections[key] = &packageSelection{ + Key: key, + Tests: map[string]struct{}{fmt.Sprintf("Test%02d", index): {}}, + Files: map[string]struct{}{fmt.Sprintf("pkg%02d/file_test.go", index): {}}, + } + } + result, err := buildExecutionPlan(selections) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, maxMatrixEntries) + overflow := result.Matrix.Include[len(result.Matrix.Include)-1] + require.Equal(t, "1", overflow.TestCount) + require.Empty(t, overflow.RunRegex) + require.Contains(t, overflow.Package, "./pkg") + require.Contains(t, result.Summary.Notes[0], "Matrix target cap") + require.Contains(t, result.Summary.Entries[len(result.Summary.Entries)-1].Notes[1], "and 3 more") +} + +func TestRunValidationErrors(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + var stderr bytes.Buffer + neverGit := func(_ context.Context, _ string, _ ...string) (gitResult, error) { + return gitResult{}, xerrors.New("git should not be called") + } + + err := run(t.Context(), config{OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.EqualError(t, err, "--base-sha is required") + + err = run(t.Context(), config{BaseSHA: "base"}, &stdout, &stderr, neverGit) + require.EqualError(t, err, "--out-matrix is required") + + err = run(t.Context(), config{BaseSHA: "-bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.ErrorContains(t, err, "must not start with '-'") + + err = run(t.Context(), config{BaseSHA: "base:bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.ErrorContains(t, err, "must not contain ':'") + + err = run(t.Context(), config{BaseSHA: "base\x00bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.ErrorContains(t, err, "must not contain NUL bytes") +} + +func TestRunWritesMatrixAndSummaryWithPackageScopedEntries(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + baseFiles := map[string]string{ + "pkgone/shared_test.go": `package one + +import "testing" + +func TestShared(t *testing.T) { + t.Log("before one") +} +`, + "pkgtwo/shared_test.go": `package two + +import "testing" + +func TestShared(t *testing.T) { + t.Log("before two") +} +`, + } + headFiles := map[string]string{ + "pkgone/shared_test.go": `package one + +import "testing" + +func TestShared(t *testing.T) { + t.Log("changed one") +} +`, + "pkgtwo/shared_test.go": `package two + +import "testing" + +func TestShared(t *testing.T) { + t.Log("changed two") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{ + {Kind: changeModified, OldPath: "pkgone/shared_test.go", NewPath: "pkgone/shared_test.go"}, + {Kind: changeModified, OldPath: "pkgtwo/shared_test.go", NewPath: "pkgtwo/shared_test.go"}, + }, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkgone/shared_test.go": diffForChange( + singleLineRange(t, baseFiles["pkgone/shared_test.go"], `t.Log("before one")`), + singleLineRange(t, headFiles["pkgone/shared_test.go"], `t.Log("changed one")`), + ), + "pkgtwo/shared_test.go": diffForChange( + singleLineRange(t, baseFiles["pkgtwo/shared_test.go"], `t.Log("before two")`), + singleLineRange(t, headFiles["pkgtwo/shared_test.go"], `t.Log("changed two")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + require.Empty(t, stdout.String()) + require.Contains(t, stderr.String(), "selected 2 package targets") + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 2) + require.Equal(t, "./pkgone", matrix.Include[0].Package) + require.Equal(t, "^(TestShared)(/.*)?$", matrix.Include[0].RunRegex) + require.Equal(t, "10", matrix.Include[0].TestCount) + require.Equal(t, "./pkgtwo", matrix.Include[1].Package) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "Selected 2 tests across 2 package targets") + require.Contains(t, string(summary), "### `./pkgone`") + require.Contains(t, string(summary), "### `./pkgtwo`") +} + +func TestRunWritesSummaryToStdout(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + baseFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before") +} +`, + } + headFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("after") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/sample_test.go", NewPath: "pkg/sample_test.go"}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkg/sample_test.go": diffForChange( + singleLineRange(t, baseFiles["pkg/sample_test.go"], `t.Log("before")`), + singleLineRange(t, headFiles["pkg/sample_test.go"], `t.Log("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: "-"}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + require.Contains(t, stdout.String(), "## Go test flake detector selection") + require.Contains(t, stdout.String(), "### `./pkg`") + require.Contains(t, stderr.String(), "selected 1 package targets") +} + +func TestRunBroadensTestMainAcrossPackageAndPackageTest(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + baseFiles := map[string]string{ + "pkg/setup_test.go": `package sample + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestInternal(t *testing.T) { + t.Log("internal") +} +`, + "pkg/external_test.go": `package sample_test + +import "testing" + +func TestExternal(t *testing.T) { + t.Log("external") +} +`, + } + headFiles := map[string]string{ + "pkg/setup_test.go": `package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`, + "pkg/internal_test.go": baseFiles["pkg/internal_test.go"], + "pkg/external_test.go": baseFiles["pkg/external_test.go"], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/setup_test.go", NewPath: "pkg/setup_test.go"}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkg/setup_test.go": diffForChange( + singleLineRange(t, baseFiles["pkg/setup_test.go"], `os.Exit(m.Run())`), + singleLineRange(t, headFiles["pkg/setup_test.go"], `fmt.Println("setup")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./pkg", matrix.Include[0].Package) + require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "TestInternal") + require.Contains(t, string(summary), "TestExternal") +} + +func TestRunHandlesRename(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + oldPath := "pkg/old_test.go" + newPath := "pkg/new_test.go" + baseFiles := map[string]string{ + oldPath: `package sample + +import "testing" + +func TestRenamed(t *testing.T) { + t.Log("before rename") +} +`, + } + headFiles := map[string]string{ + newPath: `package sample + +import "testing" + +func TestRenamed(t *testing.T) { + t.Log("after rename") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + oldPath + "\x00" + newPath: diffForChange( + singleLineRange(t, baseFiles[oldPath], `t.Log("before rename")`), + singleLineRange(t, headFiles[newPath], `t.Log("after rename")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./pkg", matrix.Include[0].Package) + require.Equal(t, "^(TestRenamed)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), newPath) +} + +func TestRunUsesHeadRevisionInsteadOfWorkingTree(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + writeTestFile(t, repoRoot, "pkg/sample_test.go", `package sample + +import "testing" + +func TestWorkingTree(t *testing.T) { + t.Log("working tree") +} +`) + + baseFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + } + headFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestHead(t *testing.T) { + t.Log("head") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/sample_test.go", NewPath: "pkg/sample_test.go"}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkg/sample_test.go": diffForChange( + singleLineRange(t, baseFiles["pkg/sample_test.go"], `func TestAlpha`), + singleLineRange(t, headFiles["pkg/sample_test.go"], `func TestHead`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestHead)(/.*)?$", matrix.Include[0].RunRegex) + require.NotContains(t, string(matrixData), "TestWorkingTree") +} + +func TestRunSkipsNonRunnableChangedTestFiles(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + headFiles := map[string]string{ + "pkg/testdata/example_test.go": `package sample + +import "testing" + +func TestIgnored(t *testing.T) { + t.Log("ignored") +} +`, + "pkg/_ignored_test.go": `package sample + +import "testing" + +func TestUnderscoreIgnored(t *testing.T) { + t.Log("ignored") +} +`, + "pkg/.hidden_test.go": `package sample + +import "testing" + +func TestHiddenIgnored(t *testing.T) { + t.Log("ignored") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{ + {Kind: changeAdded, NewPath: "pkg/testdata/example_test.go"}, + {Kind: changeAdded, NewPath: "pkg/_ignored_test.go"}, + {Kind: changeAdded, NewPath: "pkg/.hidden_test.go"}, + }, + revisions: map[string]map[string]string{"base": {}, "head": headFiles}, + diffOutputs: map[string]string{}, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Empty(t, matrix.Include) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "No changed `*_test.go` files were detected") +} + +func TestRunToleratesDuplicateRunnableNamesInPackageInventory(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + linuxPath := "pkg/platform_linux_test.go" + windowsPath := "pkg/platform_windows_test.go" + baseFiles := map[string]string{ + linuxPath: `//go:build linux + +package sample + +import "testing" + +func TestPlatform(t *testing.T) { + t.Log("linux before") +} +`, + windowsPath: `//go:build windows + +package sample + +import "testing" + +func TestPlatform(t *testing.T) { + t.Log("windows") +} +`, + } + headFiles := map[string]string{ + linuxPath: `//go:build linux + +package sample + +import "testing" + +func TestPlatform(t *testing.T) { + t.Log("linux after") +} +`, + windowsPath: baseFiles[windowsPath], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: linuxPath, NewPath: linuxPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + linuxPath: diffForChange( + singleLineRange(t, baseFiles[linuxPath], `t.Log("linux before")`), + singleLineRange(t, headFiles[linuxPath], `t.Log("linux after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestPlatform)(/.*)?$", matrix.Include[0].RunRegex) +} + +func TestBuildExecutionPlanKeepsSameNamePackageAndExternalTestsPrecise(t *testing.T) { + t.Parallel() + + selections := map[packageKey]*packageSelection{ + {Dir: "pkg", Name: "sample"}: { + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{"TestShared": {}}, + Files: map[string]struct{}{"pkg/internal_test.go": {}}, + }, + {Dir: "pkg", Name: "sample_test"}: { + Key: packageKey{Dir: "pkg", Name: "sample_test"}, + Tests: map[string]struct{}{"TestShared": {}}, + Files: map[string]struct{}{"pkg/external_test.go": {}}, + }, + } + result, err := buildExecutionPlan(selections) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, 1) + require.Equal(t, "./pkg", result.Matrix.Include[0].Package) + require.Equal(t, "^(TestShared)(/.*)?$", result.Matrix.Include[0].RunRegex) + require.Equal(t, "10", result.Matrix.Include[0].TestCount) + require.False(t, result.Summary.Entries[0].RunAll) + require.Empty(t, result.Summary.Entries[0].Notes) +} + +func TestMergePackageSelectionCombinesSamePackageFiles(t *testing.T) { + t.Parallel() + + key := packageKey{Dir: "pkg", Name: "sample"} + selections := map[packageKey]*packageSelection{} + mergePackageSelection(selections, &packageSelection{ + Key: key, + Tests: map[string]struct{}{"TestAlpha": {}}, + Files: map[string]struct{}{"pkg/alpha_test.go": {}}, + }) + mergePackageSelection(selections, &packageSelection{ + Key: key, + Tests: map[string]struct{}{"TestBeta": {}}, + Files: map[string]struct{}{"pkg/beta_test.go": {}}, + Broadened: true, + }) + + require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selections[key])) + require.True(t, selections[key].Broadened) + require.Contains(t, selections[key].Files, "pkg/alpha_test.go") + require.Contains(t, selections[key].Files, "pkg/beta_test.go") +} + +func TestRunHandlesDeletedSetupFile(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + setupPath := "pkg/setup_test.go" + testPath := "pkg/alpha_test.go" + baseFiles := map[string]string{ + setupPath: `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("setup") +} +`, + testPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + } + headFiles := map[string]string{ + testPath: baseFiles[testPath], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeDeleted, OldPath: setupPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + setupPath: diffForChange( + singleLineRange(t, baseFiles[setupPath], `t.Log("setup")`), + emptyRangeAt(1), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestAlpha)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), setupPath) + require.Contains(t, string(summary), "TestAlpha") +} + +func TestRunBroadensInitAcrossPackageAndPackageTest(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + setupPath := "pkg/external_setup_test.go" + baseFiles := map[string]string{ + setupPath: `package sample_test + +func init() { + println("before") +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestInternal(t *testing.T) { + t.Log("internal") +} +`, + "pkg/external_test.go": `package sample_test + +import "testing" + +func TestExternal(t *testing.T) { + t.Log("external") +} +`, + } + headFiles := map[string]string{ + setupPath: `package sample_test + +func init() { + println("after") +} +`, + "pkg/internal_test.go": baseFiles["pkg/internal_test.go"], + "pkg/external_test.go": baseFiles["pkg/external_test.go"], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: setupPath, NewPath: setupPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + setupPath: diffForChange( + singleLineRange(t, baseFiles[setupPath], `println("before")`), + singleLineRange(t, headFiles[setupPath], `println("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) +} + +func TestRunHandlesCrossDirectoryRenamePrecisely(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + oldPath := "oldpkg/moved_test.go" + newPath := "newpkg/moved_test.go" + baseFiles := map[string]string{ + oldPath: `package oldpkg + +import "testing" + +func TestMoved(t *testing.T) { + t.Log("before") +} +`, + "oldpkg/stable_test.go": `package oldpkg + +import "testing" + +func TestOldStable(t *testing.T) { + t.Log("old") +} +`, + } + headFiles := map[string]string{ + newPath: `package newpkg + +import "testing" + +func TestMoved(t *testing.T) { + t.Log("after") +} +`, + "oldpkg/stable_test.go": baseFiles["oldpkg/stable_test.go"], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + oldPath + "\x00" + newPath: diffForChange( + singleLineRange(t, baseFiles[oldPath], `t.Log("before")`), + singleLineRange(t, headFiles[newPath], `t.Log("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./newpkg", matrix.Include[0].Package) + require.Equal(t, "^(TestMoved)(/.*)?$", matrix.Include[0].RunRegex) +} + +func TestRunHandlesCrossDirectoryRenameSourceFallout(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + oldPath := "oldpkg/setup_test.go" + newPath := "newpkg/setup_test.go" + baseFiles := map[string]string{ + oldPath: `package oldpkg + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("before") +} +`, + "oldpkg/stable_test.go": `package oldpkg + +import "testing" + +func TestOldStable(t *testing.T) { + t.Log("old") +} +`, + } + headFiles := map[string]string{ + newPath: `package newpkg + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("after") +} +`, + "oldpkg/stable_test.go": baseFiles["oldpkg/stable_test.go"], + "newpkg/stable_test.go": `package newpkg + +import "testing" + +func TestNewStable(t *testing.T) { + t.Log("new") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + oldPath + "\x00" + newPath: diffForChange( + singleLineRange(t, baseFiles[oldPath], `t.Log("before")`), + singleLineRange(t, headFiles[newPath], `t.Log("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./oldpkg", matrix.Include[0].Package) + require.Equal(t, "^(TestOldStable)(/.*)?$", matrix.Include[0].RunRegex) +} + +func TestReadFileAtRevisionPropagatesExistenceCheckFailures(t *testing.T) { + t.Parallel() + + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "head": { + "pkg/sample_test.go": `package sample +`, + }, + }, + failures: map[string]gitResponse{ + gitKey("ls-tree", "-z", "--name-only", "head", "--", "pkg/sample_test.go"): { + result: gitResult{Stderr: "fatal: ls-tree failed", ExitCode: 128}, + err: xerrors.New("fatal: ls-tree failed"), + }, + }, + } + _, _, err := readFileAtRevision(t.Context(), config{RepoRoot: t.TempDir()}, repo.runner(t), "head", "pkg/sample_test.go") + require.ErrorContains(t, err, "check whether pkg/sample_test.go exists at head") +} + +func selectionNames(selection *packageSelection) []string { + if selection == nil { + return nil + } + return slices.Sorted(maps.Keys(selection.Tests)) +} + +func mustPackageInventory(t *testing.T, dir, packageName string, files map[string]string) packageInventory { + t.Helper() + inventory := packageInventory{ + Key: packageKey{Dir: dir, Name: packageName}, + Tests: map[string][]testDecl{}, + } + for filePath, content := range files { + snapshot, err := parseOrFallbackSnapshot([]byte(content)) + require.NoError(t, err) + require.Equal(t, packageName, snapshot.packageName) + for testName, declRange := range snapshot.tests { + inventory.Tests[testName] = append(inventory.Tests[testName], testDecl{FilePath: filePath, Range: declRange}) + } + } + return inventory +} + +func diffForChange(oldRange, newRange lineRange) string { + return fmt.Sprintf("@@ -%s +%s @@\n", formatDiffRange(oldRange), formatDiffRange(newRange)) +} + +func formatDiffRange(r lineRange) string { + if !r.hasLines() { + start := r.Start + if start == 0 { + start = 1 + } + return fmt.Sprintf("%d,0", start) + } + count := r.End - r.Start + 1 + if count == 1 { + return fmt.Sprintf("%d", r.Start) + } + return fmt.Sprintf("%d,%d", r.Start, count) +} + +func singleLineRange(t *testing.T, content, needle string) lineRange { + t.Helper() + line := lineNumberForSubstring(t, content, needle) + return lineRange{Start: line, End: line} +} + +func rangeSpan(start, end lineRange) lineRange { + return lineRange{Start: start.Start, End: end.End} +} + +func emptyRangeAt(start int) lineRange { + return lineRange{Start: start, End: start - 1} +} + +func lineNumberForSubstring(t *testing.T, content, needle string) int { + t.Helper() + lineNumber := 0 + for index, line := range strings.Split(content, "\n") { + if !strings.Contains(line, needle) { + continue + } + if lineNumber != 0 { + t.Fatalf("needle %q matched more than once", needle) + } + lineNumber = index + 1 + } + if lineNumber == 0 { + t.Fatalf("needle %q not found", needle) + } + return lineNumber +} + +func writeTestFile(t *testing.T, root, relativePath, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(relativePath)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} + +type fakeGitRepo struct { + changes []testFileChange + revisions map[string]map[string]string + diffOutputs map[string]string + mergeBases map[string]string + headSHA string + failures map[string]gitResponse +} + +type gitResponse struct { + result gitResult + err error +} + +func (repo fakeGitRepo) runner(t *testing.T) gitRunner { + t.Helper() + return func(_ context.Context, _ string, args ...string) (gitResult, error) { + t.Helper() + if response, ok := repo.failures[gitKey(args...)]; ok { + return response.result, response.err + } + switch args[0] { + case "diff": + return repo.diffResponse(t, args) + case "cat-file": + return repo.catFileResponse(t, args) + case "show": + return repo.showResponse(t, args) + case "ls-tree": + return repo.lsTreeResponse(t, args) + case "merge-base": + return repo.mergeBaseResponse(t, args) + case "rev-parse": + return repo.revParseResponse(t, args) + default: + t.Fatalf("unexpected git command: %v", args) + return gitResult{}, nil + } + } +} + +func (repo fakeGitRepo) diffResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + if len(args) >= 2 && args[1] == "--name-status" { + return gitResult{Stdout: repo.nameStatusOutput()}, nil + } + separator := slices.Index(args, "--") + require.NotEqual(t, -1, separator) + paths := args[separator+1:] + output, ok := repo.diffOutputs[strings.Join(paths, "\x00")] + if !ok { + t.Fatalf("unexpected diff paths %q", strings.Join(paths, "\x00")) + } + return gitResult{Stdout: output}, nil +} + +func (repo fakeGitRepo) nameStatusOutput() string { + parts := make([]string, 0, len(repo.changes)*3) + for _, change := range repo.changes { + switch change.Kind { + case changeRenamed: + parts = append(parts, "R100", change.OldPath, change.NewPath) + case changeAdded: + parts = append(parts, string(change.Kind), change.NewPath) + case changeDeleted: + parts = append(parts, string(change.Kind), change.OldPath) + default: + parts = append(parts, string(change.Kind), change.displayPath()) + } + } + return strings.Join(parts, "\x00") + "\x00" +} + +func (repo fakeGitRepo) catFileResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Len(t, args, 3) + require.Equal(t, "-e", args[1]) + spec := args[2] + if strings.HasSuffix(spec, "^{commit}") { + revision := strings.TrimSuffix(spec, "^{commit}") + if _, ok := repo.revisions[revision]; ok { + return gitResult{}, nil + } + return gitFailure(128, fmt.Sprintf("fatal: bad revision %q", revision)) + } + revision, path := splitRevisionPath(t, spec) + if _, ok := repo.revisions[revision][path]; ok { + return gitResult{}, nil + } + return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) +} + +func (repo fakeGitRepo) showResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Len(t, args, 2) + revision, path := splitRevisionPath(t, args[1]) + content, ok := repo.revisions[revision][path] + if !ok { + return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) + } + return gitResult{Stdout: content}, nil +} + +func (repo fakeGitRepo) lsTreeResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + separator := slices.Index(args, "--") + require.Greater(t, separator, 1) + require.Less(t, separator+1, len(args)) + revision := args[separator-1] + pathspec := cleanGitPath(args[separator+1]) + files := make([]string, 0) + for filePath := range repo.revisions[revision] { + cleanPath := cleanGitPath(filePath) + if pathspec != "." && !strings.HasPrefix(cleanPath, pathspec+"/") && cleanPath != pathspec { + continue + } + files = append(files, cleanPath) + } + slices.Sort(files) + return gitResult{Stdout: strings.Join(files, "\x00") + "\x00"}, nil +} + +func (repo fakeGitRepo) mergeBaseResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Len(t, args, 3) + key := gitKey(args...) + if repo.mergeBases != nil { + if base, ok := repo.mergeBases[key]; ok { + return gitResult{Stdout: base + "\n"}, nil + } + } + left := args[1] + if _, ok := repo.revisions[left]; ok { + return gitResult{Stdout: left + "\n"}, nil + } + return gitFailure(1, fmt.Sprintf("fatal: no merge base for %s and %s", args[1], args[2])) +} + +func (repo fakeGitRepo) revParseResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Equal(t, []string{"rev-parse", "HEAD"}, args) + head := repo.headSHA + if head == "" { + head = "head" + } + return gitResult{Stdout: head + "\n"}, nil +} + +func splitRevisionPath(t *testing.T, spec string) (revision string, path string) { + t.Helper() + revision, path, ok := strings.Cut(spec, ":") + require.True(t, ok) + return revision, cleanGitPath(path) +} + +func gitFailure(exitCode int, stderr string) (gitResult, error) { + return gitResult{Stderr: stderr, ExitCode: exitCode}, xerrors.New(stderr) +} + +func gitKey(args ...string) string { + // NUL is a stable separator because git diff pathspecs can contain spaces. + return strings.Join(args, "\x00") +} diff --git a/plan.go b/plan.go new file mode 100644 index 0000000..3e5742c --- /dev/null +++ b/plan.go @@ -0,0 +1,312 @@ +package main + +import ( + "context" + "fmt" + "maps" + "path/filepath" + "regexp" + "slices" + "strings" + + "golang.org/x/xerrors" +) + +var ( + safeTestNameRE = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + safePackagePatternRE = regexp.MustCompile(`^(?:\.|\./[A-Za-z0-9._/-]+)$`) +) + +type matrixOutput struct { + Include []matrixEntry `json:"include"` +} + +type matrixEntry struct { + Package string `json:"package"` + RunRegex string `json:"run_regex,omitempty"` + TestCount string `json:"test_count"` +} + +type summaryReport struct { + Entries []summaryEntry + Notes []string +} + +type summaryEntry struct { + Label string + Files []string + Tests []string + RunAll bool + TestCount string + Notes []string +} + +type buildResult struct { + Matrix matrixOutput + Summary summaryReport +} + +type executionAccumulator struct { + Package string + Files map[string]struct{} + Tests map[string]struct{} + Broadened bool + RunAll bool + TestCount string + Notes []string +} + +func selectTestPlan(ctx context.Context, cfg config, git gitRunner) ([]string, buildResult, error) { + changes, err := listChangedTestFiles(ctx, cfg, git) + if err != nil { + return nil, buildResult{}, err + } + changedFiles := make([]string, 0, len(changes)) + for _, change := range changes { + changedFiles = append(changedFiles, change.displayPath()) + } + + cache := newInventoryCache(cfg, git) + selections := map[packageKey]*packageSelection{} + for _, change := range changes { + if err := selectChange(ctx, cfg, git, cache, selections, change); err != nil { + return nil, buildResult{}, err + } + } + + result, err := buildExecutionPlan(selections) + if err != nil { + return nil, buildResult{}, err + } + return changedFiles, result, nil +} + +func mergePackageSelection(selections map[packageKey]*packageSelection, selection *packageSelection) { + merged := selections[selection.Key] + if merged == nil { + merged = &packageSelection{ + Key: selection.Key, + Tests: map[string]struct{}{}, + Files: map[string]struct{}{}, + } + selections[selection.Key] = merged + } + merged.Broadened = merged.Broadened || selection.Broadened + maps.Copy(merged.Files, selection.Files) + maps.Copy(merged.Tests, selection.Tests) +} + +func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResult, error) { + accumulators := map[string]*executionAccumulator{} + for key, selection := range selections { + packagePath := packagePattern(key.Dir) + if !safePackagePatternRE.MatchString(packagePath) { + return buildResult{}, xerrors.Errorf("unsafe package path %q", packagePath) + } + entry := accumulators[packagePath] + if entry == nil { + entry = &executionAccumulator{ + Package: packagePath, + Files: map[string]struct{}{}, + Tests: map[string]struct{}{}, + TestCount: defaultTargetCount, + } + accumulators[packagePath] = entry + } + entry.Broadened = entry.Broadened || selection.Broadened + maps.Copy(entry.Files, selection.Files) + maps.Copy(entry.Tests, selection.Tests) + } + + orderedPackages := slices.Sorted(maps.Keys(accumulators)) + result := buildResult{Matrix: matrixOutput{Include: []matrixEntry{}}} + for _, packagePath := range orderedPackages { + entry := accumulators[packagePath] + tests := slices.Sorted(maps.Keys(entry.Tests)) + files := slices.Sorted(maps.Keys(entry.Files)) + if entry.Broadened && len(tests) > maxBroadenedTests { + entry.RunAll = true + entry.TestCount = runOnceTargetCount + entry.Notes = appendUniqueNote(entry.Notes, fmt.Sprintf("Package-wide broadening selected %d tests, above the %d-test cap, so this target will run all tests once.", len(tests), maxBroadenedTests)) + } + if unsafeTestNames := unsafeRunRegexTestNames(tests); len(unsafeTestNames) > 0 { + entry.RunAll = true + entry.TestCount = runOnceTargetCount + entry.Notes = appendUniqueNote(entry.Notes, fmt.Sprintf("Selected %d test names that cannot be passed safely through RUN, so this target will run all tests once.", len(unsafeTestNames))) + } + runRegex := "" + if !entry.RunAll { + var err error + runRegex, err = buildRunRegex(tests) + if err != nil { + return buildResult{}, xerrors.Errorf("build run regex for %s: %w", packagePath, err) + } + } + result.Matrix.Include = append(result.Matrix.Include, matrixEntry{ + Package: packagePath, + RunRegex: runRegex, + TestCount: entry.TestCount, + }) + result.Summary.Entries = append(result.Summary.Entries, summaryEntry{ + Label: packagePath, + Files: files, + Tests: tests, + RunAll: entry.RunAll, + TestCount: entry.TestCount, + Notes: entry.Notes, + }) + } + + if len(result.Matrix.Include) > maxMatrixEntries { + keep := maxMatrixEntries - 1 + overflowPackages := make([]string, 0, len(result.Matrix.Include)-keep) + overflowFiles := map[string]struct{}{} + for _, entry := range result.Matrix.Include[keep:] { + overflowPackages = append(overflowPackages, entry.Package) + } + for _, entry := range result.Summary.Entries[keep:] { + for _, filePath := range entry.Files { + overflowFiles[filePath] = struct{}{} + } + } + note := fmt.Sprintf("Matrix target cap %d hit. Collapsed %d additional packages into one overflow target that runs once.", maxMatrixEntries, len(overflowPackages)) + result.Matrix.Include = result.Matrix.Include[:keep] + result.Matrix.Include = append(result.Matrix.Include, matrixEntry{ + Package: strings.Join(overflowPackages, " "), + TestCount: runOnceTargetCount, + }) + result.Summary.Entries = result.Summary.Entries[:keep] + result.Summary.Entries = append(result.Summary.Entries, summaryEntry{ + Label: fmt.Sprintf("overflow target (%d packages)", len(overflowPackages)), + Files: slices.Sorted(maps.Keys(overflowFiles)), + RunAll: true, + TestCount: runOnceTargetCount, + Notes: []string{ + note, + summarizePackages(overflowPackages), + }, + }) + result.Summary.Notes = appendUniqueNote(result.Summary.Notes, note) + } + + return result, nil +} + +func summarizePackages(packages []string) string { + display := packages + if len(display) > maxOverflowSummaries { + display = display[:maxOverflowSummaries] + } + quoted := make([]string, 0, len(display)) + for _, packagePath := range display { + quoted = append(quoted, "`"+packagePath+"`") + } + note := "Packages: " + strings.Join(quoted, ", ") + if len(packages) > len(display) { + note += fmt.Sprintf(", and %d more.", len(packages)-len(display)) + } + return note +} + +func appendUniqueNote(notes []string, note string) []string { + if note == "" || slices.Contains(notes, note) { + return notes + } + return append(notes, note) +} + +func unsafeRunRegexTestNames(tests []string) []string { + unsafeNames := make([]string, 0) + for _, testName := range tests { + if !safeTestNameRE.MatchString(testName) { + unsafeNames = append(unsafeNames, testName) + } + } + return unsafeNames +} + +func buildRunRegex(tests []string) (string, error) { + quoted := make([]string, 0, len(tests)) + for _, testName := range tests { + if !safeTestNameRE.MatchString(testName) { + return "", xerrors.Errorf("unsafe test name %q", testName) + } + quoted = append(quoted, regexp.QuoteMeta(testName)) + } + return "^(" + strings.Join(quoted, "|") + ")(/.*)?$", nil +} + +func renderSummary(changedFiles []string, summary summaryReport) string { + var builder strings.Builder + _, _ = builder.WriteString("## Go test flake detector selection\n\n") + if len(changedFiles) == 0 { + _, _ = builder.WriteString("No changed `*_test.go` files were detected.\n") + return builder.String() + } + if len(summary.Entries) == 0 { + _, _ = builder.WriteString("Changed `*_test.go` files were detected, but no runnable top-level tests were selected.\n\n") + _, _ = builder.WriteString("Files:\n") + for _, filePath := range changedFiles { + _, _ = builder.WriteString("- `" + filePath + "`\n") + } + return builder.String() + } + + totalTests := 0 + for _, entry := range summary.Entries { + totalTests += len(entry.Tests) + } + _, _ = fmt.Fprintf(&builder, "Selected %d tests across %d package targets.\n\n", totalTests, len(summary.Entries)) + if len(summary.Notes) > 0 { + _, _ = builder.WriteString("Notes:\n") + for _, note := range summary.Notes { + _, _ = builder.WriteString("- " + note + "\n") + } + _, _ = builder.WriteString("\n") + } + for _, entry := range summary.Entries { + _, _ = builder.WriteString("### `" + entry.Label + "`\n\n") + _, _ = builder.WriteString("Files:\n") + for _, filePath := range entry.Files { + _, _ = builder.WriteString("- `" + filePath + "`\n") + } + if len(entry.Notes) > 0 { + _, _ = builder.WriteString("\nNotes:\n") + for _, note := range entry.Notes { + _, _ = builder.WriteString("- " + note + "\n") + } + } + if entry.RunAll { + _, _ = builder.WriteString("\nRuns all tests in this target " + countDescription(entry.TestCount) + ".\n") + if len(entry.Tests) > 0 { + _, _ = builder.WriteString("\nAttributed tests:\n") + for _, testName := range entry.Tests { + _, _ = builder.WriteString("- `" + testName + "`\n") + } + } + _, _ = builder.WriteString("\n") + continue + } + _, _ = builder.WriteString("\nTests:\n") + for _, testName := range entry.Tests { + _, _ = builder.WriteString("- `" + testName + "`\n") + } + _, _ = builder.WriteString("\n") + } + return builder.String() +} + +func countDescription(count string) string { + if count == "1" { + return "once" + } + return count + " times" +} + +func packagePattern(dir string) string { + cleanDir := filepath.ToSlash(filepath.Clean(dir)) + if cleanDir == "." { + return "." + } + return "./" + cleanDir +} diff --git a/publish.go b/publish.go new file mode 100644 index 0000000..6d0e69c --- /dev/null +++ b/publish.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/xerrors" +) + +const defaultGitHubOutputValueLimit = 1024 * 1024 + +func publishPlan(sinks outputSinks, matrix matrixOutput, summary string, stdout io.Writer, outputSizeLimit int) error { + matrixData, err := marshalMatrix(matrix) + if err != nil { + return err + } + if sinks.OutMatrix != "" { + if err := writeFile(sinks.OutMatrix, appendNewline(matrixData)); err != nil { + return err + } + } + if sinks.OutSummary != "" { + if err := writeSummary(sinks.OutSummary, summary, stdout); err != nil { + return err + } + } + if sinks.GitHubOutput != "" { + if err := appendGitHubOutput(sinks.GitHubOutput, "matrix", string(matrixData), outputSizeLimit); err != nil { + return err + } + } + if sinks.GitHubStepSummary != "" { + if err := appendFile(sinks.GitHubStepSummary, []byte(summary)); err != nil { + return err + } + } + return nil +} + +func marshalMatrix(matrix matrixOutput) ([]byte, error) { + if matrix.Include == nil { + matrix.Include = []matrixEntry{} + } + data, err := json.Marshal(matrix) + if err != nil { + return nil, xerrors.Errorf("marshal matrix json: %w", err) + } + return data, nil +} + +func appendGitHubOutput(path, name, value string, outputSizeLimit int) error { + if strings.ContainsAny(value, "\r\n") { + return xerrors.Errorf("GitHub output %s must be a single line", name) + } + if outputSizeLimit == 0 { + outputSizeLimit = defaultGitHubOutputValueLimit + } + if len(value) > outputSizeLimit { + return xerrors.Errorf("GitHub output %s is %d bytes, above the %d byte limit", name, len(value), outputSizeLimit) + } + return appendFile(path, []byte(name+"="+value+"\n")) +} + +func writeSummary(path, summary string, stdout io.Writer) error { + if path == "-" { + _, err := io.WriteString(stdout, summary) + return err + } + return writeFile(path, []byte(summary)) +} + +func writeFile(path string, data []byte) error { + dir := filepath.Dir(path) + if dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return xerrors.Errorf("mkdir %s: %w", dir, err) + } + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return xerrors.Errorf("write %s: %w", path, err) + } + return nil +} + +func appendFile(path string, data []byte) error { + dir := filepath.Dir(path) + if dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return xerrors.Errorf("mkdir %s: %w", dir, err) + } + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return xerrors.Errorf("open %s: %w", path, err) + } + defer file.Close() + if _, err := file.Write(data); err != nil { + return xerrors.Errorf("append %s: %w", path, err) + } + return nil +} + +func appendNewline(data []byte) []byte { + withNewline := make([]byte, 0, len(data)+1) + withNewline = append(withNewline, data...) + withNewline = append(withNewline, '\n') + return withNewline +} diff --git a/request.go b/request.go new file mode 100644 index 0000000..9974162 --- /dev/null +++ b/request.go @@ -0,0 +1,53 @@ +package main + +import ( + "strings" + + "golang.org/x/xerrors" +) + +type diffRange struct { + BaseSHA string + HeadSHA string +} + +type runRequest struct { + RepoRoot string + Range diffRange + Prepare []fetchSpec + MergeBaseRef string + Sinks outputSinks + OutputSizeLimit int +} + +type fetchSpec struct { + Remote string + Ref string +} + +type outputSinks struct { + OutMatrix string + OutSummary string + GitHubOutput string + GitHubStepSummary string +} + +func validateRevision(flagName, revision string) error { + if revision == "" { + return xerrors.Errorf("%s is required", flagName) + } + if strings.HasPrefix(revision, "-") { + return xerrors.Errorf("%s must not start with '-': %q", flagName, revision) + } + if strings.Contains(revision, ":") { + return xerrors.Errorf("%s must not contain ':': %q", flagName, revision) + } + if strings.ContainsRune(revision, '\x00') { + return xerrors.Errorf("%s must not contain NUL bytes", flagName) + } + return nil +} + +func diffRangeSpec(cfg config) string { + return cfg.BaseSHA + "..." + cfg.HeadSHA +} diff --git a/selection.go b/selection.go new file mode 100644 index 0000000..109853d --- /dev/null +++ b/selection.go @@ -0,0 +1,294 @@ +package main + +import ( + "context" + "fmt" + "maps" + "path/filepath" + "slices" + + "golang.org/x/xerrors" +) + +type packageKey struct { + Dir string + Name string +} + +type testDecl struct { + FilePath string + Range lineRange +} + +type packageInventory struct { + Key packageKey + Tests map[string][]testDecl +} + +func (inventory packageInventory) allTests() []string { + return slices.Sorted(maps.Keys(inventory.Tests)) +} + +func (inventory packageInventory) hasTest(name string) bool { + _, ok := inventory.Tests[name] + return ok +} + +type packageSelection struct { + Key packageKey + Tests map[string]struct{} + Files map[string]struct{} + Broadened bool + DirectoryWide bool +} + +func selectChange(ctx context.Context, cfg config, git gitRunner, cache *inventoryCache, selections map[packageKey]*packageSelection, change testFileChange) error { + hunks, err := listDiffHunks(ctx, cfg, git, change) + if err != nil { + return xerrors.Errorf("list diff hunks for %s: %w", change.displayPath(), err) + } + if len(hunks) == 0 { + return nil + } + + oldData, oldExists, err := readChangeFile(ctx, cfg, git, cfg.BaseSHA, change.OldPath) + if err != nil { + return err + } + newData, newExists, err := readChangeFile(ctx, cfg, git, cfg.HeadSHA, change.NewPath) + if err != nil { + return err + } + if change.NewPath != "" && isRunnableTestFilePath(change.NewPath) && !newExists { + return xerrors.Errorf("head revision %s is missing %s", cfg.HeadSHA, change.NewPath) + } + + var oldKey packageKey + oldKeyOK := oldExists + if oldExists { + oldKey, err = packageKeyForData(change.OldPath, oldData) + if err != nil { + return xerrors.Errorf("resolve old package for %s: %w", change.displayPath(), err) + } + } + var newKey packageKey + newKeyOK := newExists + if newExists { + newKey, err = packageKeyForData(change.NewPath, newData) + if err != nil { + return xerrors.Errorf("resolve new package for %s: %w", change.displayPath(), err) + } + } + + if newKeyOK { + inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, newKey) + if err != nil { + return xerrors.Errorf("load package inventory for %s: %w", newKey.String(), err) + } + selectionOldData := oldData + selectionHunks := hunks + if !oldKeyOK || oldKey != newKey { + selectionOldData = nil + selectionHunks = newSideOnlyHunks(hunks) + } + selection := selectTestsForSnapshots(change, selectionOldData, newData, inventory, selectionHunks) + if err := mergeSelection(ctx, cache, cfg.HeadSHA, selections, selection); err != nil { + return err + } + } + + if oldKeyOK && (!newKeyOK || oldKey != newKey) { + inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, oldKey) + if err != nil { + return xerrors.Errorf("load package inventory for %s: %w", oldKey.String(), err) + } + sourceChange := testFileChange{Kind: changeDeleted, OldPath: change.OldPath} + selection := selectSourceRemovalForSnapshots(sourceChange, oldData, inventory, hunks) + if err := mergeSelection(ctx, cache, cfg.HeadSHA, selections, selection); err != nil { + return err + } + } + + return nil +} + +func packageKeyForData(filePath string, data []byte) (packageKey, error) { + snapshot, err := parseFileSnapshot(data) + if err != nil { + packageName, ok := fallbackPackageName(data) + if !ok { + return packageKey{}, xerrors.Errorf("parse package clause: %w", err) + } + return packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: packageName}, nil + } + return packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: snapshot.packageName}, nil +} + +func mergeSelection(ctx context.Context, cache *inventoryCache, revision string, selections map[packageKey]*packageSelection, selection *packageSelection) error { + if selection == nil { + return nil + } + if !selection.DirectoryWide { + if len(selection.Tests) > 0 { + mergePackageSelection(selections, selection) + } + return nil + } + + expanded, err := cache.directoryWideSelections(ctx, revision, selection.Key.Dir, selection.Files) + if err != nil { + return xerrors.Errorf("load directory-wide inventory for %s: %w", packagePattern(selection.Key.Dir), err) + } + for _, expandedSelection := range expanded { + mergePackageSelection(selections, expandedSelection) + } + return nil +} + +func selectTestsForSnapshots(change testFileChange, oldData, newData []byte, newInventory packageInventory, hunks []diffHunk) *packageSelection { + newSnapshot, err := parseFileSnapshot(newData) + if err != nil { + return allPackageTestsSelection(newInventory, change.displayPath()) + } + + if oldData == nil && needsOldSnapshot(hunks) { + return allPackageTestsSelection(newInventory, change.displayPath()) + } + + var oldSnapshot *fileSnapshot + if len(oldData) > 0 { + snapshot, err := parseFileSnapshot(oldData) + if err != nil { + if needsOldSnapshot(hunks) { + return allPackageTestsSelection(newInventory, change.displayPath()) + } + } else { + oldSnapshot = &snapshot + } + } + + selected := map[string]struct{}{} + for _, hunk := range hunks { + if oldSnapshot != nil { + switch scope := broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old); scope { + case broadeningDirectory: + return allDirectoryTestsSelection(newInventory, change.displayPath()) + case broadeningPackage: + return allPackageTestsSelection(newInventory, change.displayPath()) + } + } + switch scope := broadeningScopeForNewHunk(newSnapshot.shared, oldSnapshot, hunk.New); scope { + case broadeningDirectory: + return allDirectoryTestsSelection(newInventory, change.displayPath()) + case broadeningPackage: + return allPackageTestsSelection(newInventory, change.displayPath()) + } + addMatchingTests(selected, newSnapshot.tests, hunk.New) + if oldSnapshot == nil { + continue + } + for name, declRange := range oldSnapshot.tests { + if !declRange.overlaps(hunk.Old) { + continue + } + if newInventory.hasTest(name) { + selected[name] = struct{}{} + } + } + } + if len(selected) == 0 { + return nil + } + return &packageSelection{ + Key: newInventory.Key, + Tests: selected, + Files: map[string]struct{}{change.displayPath(): {}}, + } +} + +func selectSourceRemovalForSnapshots(change testFileChange, oldData []byte, inventory packageInventory, hunks []diffHunk) *packageSelection { + oldSnapshot, err := parseFileSnapshot(oldData) + if err != nil { + if needsOldSnapshot(hunks) { + return allPackageTestsSelection(inventory, change.displayPath()) + } + return nil + } + + selected := map[string]struct{}{} + for _, hunk := range hunks { + switch scope := broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old); scope { + case broadeningDirectory: + return allDirectoryTestsSelection(inventory, change.displayPath()) + case broadeningPackage: + return allPackageTestsSelection(inventory, change.displayPath()) + } + for name, declRange := range oldSnapshot.tests { + if declRange.overlaps(hunk.Old) && inventory.hasTest(name) { + selected[name] = struct{}{} + } + } + } + if len(selected) == 0 { + return nil + } + return &packageSelection{ + Key: inventory.Key, + Tests: selected, + Files: map[string]struct{}{change.displayPath(): {}}, + } +} + +func allPackageTestsSelection(inventory packageInventory, filePath string) *packageSelection { + return allPackageTestsSelectionForFiles(inventory, map[string]struct{}{filePath: {}}) +} + +func allPackageTestsSelectionForFiles(inventory packageInventory, files map[string]struct{}) *packageSelection { + selection := &packageSelection{ + Key: inventory.Key, + Tests: map[string]struct{}{}, + Files: files, + Broadened: true, + } + for _, testName := range inventory.allTests() { + selection.Tests[testName] = struct{}{} + } + if len(selection.Tests) == 0 { + return nil + } + return selection +} + +func allDirectoryTestsSelection(inventory packageInventory, filePath string) *packageSelection { + selection := allPackageTestsSelection(inventory, filePath) + if selection == nil { + selection = &packageSelection{ + Key: inventory.Key, + Tests: map[string]struct{}{}, + Files: map[string]struct{}{filePath: {}}, + Broadened: true, + } + } + selection.DirectoryWide = true + return selection +} + +func needsOldSnapshot(hunks []diffHunk) bool { + for _, hunk := range hunks { + if hunk.Old.hasLines() { + return true + } + } + return false +} +func addMatchingTests(selected map[string]struct{}, tests map[string]lineRange, candidate lineRange) { + for name, declRange := range tests { + if declRange.overlaps(candidate) { + selected[name] = struct{}{} + } + } +} + +func (key packageKey) String() string { + return fmt.Sprintf("%s (%s)", packagePattern(key.Dir), key.Name) +} diff --git a/snapshot.go b/snapshot.go new file mode 100644 index 0000000..9b68621 --- /dev/null +++ b/snapshot.go @@ -0,0 +1,327 @@ +package main + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "maps" + "regexp" + "slices" + "strings" + "unicode" + "unicode/utf8" +) + +var ( + packagePatternRE = regexp.MustCompile(`(?m)^package\s+([A-Za-z_][A-Za-z0-9_]*)\b`) + fallbackTestRE = regexp.MustCompile(`(?m)^func\s+((?:Test|Fuzz)[A-Z_][A-Za-z0-9_]*|Example(?:[A-Z_][A-Za-z0-9_]*)?)\s*\(`) +) + +type fileSnapshot struct { + packageName string + tests map[string]lineRange + shared []sharedDecl + sharedKeys map[string]struct{} + testingDotImport bool +} + +type sharedDeclKind uint8 + +const ( + sharedDeclImport sharedDeclKind = iota + 1 + sharedDeclVar + sharedDeclConst + sharedDeclType + sharedDeclHelper + sharedDeclInit + sharedDeclTestMain +) + +type sharedDecl struct { + Range lineRange + Kind sharedDeclKind + Keys []string +} + +func parseFileSnapshot(data []byte) (fileSnapshot, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "", data, parser.SkipObjectResolution) + if err != nil { + return fileSnapshot{}, err + } + + snapshot := fileSnapshot{ + packageName: file.Name.Name, + tests: map[string]lineRange{}, + sharedKeys: map[string]struct{}{}, + testingDotImport: hasTestingDotImport(file), + } + for _, decl := range file.Decls { + rangeForDecl := nodeRange(fset, decl) + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok { + genDecl, ok := decl.(*ast.GenDecl) + if !ok { + snapshot.addSharedDecl(sharedDecl{Range: rangeForDecl, Kind: sharedDeclHelper}) + continue + } + snapshot.addSharedDecl(classifyGenDecl(rangeForDecl, genDecl)) + continue + } + if funcDecl.Name == nil { + snapshot.addSharedDecl(sharedDecl{Range: rangeForDecl, Kind: sharedDeclHelper}) + continue + } + + name := funcDecl.Name.Name + switch { + case name == "TestMain": + snapshot.addSharedDecl(sharedDecl{ + Range: rangeForDecl, + Kind: sharedDeclTestMain, + Keys: []string{"func:TestMain"}, + }) + case name == "init": + snapshot.addSharedDecl(sharedDecl{Range: rangeForDecl, Kind: sharedDeclInit}) + case isTopLevelTestFunc(funcDecl, snapshot.testingDotImport), isTopLevelFuzzFunc(funcDecl, snapshot.testingDotImport), isTopLevelExampleFunc(funcDecl): + snapshot.tests[name] = rangeForDecl + default: + snapshot.addSharedDecl(sharedDecl{ + Range: rangeForDecl, + Kind: sharedDeclHelper, + Keys: []string{funcIdentity(fset, funcDecl)}, + }) + } + } + return snapshot, nil +} + +func hasTestingDotImport(file *ast.File) bool { + for _, importSpec := range file.Imports { + if importSpec == nil || importSpec.Path == nil { + continue + } + if importSpec.Name == nil || importSpec.Name.Name != "." { + continue + } + if strings.Trim(importSpec.Path.Value, `"`) == "testing" { + return true + } + } + return false +} + +func classifyGenDecl(rangeForDecl lineRange, decl *ast.GenDecl) sharedDecl { + shared := sharedDecl{Range: rangeForDecl} + switch decl.Tok { + case token.IMPORT: + shared.Kind = sharedDeclImport + case token.VAR: + shared.Kind = sharedDeclVar + shared.Keys = genDeclKeys("var", decl.Specs) + case token.CONST: + shared.Kind = sharedDeclConst + shared.Keys = genDeclKeys("const", decl.Specs) + case token.TYPE: + shared.Kind = sharedDeclType + shared.Keys = genDeclKeys("type", decl.Specs) + default: + shared.Kind = sharedDeclHelper + } + return shared +} + +func genDeclKeys(prefix string, specs []ast.Spec) []string { + keys := make([]string, 0, len(specs)) + for _, spec := range specs { + switch typed := spec.(type) { + case *ast.TypeSpec: + if typed.Name == nil || typed.Name.Name == "_" { + continue + } + keys = append(keys, prefix+":"+typed.Name.Name) + case *ast.ValueSpec: + for _, name := range typed.Names { + if name == nil || name.Name == "_" { + continue + } + keys = append(keys, prefix+":"+name.Name) + } + } + } + slices.Sort(keys) + return keys +} + +func funcIdentity(fset *token.FileSet, fn *ast.FuncDecl) string { + if fn.Name == nil { + return "" + } + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return "func:" + fn.Name.Name + } + return "method:" + exprString(fset, fn.Recv.List[0].Type) + "." + fn.Name.Name +} + +func exprString(fset *token.FileSet, expr ast.Expr) string { + var buffer bytes.Buffer + if err := printer.Fprint(&buffer, fset, expr); err != nil { + return fmt.Sprintf("%T", expr) + } + return buffer.String() +} + +func nodeRange(fset *token.FileSet, node ast.Node) lineRange { + start := fset.Position(node.Pos()).Line + end := fset.Position(node.End()).Line + if end < start { + end = start + } + return lineRange{Start: start, End: end} +} + +func isTopLevelTestFunc(fn *ast.FuncDecl, testingDotImport bool) bool { + if fn.Recv != nil || !hasRunnableName(fn.Name, "Test", false) { + return false + } + if hasParamSelectorName(fn, "T") { + return true + } + return testingDotImport && hasParamIdentName(fn, "T") +} + +func isTopLevelFuzzFunc(fn *ast.FuncDecl, testingDotImport bool) bool { + if fn.Recv != nil || !hasRunnableName(fn.Name, "Fuzz", false) { + return false + } + if hasParamSelectorName(fn, "F") { + return true + } + return testingDotImport && hasParamIdentName(fn, "F") +} + +func isTopLevelExampleFunc(fn *ast.FuncDecl) bool { + return fn.Recv == nil && hasRunnableName(fn.Name, "Example", true) && fn.Type != nil && fn.Type.Params != nil && len(fn.Type.Params.List) == 0 +} + +func hasRunnableName(name *ast.Ident, prefix string, allowBare bool) bool { + if name == nil || !strings.HasPrefix(name.Name, prefix) { + return false + } + rest := strings.TrimPrefix(name.Name, prefix) + if rest == "" { + return allowBare + } + r, _ := utf8.DecodeRuneInString(rest) + return r == '_' || !unicode.IsLower(r) +} + +func hasParamSelectorName(fn *ast.FuncDecl, expectedName string) bool { + if fn.Type == nil || fn.Type.Params == nil { + return false + } + params := fn.Type.Params.List + if len(params) != 1 { + return false + } + name, ok := pointerSelectorName(params[0].Type) + return ok && name == expectedName +} + +func hasParamIdentName(fn *ast.FuncDecl, expectedName string) bool { + if fn.Type == nil || fn.Type.Params == nil { + return false + } + params := fn.Type.Params.List + if len(params) != 1 { + return false + } + name, ok := pointerIdentName(params[0].Type) + return ok && name == expectedName +} + +func pointerSelectorName(expr ast.Expr) (string, bool) { + star, ok := expr.(*ast.StarExpr) + if !ok { + return "", false + } + selector, ok := star.X.(*ast.SelectorExpr) + if !ok || selector.Sel == nil { + return "", false + } + return selector.Sel.Name, true +} + +func pointerIdentName(expr ast.Expr) (string, bool) { + star, ok := expr.(*ast.StarExpr) + if !ok { + return "", false + } + ident, ok := star.X.(*ast.Ident) + if !ok { + return "", false + } + return ident.Name, true +} + +func fallbackPackageName(data []byte) (string, bool) { + matches := packagePatternRE.FindSubmatch(data) + if len(matches) < 2 { + return "", false + } + return string(matches[1]), true +} + +func fallbackTestNames(data []byte) []string { + matches := fallbackTestRE.FindAllSubmatch(data, -1) + selected := map[string]struct{}{} + for _, match := range matches { + if len(match) < 2 { + continue + } + selected[string(match[1])] = struct{}{} + } + return slices.Sorted(maps.Keys(selected)) +} + +func parseOrFallbackSnapshot(data []byte) (fileSnapshot, error) { + snapshot, err := parseFileSnapshot(data) + if err == nil { + return snapshot, nil + } + packageName, ok := fallbackPackageName(data) + if !ok { + return fileSnapshot{}, err + } + fallback := fileSnapshot{ + packageName: packageName, + tests: map[string]lineRange{}, + sharedKeys: map[string]struct{}{}, + } + for _, testName := range fallbackTestNames(data) { + fallback.tests[testName] = lineRange{} + } + return fallback, nil +} + +func (snapshot *fileSnapshot) addSharedDecl(decl sharedDecl) { + snapshot.shared = append(snapshot.shared, decl) + for _, key := range decl.Keys { + if key == "" { + continue + } + snapshot.sharedKeys[key] = struct{}{} + } +} + +func (snapshot *fileSnapshot) hasSharedKey(keys []string) bool { + for _, key := range keys { + if _, ok := snapshot.sharedKeys[key]; ok { + return true + } + } + return false +} From f8dc245f0d5988d8d351bc6243c6dcea10590ce0 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 19 May 2026 13:11:39 +0000 Subject: [PATCH 02/14] split: organize tests along source-file boundaries Split main_test.go (2297 lines) into focused *_test.go files mirroring the source layout, and move two helpers (mergePackageSelection, packagePattern) from plan.go to selection.go so layering is consistent (plan depends on selection, not vice versa). Test files now line up 1:1 with their production counterparts: cli_test.go - run/runCommand end-to-end tests diff_test.go - hunk/change parsing tests snapshot_test.go - parseFileSnapshot/fallback tests selection_test.go - selectTestsForSnapshots and merge tests plan_test.go - buildExecutionPlan/renderSummary tests publish_test.go - publishPlan tests (from githubactions_test.go) githubactions_test.go - GitHub event/range tests integration_test.go - real-git integration (unchanged) helpers_test.go - shared line-range + inventory helpers gitfake_test.go - fakeGitRepo mock + dispatcher methods go vet, go build, go test, and go test -race all pass. --- cli_test.go | 744 +++++++++++++ diff_test.go | 70 ++ gitfake_test.go | 176 ++++ githubactions_test.go | 51 - helpers_test.go | 95 ++ main_test.go | 2297 ----------------------------------------- plan.go | 24 - plan_test.go | 130 +++ publish_test.go | 60 ++ selection.go | 23 + selection_test.go | 1090 +++++++++++++++++++ snapshot_test.go | 46 + 12 files changed, 2434 insertions(+), 2372 deletions(-) create mode 100644 cli_test.go create mode 100644 diff_test.go create mode 100644 gitfake_test.go create mode 100644 helpers_test.go delete mode 100644 main_test.go create mode 100644 plan_test.go create mode 100644 publish_test.go create mode 100644 selection_test.go create mode 100644 snapshot_test.go diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..436e116 --- /dev/null +++ b/cli_test.go @@ -0,0 +1,744 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +func TestRunValidationErrors(t *testing.T) { + t.Parallel() + + var stdout bytes.Buffer + var stderr bytes.Buffer + neverGit := func(_ context.Context, _ string, _ ...string) (gitResult, error) { + return gitResult{}, xerrors.New("git should not be called") + } + + err := run(t.Context(), config{OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.EqualError(t, err, "--base-sha is required") + + err = run(t.Context(), config{BaseSHA: "base"}, &stdout, &stderr, neverGit) + require.EqualError(t, err, "--out-matrix is required") + + err = run(t.Context(), config{BaseSHA: "-bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.ErrorContains(t, err, "must not start with '-'") + + err = run(t.Context(), config{BaseSHA: "base:bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.ErrorContains(t, err, "must not contain ':'") + + err = run(t.Context(), config{BaseSHA: "base\x00bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + require.ErrorContains(t, err, "must not contain NUL bytes") +} + +func TestRunWritesMatrixAndSummaryWithPackageScopedEntries(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + baseFiles := map[string]string{ + "pkgone/shared_test.go": `package one + +import "testing" + +func TestShared(t *testing.T) { + t.Log("before one") +} +`, + "pkgtwo/shared_test.go": `package two + +import "testing" + +func TestShared(t *testing.T) { + t.Log("before two") +} +`, + } + headFiles := map[string]string{ + "pkgone/shared_test.go": `package one + +import "testing" + +func TestShared(t *testing.T) { + t.Log("changed one") +} +`, + "pkgtwo/shared_test.go": `package two + +import "testing" + +func TestShared(t *testing.T) { + t.Log("changed two") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{ + {Kind: changeModified, OldPath: "pkgone/shared_test.go", NewPath: "pkgone/shared_test.go"}, + {Kind: changeModified, OldPath: "pkgtwo/shared_test.go", NewPath: "pkgtwo/shared_test.go"}, + }, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkgone/shared_test.go": diffForChange( + singleLineRange(t, baseFiles["pkgone/shared_test.go"], `t.Log("before one")`), + singleLineRange(t, headFiles["pkgone/shared_test.go"], `t.Log("changed one")`), + ), + "pkgtwo/shared_test.go": diffForChange( + singleLineRange(t, baseFiles["pkgtwo/shared_test.go"], `t.Log("before two")`), + singleLineRange(t, headFiles["pkgtwo/shared_test.go"], `t.Log("changed two")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + require.Empty(t, stdout.String()) + require.Contains(t, stderr.String(), "selected 2 package targets") + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 2) + require.Equal(t, "./pkgone", matrix.Include[0].Package) + require.Equal(t, "^(TestShared)(/.*)?$", matrix.Include[0].RunRegex) + require.Equal(t, "10", matrix.Include[0].TestCount) + require.Equal(t, "./pkgtwo", matrix.Include[1].Package) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "Selected 2 tests across 2 package targets") + require.Contains(t, string(summary), "### `./pkgone`") + require.Contains(t, string(summary), "### `./pkgtwo`") +} + +func TestRunWritesSummaryToStdout(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + baseFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before") +} +`, + } + headFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("after") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/sample_test.go", NewPath: "pkg/sample_test.go"}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkg/sample_test.go": diffForChange( + singleLineRange(t, baseFiles["pkg/sample_test.go"], `t.Log("before")`), + singleLineRange(t, headFiles["pkg/sample_test.go"], `t.Log("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: "-"}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + require.Contains(t, stdout.String(), "## Go test flake detector selection") + require.Contains(t, stdout.String(), "### `./pkg`") + require.Contains(t, stderr.String(), "selected 1 package targets") +} + +func TestRunBroadensTestMainAcrossPackageAndPackageTest(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + baseFiles := map[string]string{ + "pkg/setup_test.go": `package sample + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestInternal(t *testing.T) { + t.Log("internal") +} +`, + "pkg/external_test.go": `package sample_test + +import "testing" + +func TestExternal(t *testing.T) { + t.Log("external") +} +`, + } + headFiles := map[string]string{ + "pkg/setup_test.go": `package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`, + "pkg/internal_test.go": baseFiles["pkg/internal_test.go"], + "pkg/external_test.go": baseFiles["pkg/external_test.go"], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/setup_test.go", NewPath: "pkg/setup_test.go"}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkg/setup_test.go": diffForChange( + singleLineRange(t, baseFiles["pkg/setup_test.go"], `os.Exit(m.Run())`), + singleLineRange(t, headFiles["pkg/setup_test.go"], `fmt.Println("setup")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./pkg", matrix.Include[0].Package) + require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "TestInternal") + require.Contains(t, string(summary), "TestExternal") +} + +func TestRunHandlesRename(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + oldPath := "pkg/old_test.go" + newPath := "pkg/new_test.go" + baseFiles := map[string]string{ + oldPath: `package sample + +import "testing" + +func TestRenamed(t *testing.T) { + t.Log("before rename") +} +`, + } + headFiles := map[string]string{ + newPath: `package sample + +import "testing" + +func TestRenamed(t *testing.T) { + t.Log("after rename") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + oldPath + "\x00" + newPath: diffForChange( + singleLineRange(t, baseFiles[oldPath], `t.Log("before rename")`), + singleLineRange(t, headFiles[newPath], `t.Log("after rename")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./pkg", matrix.Include[0].Package) + require.Equal(t, "^(TestRenamed)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), newPath) +} + +func TestRunUsesHeadRevisionInsteadOfWorkingTree(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + writeTestFile(t, repoRoot, "pkg/sample_test.go", `package sample + +import "testing" + +func TestWorkingTree(t *testing.T) { + t.Log("working tree") +} +`) + + baseFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + } + headFiles := map[string]string{ + "pkg/sample_test.go": `package sample + +import "testing" + +func TestHead(t *testing.T) { + t.Log("head") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/sample_test.go", NewPath: "pkg/sample_test.go"}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + "pkg/sample_test.go": diffForChange( + singleLineRange(t, baseFiles["pkg/sample_test.go"], `func TestAlpha`), + singleLineRange(t, headFiles["pkg/sample_test.go"], `func TestHead`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestHead)(/.*)?$", matrix.Include[0].RunRegex) + require.NotContains(t, string(matrixData), "TestWorkingTree") +} + +func TestRunSkipsNonRunnableChangedTestFiles(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + headFiles := map[string]string{ + "pkg/testdata/example_test.go": `package sample + +import "testing" + +func TestIgnored(t *testing.T) { + t.Log("ignored") +} +`, + "pkg/_ignored_test.go": `package sample + +import "testing" + +func TestUnderscoreIgnored(t *testing.T) { + t.Log("ignored") +} +`, + "pkg/.hidden_test.go": `package sample + +import "testing" + +func TestHiddenIgnored(t *testing.T) { + t.Log("ignored") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{ + {Kind: changeAdded, NewPath: "pkg/testdata/example_test.go"}, + {Kind: changeAdded, NewPath: "pkg/_ignored_test.go"}, + {Kind: changeAdded, NewPath: "pkg/.hidden_test.go"}, + }, + revisions: map[string]map[string]string{"base": {}, "head": headFiles}, + diffOutputs: map[string]string{}, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Empty(t, matrix.Include) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), "No changed `*_test.go` files were detected") +} + +func TestRunToleratesDuplicateRunnableNamesInPackageInventory(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + linuxPath := "pkg/platform_linux_test.go" + windowsPath := "pkg/platform_windows_test.go" + baseFiles := map[string]string{ + linuxPath: `//go:build linux + +package sample + +import "testing" + +func TestPlatform(t *testing.T) { + t.Log("linux before") +} +`, + windowsPath: `//go:build windows + +package sample + +import "testing" + +func TestPlatform(t *testing.T) { + t.Log("windows") +} +`, + } + headFiles := map[string]string{ + linuxPath: `//go:build linux + +package sample + +import "testing" + +func TestPlatform(t *testing.T) { + t.Log("linux after") +} +`, + windowsPath: baseFiles[windowsPath], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: linuxPath, NewPath: linuxPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + linuxPath: diffForChange( + singleLineRange(t, baseFiles[linuxPath], `t.Log("linux before")`), + singleLineRange(t, headFiles[linuxPath], `t.Log("linux after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestPlatform)(/.*)?$", matrix.Include[0].RunRegex) +} + +func TestRunHandlesDeletedSetupFile(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + setupPath := "pkg/setup_test.go" + testPath := "pkg/alpha_test.go" + baseFiles := map[string]string{ + setupPath: `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("setup") +} +`, + testPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + } + headFiles := map[string]string{ + testPath: baseFiles[testPath], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeDeleted, OldPath: setupPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + setupPath: diffForChange( + singleLineRange(t, baseFiles[setupPath], `t.Log("setup")`), + emptyRangeAt(1), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + summaryPath := filepath.Join(repoRoot, "summary.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestAlpha)(/.*)?$", matrix.Include[0].RunRegex) + + summary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Contains(t, string(summary), setupPath) + require.Contains(t, string(summary), "TestAlpha") +} + +func TestRunBroadensInitAcrossPackageAndPackageTest(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + setupPath := "pkg/external_setup_test.go" + baseFiles := map[string]string{ + setupPath: `package sample_test + +func init() { + println("before") +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestInternal(t *testing.T) { + t.Log("internal") +} +`, + "pkg/external_test.go": `package sample_test + +import "testing" + +func TestExternal(t *testing.T) { + t.Log("external") +} +`, + } + headFiles := map[string]string{ + setupPath: `package sample_test + +func init() { + println("after") +} +`, + "pkg/internal_test.go": baseFiles["pkg/internal_test.go"], + "pkg/external_test.go": baseFiles["pkg/external_test.go"], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeModified, OldPath: setupPath, NewPath: setupPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + setupPath: diffForChange( + singleLineRange(t, baseFiles[setupPath], `println("before")`), + singleLineRange(t, headFiles[setupPath], `println("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) +} + +func TestRunHandlesCrossDirectoryRenamePrecisely(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + oldPath := "oldpkg/moved_test.go" + newPath := "newpkg/moved_test.go" + baseFiles := map[string]string{ + oldPath: `package oldpkg + +import "testing" + +func TestMoved(t *testing.T) { + t.Log("before") +} +`, + "oldpkg/stable_test.go": `package oldpkg + +import "testing" + +func TestOldStable(t *testing.T) { + t.Log("old") +} +`, + } + headFiles := map[string]string{ + newPath: `package newpkg + +import "testing" + +func TestMoved(t *testing.T) { + t.Log("after") +} +`, + "oldpkg/stable_test.go": baseFiles["oldpkg/stable_test.go"], + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + oldPath + "\x00" + newPath: diffForChange( + singleLineRange(t, baseFiles[oldPath], `t.Log("before")`), + singleLineRange(t, headFiles[newPath], `t.Log("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./newpkg", matrix.Include[0].Package) + require.Equal(t, "^(TestMoved)(/.*)?$", matrix.Include[0].RunRegex) +} + +func TestRunHandlesCrossDirectoryRenameSourceFallout(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + oldPath := "oldpkg/setup_test.go" + newPath := "newpkg/setup_test.go" + baseFiles := map[string]string{ + oldPath: `package oldpkg + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("before") +} +`, + "oldpkg/stable_test.go": `package oldpkg + +import "testing" + +func TestOldStable(t *testing.T) { + t.Log("old") +} +`, + } + headFiles := map[string]string{ + newPath: `package newpkg + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("after") +} +`, + "oldpkg/stable_test.go": baseFiles["oldpkg/stable_test.go"], + "newpkg/stable_test.go": `package newpkg + +import "testing" + +func TestNewStable(t *testing.T) { + t.Log("new") +} +`, + } + repo := fakeGitRepo{ + changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, + revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, + diffOutputs: map[string]string{ + oldPath + "\x00" + newPath: diffForChange( + singleLineRange(t, baseFiles[oldPath], `t.Log("before")`), + singleLineRange(t, headFiles[newPath], `t.Log("after")`), + ), + }, + } + + matrixPath := filepath.Join(repoRoot, "matrix.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + require.NoError(t, err) + + var matrix matrixOutput + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(matrixData, &matrix)) + require.Len(t, matrix.Include, 1) + require.Equal(t, "./oldpkg", matrix.Include[0].Package) + require.Equal(t, "^(TestOldStable)(/.*)?$", matrix.Include[0].RunRegex) +} diff --git a/diff_test.go b/diff_test.go new file mode 100644 index 0000000..260cfe1 --- /dev/null +++ b/diff_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +func TestParseChangeKindAcceptsTypeChanges(t *testing.T) { + t.Parallel() + + kind, err := parseChangeKind("T") + require.NoError(t, err) + require.Equal(t, changeType, kind) +} + +func TestParseDiffHunks(t *testing.T) { + t.Parallel() + + hunks, err := parseDiffHunks(strings.Join([]string{ + "@@ -10 +12 @@", + "@@ -0,0 +5,3 @@", + "@@ -20,4 +30,6 @@", + "@@ malformed @@", + }, "\n")) + require.NoError(t, err) + require.Equal(t, []diffHunk{ + {Old: lineRange{Start: 10, End: 10}, New: lineRange{Start: 12, End: 12}}, + {Old: lineRange{Start: 1, End: 0}, New: lineRange{Start: 5, End: 7}}, + {Old: lineRange{Start: 20, End: 23}, New: lineRange{Start: 30, End: 35}}, + }, hunks) +} + +func TestParseNonNegativeInt(t *testing.T) { + t.Parallel() + + value, err := parseNonNegativeInt("0") + require.NoError(t, err) + require.Zero(t, value) + + value, err = parseNonNegativeInt("42") + require.NoError(t, err) + require.Equal(t, 42, value) + + _, err = parseNonNegativeInt("x") + require.Error(t, err) +} + +func TestReadFileAtRevisionPropagatesExistenceCheckFailures(t *testing.T) { + t.Parallel() + + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "head": { + "pkg/sample_test.go": `package sample +`, + }, + }, + failures: map[string]gitResponse{ + gitKey("ls-tree", "-z", "--name-only", "head", "--", "pkg/sample_test.go"): { + result: gitResult{Stderr: "fatal: ls-tree failed", ExitCode: 128}, + err: xerrors.New("fatal: ls-tree failed"), + }, + }, + } + _, _, err := readFileAtRevision(t.Context(), config{RepoRoot: t.TempDir()}, repo.runner(t), "head", "pkg/sample_test.go") + require.ErrorContains(t, err, "check whether pkg/sample_test.go exists at head") +} diff --git a/gitfake_test.go b/gitfake_test.go new file mode 100644 index 0000000..7d7f6e7 --- /dev/null +++ b/gitfake_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "fmt" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +type fakeGitRepo struct { + changes []testFileChange + revisions map[string]map[string]string + diffOutputs map[string]string + mergeBases map[string]string + headSHA string + failures map[string]gitResponse +} + +type gitResponse struct { + result gitResult + err error +} + +func (repo fakeGitRepo) runner(t *testing.T) gitRunner { + t.Helper() + return func(_ context.Context, _ string, args ...string) (gitResult, error) { + t.Helper() + if response, ok := repo.failures[gitKey(args...)]; ok { + return response.result, response.err + } + switch args[0] { + case "diff": + return repo.diffResponse(t, args) + case "cat-file": + return repo.catFileResponse(t, args) + case "show": + return repo.showResponse(t, args) + case "ls-tree": + return repo.lsTreeResponse(t, args) + case "merge-base": + return repo.mergeBaseResponse(t, args) + case "rev-parse": + return repo.revParseResponse(t, args) + default: + t.Fatalf("unexpected git command: %v", args) + return gitResult{}, nil + } + } +} + +func (repo fakeGitRepo) diffResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + if len(args) >= 2 && args[1] == "--name-status" { + return gitResult{Stdout: repo.nameStatusOutput()}, nil + } + separator := slices.Index(args, "--") + require.NotEqual(t, -1, separator) + paths := args[separator+1:] + output, ok := repo.diffOutputs[strings.Join(paths, "\x00")] + if !ok { + t.Fatalf("unexpected diff paths %q", strings.Join(paths, "\x00")) + } + return gitResult{Stdout: output}, nil +} + +func (repo fakeGitRepo) nameStatusOutput() string { + parts := make([]string, 0, len(repo.changes)*3) + for _, change := range repo.changes { + switch change.Kind { + case changeRenamed: + parts = append(parts, "R100", change.OldPath, change.NewPath) + case changeAdded: + parts = append(parts, string(change.Kind), change.NewPath) + case changeDeleted: + parts = append(parts, string(change.Kind), change.OldPath) + default: + parts = append(parts, string(change.Kind), change.displayPath()) + } + } + return strings.Join(parts, "\x00") + "\x00" +} + +func (repo fakeGitRepo) catFileResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Len(t, args, 3) + require.Equal(t, "-e", args[1]) + spec := args[2] + if strings.HasSuffix(spec, "^{commit}") { + revision := strings.TrimSuffix(spec, "^{commit}") + if _, ok := repo.revisions[revision]; ok { + return gitResult{}, nil + } + return gitFailure(128, fmt.Sprintf("fatal: bad revision %q", revision)) + } + revision, path := splitRevisionPath(t, spec) + if _, ok := repo.revisions[revision][path]; ok { + return gitResult{}, nil + } + return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) +} + +func (repo fakeGitRepo) showResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Len(t, args, 2) + revision, path := splitRevisionPath(t, args[1]) + content, ok := repo.revisions[revision][path] + if !ok { + return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) + } + return gitResult{Stdout: content}, nil +} + +func (repo fakeGitRepo) lsTreeResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + separator := slices.Index(args, "--") + require.Greater(t, separator, 1) + require.Less(t, separator+1, len(args)) + revision := args[separator-1] + pathspec := cleanGitPath(args[separator+1]) + files := make([]string, 0) + for filePath := range repo.revisions[revision] { + cleanPath := cleanGitPath(filePath) + if pathspec != "." && !strings.HasPrefix(cleanPath, pathspec+"/") && cleanPath != pathspec { + continue + } + files = append(files, cleanPath) + } + slices.Sort(files) + return gitResult{Stdout: strings.Join(files, "\x00") + "\x00"}, nil +} + +func (repo fakeGitRepo) mergeBaseResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Len(t, args, 3) + key := gitKey(args...) + if repo.mergeBases != nil { + if base, ok := repo.mergeBases[key]; ok { + return gitResult{Stdout: base + "\n"}, nil + } + } + left := args[1] + if _, ok := repo.revisions[left]; ok { + return gitResult{Stdout: left + "\n"}, nil + } + return gitFailure(1, fmt.Sprintf("fatal: no merge base for %s and %s", args[1], args[2])) +} + +func (repo fakeGitRepo) revParseResponse(t *testing.T, args []string) (gitResult, error) { + t.Helper() + require.Equal(t, []string{"rev-parse", "HEAD"}, args) + head := repo.headSHA + if head == "" { + head = "head" + } + return gitResult{Stdout: head + "\n"}, nil +} + +func splitRevisionPath(t *testing.T, spec string) (revision string, path string) { + t.Helper() + revision, path, ok := strings.Cut(spec, ":") + require.True(t, ok) + return revision, cleanGitPath(path) +} + +func gitFailure(exitCode int, stderr string) (gitResult, error) { + return gitResult{Stderr: stderr, ExitCode: exitCode}, xerrors.New(stderr) +} + +func gitKey(args ...string) string { + // NUL is a stable separator because git diff pathspecs can contain spaces. + return strings.Join(args, "\x00") +} diff --git a/githubactions_test.go b/githubactions_test.go index 08fe64d..8e4f793 100644 --- a/githubactions_test.go +++ b/githubactions_test.go @@ -4,7 +4,6 @@ import ( "context" "os" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" @@ -228,56 +227,6 @@ func TestGitHubActionsRunRequestValidatesInputsBeforeFetch(t *testing.T) { } } -func TestPublishPlanWritesCompactGitHubOutputs(t *testing.T) { - t.Parallel() - - root := t.TempDir() - matrixPath := filepath.Join(root, "matrix.json") - summaryPath := filepath.Join(root, "summary.md") - outputPath := filepath.Join(root, "output.txt") - stepSummaryPath := filepath.Join(root, "step-summary.md") - summary := "## Summary\n" - err := publishPlan(outputSinks{ - OutMatrix: matrixPath, - OutSummary: summaryPath, - GitHubOutput: outputPath, - GitHubStepSummary: stepSummaryPath, - }, matrixOutput{Include: []matrixEntry{{Package: "./pkg", RunRegex: "^(TestAlpha)(/.*)?$", TestCount: "10"}}}, summary, nil, 0) - require.NoError(t, err) - - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - wantMatrix := `{"include":[{"package":"./pkg","run_regex":"^(TestAlpha)(/.*)?$","test_count":"10"}]}` - require.Equal(t, wantMatrix+"\n", string(matrixData)) - - outputData, err := os.ReadFile(outputPath) - require.NoError(t, err) - require.Equal(t, "matrix="+wantMatrix+"\n", string(outputData)) - outputValue := strings.TrimSuffix(strings.TrimPrefix(string(outputData), "matrix="), "\n") - require.NotContains(t, outputValue, "\n") - - localSummary, err := os.ReadFile(summaryPath) - require.NoError(t, err) - require.Equal(t, summary, string(localSummary)) - stepSummary, err := os.ReadFile(stepSummaryPath) - require.NoError(t, err) - require.Equal(t, summary, string(stepSummary)) -} - -func TestPublishPlanWritesEmptyMatrixAndRejectsUnsafeOutput(t *testing.T) { - t.Parallel() - - matrixData, err := marshalMatrix(matrixOutput{}) - require.NoError(t, err) - require.Equal(t, `{"include":[]}`, string(matrixData)) - - err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "first\nsecond", 0) - require.ErrorContains(t, err, "single line") - - err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "too-long", 3) - require.ErrorContains(t, err, "above the 3 byte limit") -} - func writeGitHubEvent(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "event.json") diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..c0ae841 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func selectionNames(selection *packageSelection) []string { + if selection == nil { + return nil + } + return slices.Sorted(maps.Keys(selection.Tests)) +} + +func mustPackageInventory(t *testing.T, dir, packageName string, files map[string]string) packageInventory { + t.Helper() + inventory := packageInventory{ + Key: packageKey{Dir: dir, Name: packageName}, + Tests: map[string][]testDecl{}, + } + for filePath, content := range files { + snapshot, err := parseOrFallbackSnapshot([]byte(content)) + require.NoError(t, err) + require.Equal(t, packageName, snapshot.packageName) + for testName, declRange := range snapshot.tests { + inventory.Tests[testName] = append(inventory.Tests[testName], testDecl{FilePath: filePath, Range: declRange}) + } + } + return inventory +} + +func diffForChange(oldRange, newRange lineRange) string { + return fmt.Sprintf("@@ -%s +%s @@\n", formatDiffRange(oldRange), formatDiffRange(newRange)) +} + +func formatDiffRange(r lineRange) string { + if !r.hasLines() { + start := r.Start + if start == 0 { + start = 1 + } + return fmt.Sprintf("%d,0", start) + } + count := r.End - r.Start + 1 + if count == 1 { + return fmt.Sprintf("%d", r.Start) + } + return fmt.Sprintf("%d,%d", r.Start, count) +} + +func singleLineRange(t *testing.T, content, needle string) lineRange { + t.Helper() + line := lineNumberForSubstring(t, content, needle) + return lineRange{Start: line, End: line} +} + +func rangeSpan(start, end lineRange) lineRange { + return lineRange{Start: start.Start, End: end.End} +} + +func emptyRangeAt(start int) lineRange { + return lineRange{Start: start, End: start - 1} +} + +func lineNumberForSubstring(t *testing.T, content, needle string) int { + t.Helper() + lineNumber := 0 + for index, line := range strings.Split(content, "\n") { + if !strings.Contains(line, needle) { + continue + } + if lineNumber != 0 { + t.Fatalf("needle %q matched more than once", needle) + } + lineNumber = index + 1 + } + if lineNumber == 0 { + t.Fatalf("needle %q not found", needle) + } + return lineNumber +} + +func writeTestFile(t *testing.T, root, relativePath, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(relativePath)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) +} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index f5a12d1..0000000 --- a/main_test.go +++ /dev/null @@ -1,2297 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "maps" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - "github.com/stretchr/testify/require" - "golang.org/x/xerrors" -) - -func TestSelectTestsForSnapshots(t *testing.T) { - t.Parallel() - - const changedPath = "pkg/changed_test.go" - change := testFileChange{Kind: changeModified, OldPath: changedPath, NewPath: changedPath} - - tests := []struct { - name string - oldData []byte - newData []byte - inventory packageInventory - hunks []diffHunk - wantTests []string - wantBroadened bool - wantNoSelection bool - }{ - { - name: "body change selects only changed test", - oldData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`, `t.Log("before alpha")`), - New: singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`, `t.Log("changed alpha")`), - }}, - wantTests: []string{"TestAlpha"}, - }, - { - name: "new top-level test selects only new test", - oldData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, - }), - hunks: []diffHunk{{ - Old: emptyRangeAt(7), - New: singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, `t.Log("new beta")`), - }}, - wantTests: []string{"TestBeta"}, - }, - { - name: "existing helper change broadens across package", - oldData: []byte(`package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("before helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`), - newData: []byte(`package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("changed helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("changed helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - setup(t) -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("before helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, `t.Log("before helper")`), - New: singleLineRange(t, `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("changed helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, `t.Log("changed helper")`), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "package variable change broadens across package", - oldData: []byte(`package sample - -import "testing" - -var packageValue = 1 - -func TestAlpha(t *testing.T) { - t.Log(packageValue) -} -`), - newData: []byte(`package sample - -import "testing" - -var packageValue = 2 - -func TestAlpha(t *testing.T) { - t.Log(packageValue) -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -var packageValue = 2 - -func TestAlpha(t *testing.T) { - t.Log(packageValue) -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log(packageValue) -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -var packageValue = 1 - -func TestAlpha(t *testing.T) { - t.Log(packageValue) -} -`, "var packageValue = 1"), - New: singleLineRange(t, `package sample - -import "testing" - -var packageValue = 2 - -func TestAlpha(t *testing.T) { - t.Log(packageValue) -} -`, "var packageValue = 2"), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "additive import broadens package", - oldData: []byte(`package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`), - newData: []byte(`package sample - -import ( - "fmt" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import ( - "fmt" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - }), - hunks: []diffHunk{{ - Old: emptyRangeAt(singleLineRange(t, `package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, `"testing"`).Start), - New: singleLineRange(t, `package sample - -import ( - "fmt" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, `"fmt"`), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "additive helper with new test stays narrow", - oldData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func setupCase(t *testing.T) { - t.Helper() - t.Log("beta helper") -} - -func TestBeta(t *testing.T) { - setupCase(t) -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func setupCase(t *testing.T) { - t.Helper() - t.Log("beta helper") -} - -func TestBeta(t *testing.T) { - setupCase(t) -} -`, - }), - hunks: []diffHunk{{ - Old: emptyRangeAt(7), - New: rangeSpan( - singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func setupCase(t *testing.T) { - t.Helper() - t.Log("beta helper") -} - -func TestBeta(t *testing.T) { - setupCase(t) -} -`, "func setupCase(t *testing.T) {"), - singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func setupCase(t *testing.T) { - t.Helper() - t.Log("beta helper") -} - -func TestBeta(t *testing.T) { - setupCase(t) -} -`, "setupCase(t)"), - ), - }}, - wantTests: []string{"TestBeta"}, - }, - { - name: "removed import broadens across package", - oldData: []byte(`package sample - -import ( - "fmt" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), - newData: []byte(`package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import ( - "fmt" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, `"fmt"`), - New: emptyRangeAt(singleLineRange(t, `package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, `"testing"`).Start), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "TestMain broadens across sibling files in same package", - oldData: []byte(`package sample - -import ( - "os" - "testing" -) - -func TestMain(m *testing.M) { - os.Exit(m.Run()) -} -`), - newData: []byte(`package sample - -import ( - "fmt" - "os" - "testing" -) - -func TestMain(m *testing.M) { - fmt.Println("setup") - os.Exit(m.Run()) -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import ( - "fmt" - "os" - "testing" -) - -func TestMain(m *testing.M) { - fmt.Println("setup") - os.Exit(m.Run()) -} -`, - "pkg/internal_test.go": `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import ( - "os" - "testing" -) - -func TestMain(m *testing.M) { - os.Exit(m.Run()) -} -`, `os.Exit(m.Run())`), - New: singleLineRange(t, `package sample - -import ( - "fmt" - "os" - "testing" -) - -func TestMain(m *testing.M) { - fmt.Println("setup") - os.Exit(m.Run()) -} -`, `fmt.Println("setup")`), - }}, - wantTests: []string{"TestAlpha"}, - wantBroadened: true, - }, - { - name: "init broadens across sibling files in same package", - oldData: []byte(`package sample - -import "testing" - -func init() { - register("before") -} -`), - newData: []byte(`package sample - -import "testing" - -func init() { - register("after") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func init() { - register("after") -} -`, - "pkg/internal_test.go": `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -func init() { - register("before") -} -`, `register("before")`), - New: singleLineRange(t, `package sample - -import "testing" - -func init() { - register("after") -} -`, `register("after")`), - }}, - wantTests: []string{"TestAlpha"}, - wantBroadened: true, - }, - { - name: "malformed changed file broadens package conservatively", - oldData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before alpha") -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestGamma(t *testing.T) { - t.Log("gamma") -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before alpha") -} -`, `t.Log("before alpha")`), - New: singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, `t.Log("changed alpha")`), - }}, - wantTests: []string{"TestAlpha", "TestBeta", "TestGamma"}, - wantBroadened: true, - }, - { - name: "deleted helper uses old snapshot to broaden package", - oldData: []byte(`package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - }), - hunks: []diffHunk{{ - Old: rangeSpan( - singleLineRange(t, `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, "func setup(t *testing.T) {"), - singleLineRange(t, `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, `t.Log("helper")`), - ), - New: emptyRangeAt(singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, `func TestAlpha(t *testing.T) {`).Start), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "brand-new file with additive hunk selects only new tests", - oldData: nil, - newData: []byte(`package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, - }), - hunks: []diffHunk{{ - Old: emptyRangeAt(1), - New: rangeSpan( - singleLineRange(t, `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, "func TestBeta(t *testing.T) {"), - singleLineRange(t, `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, `t.Log("new beta")`), - ), - }}, - wantTests: []string{"TestBeta"}, - }, - { - name: "dot imported testing is recognized", - oldData: []byte(`package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("before alpha") -} -`), - newData: []byte(`package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("changed alpha") -} -`), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ - changedPath: `package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("changed alpha") -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("before alpha") -} -`, `t.Log("before alpha")`), - New: singleLineRange(t, `package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("changed alpha") -} -`, `t.Log("changed alpha")`), - }}, - wantTests: []string{"TestAlpha"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - selection := selectTestsForSnapshots(change, tt.oldData, tt.newData, tt.inventory, tt.hunks) - if tt.wantNoSelection { - require.Nil(t, selection) - return - } - require.NotNil(t, selection) - require.Equal(t, tt.wantTests, selectionNames(selection)) - require.Equal(t, tt.wantBroadened, selection.Broadened) - }) - } -} - -func TestSelectTestsForSnapshotsTreatsTestMethodsAsSharedHelpers(t *testing.T) { - t.Parallel() - - change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} - oldData := []byte(`package sample - -import "testing" - -type suite struct{} - -func (suite) TestMethod(t *testing.T) { - t.Log("before method") -} - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`) - newData := []byte(`package sample - -import "testing" - -type suite struct{} - -func (suite) TestMethod(t *testing.T) { - t.Log("changed method") -} - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`) - inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ - "pkg/changed_test.go": string(newData), - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - }) - selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ - Old: singleLineRange(t, string(oldData), `t.Log("before method")`), - New: singleLineRange(t, string(newData), `t.Log("changed method")`), - }}) - require.NotNil(t, selection) - require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) - require.True(t, selection.Broadened) -} - -func TestSelectTestsForSnapshotsAdditiveSharedDeclsStayNarrow(t *testing.T) { - t.Parallel() - - change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} - basePrefix := `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -` - cases := []struct { - name string - declaration string - needle string - }{ - {name: "var", declaration: "var packageValue = 1\n", needle: "var packageValue = 1"}, - {name: "const", declaration: "const packageValue = 1\n", needle: "const packageValue = 1"}, - {name: "type", declaration: "type packageValue struct{}\n", needle: "type packageValue struct{}"}, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - oldData := []byte(basePrefix) - newData := []byte(basePrefix + tt.declaration + ` -func TestBeta(t *testing.T) { - t.Log("beta") -} -`) - inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ - "pkg/changed_test.go": string(newData), - }) - selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ - Old: emptyRangeAt(7), - New: rangeSpan( - singleLineRange(t, string(newData), tt.needle), - singleLineRange(t, string(newData), `t.Log("beta")`), - ), - }}) - require.NotNil(t, selection) - require.Equal(t, []string{"TestBeta"}, selectionNames(selection)) - require.False(t, selection.Broadened) - }) - } -} - -func TestSelectTestsForSnapshotsBroadensAddedImports(t *testing.T) { - t.Parallel() - - change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} - oldData := []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`) - newData := []byte(`package sample - -import ( - _ "example.com/sideeffect" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`) - inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ - "pkg/changed_test.go": string(newData), - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - }) - selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ - Old: emptyRangeAt(3), - New: singleLineRange(t, string(newData), `_ "example.com/sideeffect"`), - }}) - require.NotNil(t, selection) - require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) - require.True(t, selection.Broadened) -} - -func TestParseFileSnapshotRejectsLowercaseSuffixes(t *testing.T) { - t.Parallel() - - snapshot, err := parseFileSnapshot([]byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) {} -func Testify(t *testing.T) {} -func FuzzAlpha(f *testing.F) {} -func Fuzzbar(f *testing.F) {} -func Example() {} -func ExampleFoo() {} -func Examplefoo() {} -`)) - require.NoError(t, err) - require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) -} - -func TestFallbackTestNamesRejectsLowercaseSuffixes(t *testing.T) { - t.Parallel() - - data := []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) {} -func Testify(t *testing.T) {} -func FuzzAlpha(f *testing.F) {} -func Fuzzbar(f *testing.F) {} -func Example() {} -func ExampleFoo() {} -func Examplefoo() {} -`) - require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, fallbackTestNames(data)) -} - -func TestParseChangeKindAcceptsTypeChanges(t *testing.T) { - t.Parallel() - - kind, err := parseChangeKind("T") - require.NoError(t, err) - require.Equal(t, changeType, kind) -} - -func TestParseDiffHunks(t *testing.T) { - t.Parallel() - - hunks, err := parseDiffHunks(strings.Join([]string{ - "@@ -10 +12 @@", - "@@ -0,0 +5,3 @@", - "@@ -20,4 +30,6 @@", - "@@ malformed @@", - }, "\n")) - require.NoError(t, err) - require.Equal(t, []diffHunk{ - {Old: lineRange{Start: 10, End: 10}, New: lineRange{Start: 12, End: 12}}, - {Old: lineRange{Start: 1, End: 0}, New: lineRange{Start: 5, End: 7}}, - {Old: lineRange{Start: 20, End: 23}, New: lineRange{Start: 30, End: 35}}, - }, hunks) -} - -func TestParseNonNegativeInt(t *testing.T) { - t.Parallel() - - value, err := parseNonNegativeInt("0") - require.NoError(t, err) - require.Zero(t, value) - - value, err = parseNonNegativeInt("42") - require.NoError(t, err) - require.Equal(t, 42, value) - - _, err = parseNonNegativeInt("x") - require.Error(t, err) -} - -func TestRenderSummaryNoChangedFiles(t *testing.T) { - t.Parallel() - - summary := renderSummary(nil, summaryReport{}) - require.Contains(t, summary, "No changed `*_test.go` files were detected") -} - -func TestRenderSummaryNoRunnableTests(t *testing.T) { - t.Parallel() - - summary := renderSummary([]string{"pkg/changed_test.go"}, summaryReport{}) - require.Contains(t, summary, "no runnable top-level tests were selected") - require.Contains(t, summary, "pkg/changed_test.go") -} - -func TestBuildRunRegexRejectsUnsafeNames(t *testing.T) { - t.Parallel() - - _, err := buildRunRegex([]string{"TestAlpha", "TestO'Brien"}) - require.Error(t, err) -} - -func TestBuildExecutionPlanRunsAllForUnsafeTestNames(t *testing.T) { - t.Parallel() - - selection := &packageSelection{ - Key: packageKey{Dir: "pkg", Name: "sample"}, - Tests: map[string]struct{}{"TestAlpha": {}, "TestĪ›": {}}, - Files: map[string]struct{}{"pkg/sample_test.go": {}}, - } - result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) - require.NoError(t, err) - require.Len(t, result.Matrix.Include, 1) - require.Empty(t, result.Matrix.Include[0].RunRegex) - require.Equal(t, "1", result.Matrix.Include[0].TestCount) - require.True(t, result.Summary.Entries[0].RunAll) - require.Contains(t, result.Summary.Entries[0].Notes[0], "cannot be passed safely") -} - -func TestBuildExecutionPlanRejectsUnsafePackagePaths(t *testing.T) { - t.Parallel() - - key := packageKey{Dir: "pkg$(echo bad)", Name: "sample"} - _, err := buildExecutionPlan(map[packageKey]*packageSelection{ - key: { - Key: key, - Tests: map[string]struct{}{"TestAlpha": {}}, - Files: map[string]struct{}{"pkg$(echo bad)/sample_test.go": {}}, - }, - }) - require.ErrorContains(t, err, "unsafe package path") -} - -func TestBuildExecutionPlanCapsBroadenedTarget(t *testing.T) { - t.Parallel() - - selection := &packageSelection{ - Key: packageKey{Dir: "pkg", Name: "sample"}, - Tests: map[string]struct{}{}, - Files: map[string]struct{}{"pkg/setup_test.go": {}}, - Broadened: true, - } - for index := range maxBroadenedTests + 1 { - selection.Tests[fmt.Sprintf("Test%03d", index)] = struct{}{} - } - result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) - require.NoError(t, err) - require.Len(t, result.Matrix.Include, 1) - require.Equal(t, "1", result.Matrix.Include[0].TestCount) - require.Empty(t, result.Matrix.Include[0].RunRegex) - require.True(t, result.Summary.Entries[0].RunAll) - require.Contains(t, result.Summary.Entries[0].Notes[0], "above the 50-test cap") -} - -func TestBuildExecutionPlanCapsMatrixTargets(t *testing.T) { - t.Parallel() - - selections := map[packageKey]*packageSelection{} - for index := range maxMatrixEntries + maxOverflowSummaries + 2 { - key := packageKey{Dir: fmt.Sprintf("pkg%02d", index), Name: "sample"} - selections[key] = &packageSelection{ - Key: key, - Tests: map[string]struct{}{fmt.Sprintf("Test%02d", index): {}}, - Files: map[string]struct{}{fmt.Sprintf("pkg%02d/file_test.go", index): {}}, - } - } - result, err := buildExecutionPlan(selections) - require.NoError(t, err) - require.Len(t, result.Matrix.Include, maxMatrixEntries) - overflow := result.Matrix.Include[len(result.Matrix.Include)-1] - require.Equal(t, "1", overflow.TestCount) - require.Empty(t, overflow.RunRegex) - require.Contains(t, overflow.Package, "./pkg") - require.Contains(t, result.Summary.Notes[0], "Matrix target cap") - require.Contains(t, result.Summary.Entries[len(result.Summary.Entries)-1].Notes[1], "and 3 more") -} - -func TestRunValidationErrors(t *testing.T) { - t.Parallel() - - var stdout bytes.Buffer - var stderr bytes.Buffer - neverGit := func(_ context.Context, _ string, _ ...string) (gitResult, error) { - return gitResult{}, xerrors.New("git should not be called") - } - - err := run(t.Context(), config{OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) - require.EqualError(t, err, "--base-sha is required") - - err = run(t.Context(), config{BaseSHA: "base"}, &stdout, &stderr, neverGit) - require.EqualError(t, err, "--out-matrix is required") - - err = run(t.Context(), config{BaseSHA: "-bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) - require.ErrorContains(t, err, "must not start with '-'") - - err = run(t.Context(), config{BaseSHA: "base:bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) - require.ErrorContains(t, err, "must not contain ':'") - - err = run(t.Context(), config{BaseSHA: "base\x00bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) - require.ErrorContains(t, err, "must not contain NUL bytes") -} - -func TestRunWritesMatrixAndSummaryWithPackageScopedEntries(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - baseFiles := map[string]string{ - "pkgone/shared_test.go": `package one - -import "testing" - -func TestShared(t *testing.T) { - t.Log("before one") -} -`, - "pkgtwo/shared_test.go": `package two - -import "testing" - -func TestShared(t *testing.T) { - t.Log("before two") -} -`, - } - headFiles := map[string]string{ - "pkgone/shared_test.go": `package one - -import "testing" - -func TestShared(t *testing.T) { - t.Log("changed one") -} -`, - "pkgtwo/shared_test.go": `package two - -import "testing" - -func TestShared(t *testing.T) { - t.Log("changed two") -} -`, - } - repo := fakeGitRepo{ - changes: []testFileChange{ - {Kind: changeModified, OldPath: "pkgone/shared_test.go", NewPath: "pkgone/shared_test.go"}, - {Kind: changeModified, OldPath: "pkgtwo/shared_test.go", NewPath: "pkgtwo/shared_test.go"}, - }, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - "pkgone/shared_test.go": diffForChange( - singleLineRange(t, baseFiles["pkgone/shared_test.go"], `t.Log("before one")`), - singleLineRange(t, headFiles["pkgone/shared_test.go"], `t.Log("changed one")`), - ), - "pkgtwo/shared_test.go": diffForChange( - singleLineRange(t, baseFiles["pkgtwo/shared_test.go"], `t.Log("before two")`), - singleLineRange(t, headFiles["pkgtwo/shared_test.go"], `t.Log("changed two")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - summaryPath := filepath.Join(repoRoot, "summary.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - require.Empty(t, stdout.String()) - require.Contains(t, stderr.String(), "selected 2 package targets") - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 2) - require.Equal(t, "./pkgone", matrix.Include[0].Package) - require.Equal(t, "^(TestShared)(/.*)?$", matrix.Include[0].RunRegex) - require.Equal(t, "10", matrix.Include[0].TestCount) - require.Equal(t, "./pkgtwo", matrix.Include[1].Package) - - summary, err := os.ReadFile(summaryPath) - require.NoError(t, err) - require.Contains(t, string(summary), "Selected 2 tests across 2 package targets") - require.Contains(t, string(summary), "### `./pkgone`") - require.Contains(t, string(summary), "### `./pkgtwo`") -} - -func TestRunWritesSummaryToStdout(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - baseFiles := map[string]string{ - "pkg/sample_test.go": `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before") -} -`, - } - headFiles := map[string]string{ - "pkg/sample_test.go": `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("after") -} -`, - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/sample_test.go", NewPath: "pkg/sample_test.go"}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - "pkg/sample_test.go": diffForChange( - singleLineRange(t, baseFiles["pkg/sample_test.go"], `t.Log("before")`), - singleLineRange(t, headFiles["pkg/sample_test.go"], `t.Log("after")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: "-"}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - require.Contains(t, stdout.String(), "## Go test flake detector selection") - require.Contains(t, stdout.String(), "### `./pkg`") - require.Contains(t, stderr.String(), "selected 1 package targets") -} - -func TestRunBroadensTestMainAcrossPackageAndPackageTest(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - baseFiles := map[string]string{ - "pkg/setup_test.go": `package sample - -import ( - "os" - "testing" -) - -func TestMain(m *testing.M) { - os.Exit(m.Run()) -} -`, - "pkg/internal_test.go": `package sample - -import "testing" - -func TestInternal(t *testing.T) { - t.Log("internal") -} -`, - "pkg/external_test.go": `package sample_test - -import "testing" - -func TestExternal(t *testing.T) { - t.Log("external") -} -`, - } - headFiles := map[string]string{ - "pkg/setup_test.go": `package sample - -import ( - "fmt" - "os" - "testing" -) - -func TestMain(m *testing.M) { - fmt.Println("setup") - os.Exit(m.Run()) -} -`, - "pkg/internal_test.go": baseFiles["pkg/internal_test.go"], - "pkg/external_test.go": baseFiles["pkg/external_test.go"], - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/setup_test.go", NewPath: "pkg/setup_test.go"}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - "pkg/setup_test.go": diffForChange( - singleLineRange(t, baseFiles["pkg/setup_test.go"], `os.Exit(m.Run())`), - singleLineRange(t, headFiles["pkg/setup_test.go"], `fmt.Println("setup")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - summaryPath := filepath.Join(repoRoot, "summary.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "./pkg", matrix.Include[0].Package) - require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) - - summary, err := os.ReadFile(summaryPath) - require.NoError(t, err) - require.Contains(t, string(summary), "TestInternal") - require.Contains(t, string(summary), "TestExternal") -} - -func TestRunHandlesRename(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - oldPath := "pkg/old_test.go" - newPath := "pkg/new_test.go" - baseFiles := map[string]string{ - oldPath: `package sample - -import "testing" - -func TestRenamed(t *testing.T) { - t.Log("before rename") -} -`, - } - headFiles := map[string]string{ - newPath: `package sample - -import "testing" - -func TestRenamed(t *testing.T) { - t.Log("after rename") -} -`, - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - oldPath + "\x00" + newPath: diffForChange( - singleLineRange(t, baseFiles[oldPath], `t.Log("before rename")`), - singleLineRange(t, headFiles[newPath], `t.Log("after rename")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - summaryPath := filepath.Join(repoRoot, "summary.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "./pkg", matrix.Include[0].Package) - require.Equal(t, "^(TestRenamed)(/.*)?$", matrix.Include[0].RunRegex) - - summary, err := os.ReadFile(summaryPath) - require.NoError(t, err) - require.Contains(t, string(summary), newPath) -} - -func TestRunUsesHeadRevisionInsteadOfWorkingTree(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - writeTestFile(t, repoRoot, "pkg/sample_test.go", `package sample - -import "testing" - -func TestWorkingTree(t *testing.T) { - t.Log("working tree") -} -`) - - baseFiles := map[string]string{ - "pkg/sample_test.go": `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, - } - headFiles := map[string]string{ - "pkg/sample_test.go": `package sample - -import "testing" - -func TestHead(t *testing.T) { - t.Log("head") -} -`, - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeModified, OldPath: "pkg/sample_test.go", NewPath: "pkg/sample_test.go"}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - "pkg/sample_test.go": diffForChange( - singleLineRange(t, baseFiles["pkg/sample_test.go"], `func TestAlpha`), - singleLineRange(t, headFiles["pkg/sample_test.go"], `func TestHead`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "^(TestHead)(/.*)?$", matrix.Include[0].RunRegex) - require.NotContains(t, string(matrixData), "TestWorkingTree") -} - -func TestRunSkipsNonRunnableChangedTestFiles(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - headFiles := map[string]string{ - "pkg/testdata/example_test.go": `package sample - -import "testing" - -func TestIgnored(t *testing.T) { - t.Log("ignored") -} -`, - "pkg/_ignored_test.go": `package sample - -import "testing" - -func TestUnderscoreIgnored(t *testing.T) { - t.Log("ignored") -} -`, - "pkg/.hidden_test.go": `package sample - -import "testing" - -func TestHiddenIgnored(t *testing.T) { - t.Log("ignored") -} -`, - } - repo := fakeGitRepo{ - changes: []testFileChange{ - {Kind: changeAdded, NewPath: "pkg/testdata/example_test.go"}, - {Kind: changeAdded, NewPath: "pkg/_ignored_test.go"}, - {Kind: changeAdded, NewPath: "pkg/.hidden_test.go"}, - }, - revisions: map[string]map[string]string{"base": {}, "head": headFiles}, - diffOutputs: map[string]string{}, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - summaryPath := filepath.Join(repoRoot, "summary.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Empty(t, matrix.Include) - - summary, err := os.ReadFile(summaryPath) - require.NoError(t, err) - require.Contains(t, string(summary), "No changed `*_test.go` files were detected") -} - -func TestRunToleratesDuplicateRunnableNamesInPackageInventory(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - linuxPath := "pkg/platform_linux_test.go" - windowsPath := "pkg/platform_windows_test.go" - baseFiles := map[string]string{ - linuxPath: `//go:build linux - -package sample - -import "testing" - -func TestPlatform(t *testing.T) { - t.Log("linux before") -} -`, - windowsPath: `//go:build windows - -package sample - -import "testing" - -func TestPlatform(t *testing.T) { - t.Log("windows") -} -`, - } - headFiles := map[string]string{ - linuxPath: `//go:build linux - -package sample - -import "testing" - -func TestPlatform(t *testing.T) { - t.Log("linux after") -} -`, - windowsPath: baseFiles[windowsPath], - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeModified, OldPath: linuxPath, NewPath: linuxPath}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - linuxPath: diffForChange( - singleLineRange(t, baseFiles[linuxPath], `t.Log("linux before")`), - singleLineRange(t, headFiles[linuxPath], `t.Log("linux after")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "^(TestPlatform)(/.*)?$", matrix.Include[0].RunRegex) -} - -func TestBuildExecutionPlanKeepsSameNamePackageAndExternalTestsPrecise(t *testing.T) { - t.Parallel() - - selections := map[packageKey]*packageSelection{ - {Dir: "pkg", Name: "sample"}: { - Key: packageKey{Dir: "pkg", Name: "sample"}, - Tests: map[string]struct{}{"TestShared": {}}, - Files: map[string]struct{}{"pkg/internal_test.go": {}}, - }, - {Dir: "pkg", Name: "sample_test"}: { - Key: packageKey{Dir: "pkg", Name: "sample_test"}, - Tests: map[string]struct{}{"TestShared": {}}, - Files: map[string]struct{}{"pkg/external_test.go": {}}, - }, - } - result, err := buildExecutionPlan(selections) - require.NoError(t, err) - require.Len(t, result.Matrix.Include, 1) - require.Equal(t, "./pkg", result.Matrix.Include[0].Package) - require.Equal(t, "^(TestShared)(/.*)?$", result.Matrix.Include[0].RunRegex) - require.Equal(t, "10", result.Matrix.Include[0].TestCount) - require.False(t, result.Summary.Entries[0].RunAll) - require.Empty(t, result.Summary.Entries[0].Notes) -} - -func TestMergePackageSelectionCombinesSamePackageFiles(t *testing.T) { - t.Parallel() - - key := packageKey{Dir: "pkg", Name: "sample"} - selections := map[packageKey]*packageSelection{} - mergePackageSelection(selections, &packageSelection{ - Key: key, - Tests: map[string]struct{}{"TestAlpha": {}}, - Files: map[string]struct{}{"pkg/alpha_test.go": {}}, - }) - mergePackageSelection(selections, &packageSelection{ - Key: key, - Tests: map[string]struct{}{"TestBeta": {}}, - Files: map[string]struct{}{"pkg/beta_test.go": {}}, - Broadened: true, - }) - - require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selections[key])) - require.True(t, selections[key].Broadened) - require.Contains(t, selections[key].Files, "pkg/alpha_test.go") - require.Contains(t, selections[key].Files, "pkg/beta_test.go") -} - -func TestRunHandlesDeletedSetupFile(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - setupPath := "pkg/setup_test.go" - testPath := "pkg/alpha_test.go" - baseFiles := map[string]string{ - setupPath: `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("setup") -} -`, - testPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, - } - headFiles := map[string]string{ - testPath: baseFiles[testPath], - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeDeleted, OldPath: setupPath}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - setupPath: diffForChange( - singleLineRange(t, baseFiles[setupPath], `t.Log("setup")`), - emptyRangeAt(1), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - summaryPath := filepath.Join(repoRoot, "summary.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "^(TestAlpha)(/.*)?$", matrix.Include[0].RunRegex) - - summary, err := os.ReadFile(summaryPath) - require.NoError(t, err) - require.Contains(t, string(summary), setupPath) - require.Contains(t, string(summary), "TestAlpha") -} - -func TestRunBroadensInitAcrossPackageAndPackageTest(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - setupPath := "pkg/external_setup_test.go" - baseFiles := map[string]string{ - setupPath: `package sample_test - -func init() { - println("before") -} -`, - "pkg/internal_test.go": `package sample - -import "testing" - -func TestInternal(t *testing.T) { - t.Log("internal") -} -`, - "pkg/external_test.go": `package sample_test - -import "testing" - -func TestExternal(t *testing.T) { - t.Log("external") -} -`, - } - headFiles := map[string]string{ - setupPath: `package sample_test - -func init() { - println("after") -} -`, - "pkg/internal_test.go": baseFiles["pkg/internal_test.go"], - "pkg/external_test.go": baseFiles["pkg/external_test.go"], - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeModified, OldPath: setupPath, NewPath: setupPath}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - setupPath: diffForChange( - singleLineRange(t, baseFiles[setupPath], `println("before")`), - singleLineRange(t, headFiles[setupPath], `println("after")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) -} - -func TestRunHandlesCrossDirectoryRenamePrecisely(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - oldPath := "oldpkg/moved_test.go" - newPath := "newpkg/moved_test.go" - baseFiles := map[string]string{ - oldPath: `package oldpkg - -import "testing" - -func TestMoved(t *testing.T) { - t.Log("before") -} -`, - "oldpkg/stable_test.go": `package oldpkg - -import "testing" - -func TestOldStable(t *testing.T) { - t.Log("old") -} -`, - } - headFiles := map[string]string{ - newPath: `package newpkg - -import "testing" - -func TestMoved(t *testing.T) { - t.Log("after") -} -`, - "oldpkg/stable_test.go": baseFiles["oldpkg/stable_test.go"], - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - oldPath + "\x00" + newPath: diffForChange( - singleLineRange(t, baseFiles[oldPath], `t.Log("before")`), - singleLineRange(t, headFiles[newPath], `t.Log("after")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "./newpkg", matrix.Include[0].Package) - require.Equal(t, "^(TestMoved)(/.*)?$", matrix.Include[0].RunRegex) -} - -func TestRunHandlesCrossDirectoryRenameSourceFallout(t *testing.T) { - t.Parallel() - - repoRoot := t.TempDir() - oldPath := "oldpkg/setup_test.go" - newPath := "newpkg/setup_test.go" - baseFiles := map[string]string{ - oldPath: `package oldpkg - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("before") -} -`, - "oldpkg/stable_test.go": `package oldpkg - -import "testing" - -func TestOldStable(t *testing.T) { - t.Log("old") -} -`, - } - headFiles := map[string]string{ - newPath: `package newpkg - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("after") -} -`, - "oldpkg/stable_test.go": baseFiles["oldpkg/stable_test.go"], - "newpkg/stable_test.go": `package newpkg - -import "testing" - -func TestNewStable(t *testing.T) { - t.Log("new") -} -`, - } - repo := fakeGitRepo{ - changes: []testFileChange{{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}}, - revisions: map[string]map[string]string{"base": baseFiles, "head": headFiles}, - diffOutputs: map[string]string{ - oldPath + "\x00" + newPath: diffForChange( - singleLineRange(t, baseFiles[oldPath], `t.Log("before")`), - singleLineRange(t, headFiles[newPath], `t.Log("after")`), - ), - }, - } - - matrixPath := filepath.Join(repoRoot, "matrix.json") - var stdout bytes.Buffer - var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) - require.NoError(t, err) - - var matrix matrixOutput - matrixData, err := os.ReadFile(matrixPath) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "./oldpkg", matrix.Include[0].Package) - require.Equal(t, "^(TestOldStable)(/.*)?$", matrix.Include[0].RunRegex) -} - -func TestReadFileAtRevisionPropagatesExistenceCheckFailures(t *testing.T) { - t.Parallel() - - repo := fakeGitRepo{ - revisions: map[string]map[string]string{ - "head": { - "pkg/sample_test.go": `package sample -`, - }, - }, - failures: map[string]gitResponse{ - gitKey("ls-tree", "-z", "--name-only", "head", "--", "pkg/sample_test.go"): { - result: gitResult{Stderr: "fatal: ls-tree failed", ExitCode: 128}, - err: xerrors.New("fatal: ls-tree failed"), - }, - }, - } - _, _, err := readFileAtRevision(t.Context(), config{RepoRoot: t.TempDir()}, repo.runner(t), "head", "pkg/sample_test.go") - require.ErrorContains(t, err, "check whether pkg/sample_test.go exists at head") -} - -func selectionNames(selection *packageSelection) []string { - if selection == nil { - return nil - } - return slices.Sorted(maps.Keys(selection.Tests)) -} - -func mustPackageInventory(t *testing.T, dir, packageName string, files map[string]string) packageInventory { - t.Helper() - inventory := packageInventory{ - Key: packageKey{Dir: dir, Name: packageName}, - Tests: map[string][]testDecl{}, - } - for filePath, content := range files { - snapshot, err := parseOrFallbackSnapshot([]byte(content)) - require.NoError(t, err) - require.Equal(t, packageName, snapshot.packageName) - for testName, declRange := range snapshot.tests { - inventory.Tests[testName] = append(inventory.Tests[testName], testDecl{FilePath: filePath, Range: declRange}) - } - } - return inventory -} - -func diffForChange(oldRange, newRange lineRange) string { - return fmt.Sprintf("@@ -%s +%s @@\n", formatDiffRange(oldRange), formatDiffRange(newRange)) -} - -func formatDiffRange(r lineRange) string { - if !r.hasLines() { - start := r.Start - if start == 0 { - start = 1 - } - return fmt.Sprintf("%d,0", start) - } - count := r.End - r.Start + 1 - if count == 1 { - return fmt.Sprintf("%d", r.Start) - } - return fmt.Sprintf("%d,%d", r.Start, count) -} - -func singleLineRange(t *testing.T, content, needle string) lineRange { - t.Helper() - line := lineNumberForSubstring(t, content, needle) - return lineRange{Start: line, End: line} -} - -func rangeSpan(start, end lineRange) lineRange { - return lineRange{Start: start.Start, End: end.End} -} - -func emptyRangeAt(start int) lineRange { - return lineRange{Start: start, End: start - 1} -} - -func lineNumberForSubstring(t *testing.T, content, needle string) int { - t.Helper() - lineNumber := 0 - for index, line := range strings.Split(content, "\n") { - if !strings.Contains(line, needle) { - continue - } - if lineNumber != 0 { - t.Fatalf("needle %q matched more than once", needle) - } - lineNumber = index + 1 - } - if lineNumber == 0 { - t.Fatalf("needle %q not found", needle) - } - return lineNumber -} - -func writeTestFile(t *testing.T, root, relativePath, content string) { - t.Helper() - path := filepath.Join(root, filepath.FromSlash(relativePath)) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) -} - -type fakeGitRepo struct { - changes []testFileChange - revisions map[string]map[string]string - diffOutputs map[string]string - mergeBases map[string]string - headSHA string - failures map[string]gitResponse -} - -type gitResponse struct { - result gitResult - err error -} - -func (repo fakeGitRepo) runner(t *testing.T) gitRunner { - t.Helper() - return func(_ context.Context, _ string, args ...string) (gitResult, error) { - t.Helper() - if response, ok := repo.failures[gitKey(args...)]; ok { - return response.result, response.err - } - switch args[0] { - case "diff": - return repo.diffResponse(t, args) - case "cat-file": - return repo.catFileResponse(t, args) - case "show": - return repo.showResponse(t, args) - case "ls-tree": - return repo.lsTreeResponse(t, args) - case "merge-base": - return repo.mergeBaseResponse(t, args) - case "rev-parse": - return repo.revParseResponse(t, args) - default: - t.Fatalf("unexpected git command: %v", args) - return gitResult{}, nil - } - } -} - -func (repo fakeGitRepo) diffResponse(t *testing.T, args []string) (gitResult, error) { - t.Helper() - if len(args) >= 2 && args[1] == "--name-status" { - return gitResult{Stdout: repo.nameStatusOutput()}, nil - } - separator := slices.Index(args, "--") - require.NotEqual(t, -1, separator) - paths := args[separator+1:] - output, ok := repo.diffOutputs[strings.Join(paths, "\x00")] - if !ok { - t.Fatalf("unexpected diff paths %q", strings.Join(paths, "\x00")) - } - return gitResult{Stdout: output}, nil -} - -func (repo fakeGitRepo) nameStatusOutput() string { - parts := make([]string, 0, len(repo.changes)*3) - for _, change := range repo.changes { - switch change.Kind { - case changeRenamed: - parts = append(parts, "R100", change.OldPath, change.NewPath) - case changeAdded: - parts = append(parts, string(change.Kind), change.NewPath) - case changeDeleted: - parts = append(parts, string(change.Kind), change.OldPath) - default: - parts = append(parts, string(change.Kind), change.displayPath()) - } - } - return strings.Join(parts, "\x00") + "\x00" -} - -func (repo fakeGitRepo) catFileResponse(t *testing.T, args []string) (gitResult, error) { - t.Helper() - require.Len(t, args, 3) - require.Equal(t, "-e", args[1]) - spec := args[2] - if strings.HasSuffix(spec, "^{commit}") { - revision := strings.TrimSuffix(spec, "^{commit}") - if _, ok := repo.revisions[revision]; ok { - return gitResult{}, nil - } - return gitFailure(128, fmt.Sprintf("fatal: bad revision %q", revision)) - } - revision, path := splitRevisionPath(t, spec) - if _, ok := repo.revisions[revision][path]; ok { - return gitResult{}, nil - } - return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) -} - -func (repo fakeGitRepo) showResponse(t *testing.T, args []string) (gitResult, error) { - t.Helper() - require.Len(t, args, 2) - revision, path := splitRevisionPath(t, args[1]) - content, ok := repo.revisions[revision][path] - if !ok { - return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) - } - return gitResult{Stdout: content}, nil -} - -func (repo fakeGitRepo) lsTreeResponse(t *testing.T, args []string) (gitResult, error) { - t.Helper() - separator := slices.Index(args, "--") - require.Greater(t, separator, 1) - require.Less(t, separator+1, len(args)) - revision := args[separator-1] - pathspec := cleanGitPath(args[separator+1]) - files := make([]string, 0) - for filePath := range repo.revisions[revision] { - cleanPath := cleanGitPath(filePath) - if pathspec != "." && !strings.HasPrefix(cleanPath, pathspec+"/") && cleanPath != pathspec { - continue - } - files = append(files, cleanPath) - } - slices.Sort(files) - return gitResult{Stdout: strings.Join(files, "\x00") + "\x00"}, nil -} - -func (repo fakeGitRepo) mergeBaseResponse(t *testing.T, args []string) (gitResult, error) { - t.Helper() - require.Len(t, args, 3) - key := gitKey(args...) - if repo.mergeBases != nil { - if base, ok := repo.mergeBases[key]; ok { - return gitResult{Stdout: base + "\n"}, nil - } - } - left := args[1] - if _, ok := repo.revisions[left]; ok { - return gitResult{Stdout: left + "\n"}, nil - } - return gitFailure(1, fmt.Sprintf("fatal: no merge base for %s and %s", args[1], args[2])) -} - -func (repo fakeGitRepo) revParseResponse(t *testing.T, args []string) (gitResult, error) { - t.Helper() - require.Equal(t, []string{"rev-parse", "HEAD"}, args) - head := repo.headSHA - if head == "" { - head = "head" - } - return gitResult{Stdout: head + "\n"}, nil -} - -func splitRevisionPath(t *testing.T, spec string) (revision string, path string) { - t.Helper() - revision, path, ok := strings.Cut(spec, ":") - require.True(t, ok) - return revision, cleanGitPath(path) -} - -func gitFailure(exitCode int, stderr string) (gitResult, error) { - return gitResult{Stderr: stderr, ExitCode: exitCode}, xerrors.New(stderr) -} - -func gitKey(args ...string) string { - // NUL is a stable separator because git diff pathspecs can contain spaces. - return strings.Join(args, "\x00") -} diff --git a/plan.go b/plan.go index 3e5742c..0fca466 100644 --- a/plan.go +++ b/plan.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "maps" - "path/filepath" "regexp" "slices" "strings" @@ -81,21 +80,6 @@ func selectTestPlan(ctx context.Context, cfg config, git gitRunner) ([]string, b return changedFiles, result, nil } -func mergePackageSelection(selections map[packageKey]*packageSelection, selection *packageSelection) { - merged := selections[selection.Key] - if merged == nil { - merged = &packageSelection{ - Key: selection.Key, - Tests: map[string]struct{}{}, - Files: map[string]struct{}{}, - } - selections[selection.Key] = merged - } - merged.Broadened = merged.Broadened || selection.Broadened - maps.Copy(merged.Files, selection.Files) - maps.Copy(merged.Tests, selection.Tests) -} - func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResult, error) { accumulators := map[string]*executionAccumulator{} for key, selection := range selections { @@ -302,11 +286,3 @@ func countDescription(count string) string { } return count + " times" } - -func packagePattern(dir string) string { - cleanDir := filepath.ToSlash(filepath.Clean(dir)) - if cleanDir == "." { - return "." - } - return "./" + cleanDir -} diff --git a/plan_test.go b/plan_test.go new file mode 100644 index 0000000..b1d0bde --- /dev/null +++ b/plan_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenderSummaryNoChangedFiles(t *testing.T) { + t.Parallel() + + summary := renderSummary(nil, summaryReport{}) + require.Contains(t, summary, "No changed `*_test.go` files were detected") +} + +func TestRenderSummaryNoRunnableTests(t *testing.T) { + t.Parallel() + + summary := renderSummary([]string{"pkg/changed_test.go"}, summaryReport{}) + require.Contains(t, summary, "no runnable top-level tests were selected") + require.Contains(t, summary, "pkg/changed_test.go") +} + +func TestBuildRunRegexRejectsUnsafeNames(t *testing.T) { + t.Parallel() + + _, err := buildRunRegex([]string{"TestAlpha", "TestO'Brien"}) + require.Error(t, err) +} + +func TestBuildExecutionPlanRunsAllForUnsafeTestNames(t *testing.T) { + t.Parallel() + + selection := &packageSelection{ + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{"TestAlpha": {}, "TestĪ›": {}}, + Files: map[string]struct{}{"pkg/sample_test.go": {}}, + } + result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, 1) + require.Empty(t, result.Matrix.Include[0].RunRegex) + require.Equal(t, "1", result.Matrix.Include[0].TestCount) + require.True(t, result.Summary.Entries[0].RunAll) + require.Contains(t, result.Summary.Entries[0].Notes[0], "cannot be passed safely") +} + +func TestBuildExecutionPlanRejectsUnsafePackagePaths(t *testing.T) { + t.Parallel() + + key := packageKey{Dir: "pkg$(echo bad)", Name: "sample"} + _, err := buildExecutionPlan(map[packageKey]*packageSelection{ + key: { + Key: key, + Tests: map[string]struct{}{"TestAlpha": {}}, + Files: map[string]struct{}{"pkg$(echo bad)/sample_test.go": {}}, + }, + }) + require.ErrorContains(t, err, "unsafe package path") +} + +func TestBuildExecutionPlanCapsBroadenedTarget(t *testing.T) { + t.Parallel() + + selection := &packageSelection{ + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{}, + Files: map[string]struct{}{"pkg/setup_test.go": {}}, + Broadened: true, + } + for index := range maxBroadenedTests + 1 { + selection.Tests[fmt.Sprintf("Test%03d", index)] = struct{}{} + } + result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, 1) + require.Equal(t, "1", result.Matrix.Include[0].TestCount) + require.Empty(t, result.Matrix.Include[0].RunRegex) + require.True(t, result.Summary.Entries[0].RunAll) + require.Contains(t, result.Summary.Entries[0].Notes[0], "above the 50-test cap") +} + +func TestBuildExecutionPlanCapsMatrixTargets(t *testing.T) { + t.Parallel() + + selections := map[packageKey]*packageSelection{} + for index := range maxMatrixEntries + maxOverflowSummaries + 2 { + key := packageKey{Dir: fmt.Sprintf("pkg%02d", index), Name: "sample"} + selections[key] = &packageSelection{ + Key: key, + Tests: map[string]struct{}{fmt.Sprintf("Test%02d", index): {}}, + Files: map[string]struct{}{fmt.Sprintf("pkg%02d/file_test.go", index): {}}, + } + } + result, err := buildExecutionPlan(selections) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, maxMatrixEntries) + overflow := result.Matrix.Include[len(result.Matrix.Include)-1] + require.Equal(t, "1", overflow.TestCount) + require.Empty(t, overflow.RunRegex) + require.Contains(t, overflow.Package, "./pkg") + require.Contains(t, result.Summary.Notes[0], "Matrix target cap") + require.Contains(t, result.Summary.Entries[len(result.Summary.Entries)-1].Notes[1], "and 3 more") +} + +func TestBuildExecutionPlanKeepsSameNamePackageAndExternalTestsPrecise(t *testing.T) { + t.Parallel() + + selections := map[packageKey]*packageSelection{ + {Dir: "pkg", Name: "sample"}: { + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{"TestShared": {}}, + Files: map[string]struct{}{"pkg/internal_test.go": {}}, + }, + {Dir: "pkg", Name: "sample_test"}: { + Key: packageKey{Dir: "pkg", Name: "sample_test"}, + Tests: map[string]struct{}{"TestShared": {}}, + Files: map[string]struct{}{"pkg/external_test.go": {}}, + }, + } + result, err := buildExecutionPlan(selections) + require.NoError(t, err) + require.Len(t, result.Matrix.Include, 1) + require.Equal(t, "./pkg", result.Matrix.Include[0].Package) + require.Equal(t, "^(TestShared)(/.*)?$", result.Matrix.Include[0].RunRegex) + require.Equal(t, "10", result.Matrix.Include[0].TestCount) + require.False(t, result.Summary.Entries[0].RunAll) + require.Empty(t, result.Summary.Entries[0].Notes) +} diff --git a/publish_test.go b/publish_test.go new file mode 100644 index 0000000..b47cf30 --- /dev/null +++ b/publish_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPublishPlanWritesCompactGitHubOutputs(t *testing.T) { + t.Parallel() + + root := t.TempDir() + matrixPath := filepath.Join(root, "matrix.json") + summaryPath := filepath.Join(root, "summary.md") + outputPath := filepath.Join(root, "output.txt") + stepSummaryPath := filepath.Join(root, "step-summary.md") + summary := "## Summary\n" + err := publishPlan(outputSinks{ + OutMatrix: matrixPath, + OutSummary: summaryPath, + GitHubOutput: outputPath, + GitHubStepSummary: stepSummaryPath, + }, matrixOutput{Include: []matrixEntry{{Package: "./pkg", RunRegex: "^(TestAlpha)(/.*)?$", TestCount: "10"}}}, summary, nil, 0) + require.NoError(t, err) + + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + wantMatrix := `{"include":[{"package":"./pkg","run_regex":"^(TestAlpha)(/.*)?$","test_count":"10"}]}` + require.Equal(t, wantMatrix+"\n", string(matrixData)) + + outputData, err := os.ReadFile(outputPath) + require.NoError(t, err) + require.Equal(t, "matrix="+wantMatrix+"\n", string(outputData)) + outputValue := strings.TrimSuffix(strings.TrimPrefix(string(outputData), "matrix="), "\n") + require.NotContains(t, outputValue, "\n") + + localSummary, err := os.ReadFile(summaryPath) + require.NoError(t, err) + require.Equal(t, summary, string(localSummary)) + stepSummary, err := os.ReadFile(stepSummaryPath) + require.NoError(t, err) + require.Equal(t, summary, string(stepSummary)) +} + +func TestPublishPlanWritesEmptyMatrixAndRejectsUnsafeOutput(t *testing.T) { + t.Parallel() + + matrixData, err := marshalMatrix(matrixOutput{}) + require.NoError(t, err) + require.Equal(t, `{"include":[]}`, string(matrixData)) + + err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "first\nsecond", 0) + require.ErrorContains(t, err, "single line") + + err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "too-long", 3) + require.ErrorContains(t, err, "above the 3 byte limit") +} diff --git a/selection.go b/selection.go index 109853d..ba4fa67 100644 --- a/selection.go +++ b/selection.go @@ -145,6 +145,21 @@ func mergeSelection(ctx context.Context, cache *inventoryCache, revision string, return nil } +func mergePackageSelection(selections map[packageKey]*packageSelection, selection *packageSelection) { + merged := selections[selection.Key] + if merged == nil { + merged = &packageSelection{ + Key: selection.Key, + Tests: map[string]struct{}{}, + Files: map[string]struct{}{}, + } + selections[selection.Key] = merged + } + merged.Broadened = merged.Broadened || selection.Broadened + maps.Copy(merged.Files, selection.Files) + maps.Copy(merged.Tests, selection.Tests) +} + func selectTestsForSnapshots(change testFileChange, oldData, newData []byte, newInventory packageInventory, hunks []diffHunk) *packageSelection { newSnapshot, err := parseFileSnapshot(newData) if err != nil { @@ -292,3 +307,11 @@ func addMatchingTests(selected map[string]struct{}, tests map[string]lineRange, func (key packageKey) String() string { return fmt.Sprintf("%s (%s)", packagePattern(key.Dir), key.Name) } + +func packagePattern(dir string) string { + cleanDir := filepath.ToSlash(filepath.Clean(dir)) + if cleanDir == "." { + return "." + } + return "./" + cleanDir +} diff --git a/selection_test.go b/selection_test.go new file mode 100644 index 0000000..61cf0eb --- /dev/null +++ b/selection_test.go @@ -0,0 +1,1090 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSelectTestsForSnapshots(t *testing.T) { + t.Parallel() + + const changedPath = "pkg/changed_test.go" + change := testFileChange{Kind: changeModified, OldPath: changedPath, NewPath: changedPath} + + tests := []struct { + name string + oldData []byte + newData []byte + inventory packageInventory + hunks []diffHunk + wantTests []string + wantBroadened bool + wantNoSelection bool + }{ + { + name: "body change selects only changed test", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`, `t.Log("before alpha")`), + New: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") +} + +func TestBeta(t *testing.T) { + t.Log("stable beta") +} +`, `t.Log("changed alpha")`), + }}, + wantTests: []string{"TestAlpha"}, + }, + { + name: "new top-level test selects only new test", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(7), + New: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, `t.Log("new beta")`), + }}, + wantTests: []string{"TestBeta"}, + }, + { + name: "existing helper change broadens across package", + oldData: []byte(`package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("before helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`), + newData: []byte(`package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("changed helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("changed helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + setup(t) +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("before helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, `t.Log("before helper")`), + New: singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("changed helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, `t.Log("changed helper")`), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "package variable change broadens across package", + oldData: []byte(`package sample + +import "testing" + +var packageValue = 1 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`), + newData: []byte(`package sample + +import "testing" + +var packageValue = 2 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +var packageValue = 2 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log(packageValue) +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +var packageValue = 1 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`, "var packageValue = 1"), + New: singleLineRange(t, `package sample + +import "testing" + +var packageValue = 2 + +func TestAlpha(t *testing.T) { + t.Log(packageValue) +} +`, "var packageValue = 2"), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "additive import broadens package", + oldData: []byte(`package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`), + newData: []byte(`package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(singleLineRange(t, `package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, `"testing"`).Start), + New: singleLineRange(t, `package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, `"fmt"`), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "additive helper with new test stays narrow", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(7), + New: rangeSpan( + singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`, "func setupCase(t *testing.T) {"), + singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + +func TestBeta(t *testing.T) { + setupCase(t) +} +`, "setupCase(t)"), + ), + }}, + wantTests: []string{"TestBeta"}, + }, + { + name: "removed import broadens across package", + oldData: []byte(`package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + newData: []byte(`package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import ( + "fmt" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, `"fmt"`), + New: emptyRangeAt(singleLineRange(t, `package sample + +import ( + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, `"testing"`).Start), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "TestMain broadens across sibling files in same package", + oldData: []byte(`package sample + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} +`), + newData: []byte(`package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} +`, `os.Exit(m.Run())`), + New: singleLineRange(t, `package sample + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) +} +`, `fmt.Println("setup")`), + }}, + wantTests: []string{"TestAlpha"}, + wantBroadened: true, + }, + { + name: "init broadens across sibling files in same package", + oldData: []byte(`package sample + +import "testing" + +func init() { + register("before") +} +`), + newData: []byte(`package sample + +import "testing" + +func init() { + register("after") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func init() { + register("after") +} +`, + "pkg/internal_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func init() { + register("before") +} +`, `register("before")`), + New: singleLineRange(t, `package sample + +import "testing" + +func init() { + register("after") +} +`, `register("after")`), + }}, + wantTests: []string{"TestAlpha"}, + wantBroadened: true, + }, + { + name: "malformed changed file broadens package conservatively", + oldData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestGamma(t *testing.T) { + t.Log("gamma") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("before alpha") +} +`, `t.Log("before alpha")`), + New: singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("changed alpha") + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, `t.Log("changed alpha")`), + }}, + wantTests: []string{"TestAlpha", "TestBeta", "TestGamma"}, + wantBroadened: true, + }, + { + name: "deleted helper uses old snapshot to broaden package", + oldData: []byte(`package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`), + newData: []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }), + hunks: []diffHunk{{ + Old: rangeSpan( + singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, "func setup(t *testing.T) {"), + singleLineRange(t, `package sample + +import "testing" + +func setup(t *testing.T) { + t.Helper() + t.Log("helper") +} + +func TestAlpha(t *testing.T) { + setup(t) +} +`, `t.Log("helper")`), + ), + New: emptyRangeAt(singleLineRange(t, `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`, `func TestAlpha(t *testing.T) {`).Start), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "brand-new file with additive hunk selects only new tests", + oldData: nil, + newData: []byte(`package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(1), + New: rangeSpan( + singleLineRange(t, `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, "func TestBeta(t *testing.T) {"), + singleLineRange(t, `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("new beta") +} +`, `t.Log("new beta")`), + ), + }}, + wantTests: []string{"TestBeta"}, + }, + { + name: "dot imported testing is recognized", + oldData: []byte(`package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("before alpha") +} +`), + newData: []byte(`package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("changed alpha") +} +`), + inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + changedPath: `package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("changed alpha") +} +`, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, `package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("before alpha") +} +`, `t.Log("before alpha")`), + New: singleLineRange(t, `package sample + +import . "testing" + +func TestAlpha(t *T) { + t.Log("changed alpha") +} +`, `t.Log("changed alpha")`), + }}, + wantTests: []string{"TestAlpha"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + selection := selectTestsForSnapshots(change, tt.oldData, tt.newData, tt.inventory, tt.hunks) + if tt.wantNoSelection { + require.Nil(t, selection) + return + } + require.NotNil(t, selection) + require.Equal(t, tt.wantTests, selectionNames(selection)) + require.Equal(t, tt.wantBroadened, selection.Broadened) + }) + } +} + +func TestSelectTestsForSnapshotsTreatsTestMethodsAsSharedHelpers(t *testing.T) { + t.Parallel() + + change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} + oldData := []byte(`package sample + +import "testing" + +type suite struct{} + +func (suite) TestMethod(t *testing.T) { + t.Log("before method") +} + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + newData := []byte(`package sample + +import "testing" + +type suite struct{} + +func (suite) TestMethod(t *testing.T) { + t.Log("changed method") +} + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + "pkg/changed_test.go": string(newData), + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }) + selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + Old: singleLineRange(t, string(oldData), `t.Log("before method")`), + New: singleLineRange(t, string(newData), `t.Log("changed method")`), + }}) + require.NotNil(t, selection) + require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) + require.True(t, selection.Broadened) +} + +func TestSelectTestsForSnapshotsAdditiveSharedDeclsStayNarrow(t *testing.T) { + t.Parallel() + + change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} + basePrefix := `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +` + cases := []struct { + name string + declaration string + needle string + }{ + {name: "var", declaration: "var packageValue = 1\n", needle: "var packageValue = 1"}, + {name: "const", declaration: "const packageValue = 1\n", needle: "const packageValue = 1"}, + {name: "type", declaration: "type packageValue struct{}\n", needle: "type packageValue struct{}"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + oldData := []byte(basePrefix) + newData := []byte(basePrefix + tt.declaration + ` +func TestBeta(t *testing.T) { + t.Log("beta") +} +`) + inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + "pkg/changed_test.go": string(newData), + }) + selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + Old: emptyRangeAt(7), + New: rangeSpan( + singleLineRange(t, string(newData), tt.needle), + singleLineRange(t, string(newData), `t.Log("beta")`), + ), + }}) + require.NotNil(t, selection) + require.Equal(t, []string{"TestBeta"}, selectionNames(selection)) + require.False(t, selection.Broadened) + }) + } +} + +func TestSelectTestsForSnapshotsBroadensAddedImports(t *testing.T) { + t.Parallel() + + change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} + oldData := []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + newData := []byte(`package sample + +import ( + _ "example.com/sideeffect" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + "pkg/changed_test.go": string(newData), + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestBeta(t *testing.T) { + t.Log("beta") +} +`, + }) + selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + Old: emptyRangeAt(3), + New: singleLineRange(t, string(newData), `_ "example.com/sideeffect"`), + }}) + require.NotNil(t, selection) + require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) + require.True(t, selection.Broadened) +} + +func TestMergePackageSelectionCombinesSamePackageFiles(t *testing.T) { + t.Parallel() + + key := packageKey{Dir: "pkg", Name: "sample"} + selections := map[packageKey]*packageSelection{} + mergePackageSelection(selections, &packageSelection{ + Key: key, + Tests: map[string]struct{}{"TestAlpha": {}}, + Files: map[string]struct{}{"pkg/alpha_test.go": {}}, + }) + mergePackageSelection(selections, &packageSelection{ + Key: key, + Tests: map[string]struct{}{"TestBeta": {}}, + Files: map[string]struct{}{"pkg/beta_test.go": {}}, + Broadened: true, + }) + + require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selections[key])) + require.True(t, selections[key].Broadened) + require.Contains(t, selections[key].Files, "pkg/alpha_test.go") + require.Contains(t, selections[key].Files, "pkg/beta_test.go") +} diff --git a/snapshot_test.go b/snapshot_test.go new file mode 100644 index 0000000..e51d858 --- /dev/null +++ b/snapshot_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "maps" + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseFileSnapshotRejectsLowercaseSuffixes(t *testing.T) { + t.Parallel() + + snapshot, err := parseFileSnapshot([]byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) {} +func Testify(t *testing.T) {} +func FuzzAlpha(f *testing.F) {} +func Fuzzbar(f *testing.F) {} +func Example() {} +func ExampleFoo() {} +func Examplefoo() {} +`)) + require.NoError(t, err) + require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) +} + +func TestFallbackTestNamesRejectsLowercaseSuffixes(t *testing.T) { + t.Parallel() + + data := []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) {} +func Testify(t *testing.T) {} +func FuzzAlpha(f *testing.F) {} +func Fuzzbar(f *testing.F) {} +func Example() {} +func ExampleFoo() {} +func Examplefoo() {} +`) + require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, fallbackTestNames(data)) +} From 22fb9e9a1d4ce079026898dd2f79dc6372bcf0d2 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 19 May 2026 13:52:24 +0000 Subject: [PATCH 03/14] chore: import boilerplate from coder/paralleltestctx Mirrors the parallel testctx repo's structure: Makefile, golangci-lint v2 config, prettier config, gitignore, MIT license, CI workflow with fmt/lint/test jobs, and the composite setup-go action (default Go version bumped to 1.26.2 to match go.mod). Drive-by gofumpt fix: blank line between needsOldSnapshot and addMatchingTests in selection.go. --- .github/actions/setup-go/action.yaml | 24 ++++++++++ .github/workflows/ci.yaml | 68 ++++++++++++++++++++++++++++ .gitignore | 36 +++++++++++++-- .golangci.yaml | 22 +++++++++ .prettierrc | 14 ++++++ Makefile | 27 +++++++++++ selection.go | 1 + 7 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 .github/actions/setup-go/action.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .golangci.yaml create mode 100644 .prettierrc create mode 100644 Makefile diff --git a/.github/actions/setup-go/action.yaml b/.github/actions/setup-go/action.yaml new file mode 100644 index 0000000..8c1d5f6 --- /dev/null +++ b/.github/actions/setup-go/action.yaml @@ -0,0 +1,24 @@ +name: "Setup Go" +description: | + Sets up the Go environment for tests, builds, etc. +inputs: + version: + description: "The Go version to use." + default: "1.26.2" + use-cache: + description: "Whether to use the cache." + default: "true" +runs: + using: "composite" + steps: + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: ${{ inputs.version }} + cache: ${{ inputs.use-cache }} + + # It isn't necessary that we ever do this, but it helps separate the "setup" + # from the "run" times. + - name: go mod download + shell: bash + run: go mod download -x diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..1fde89c --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,68 @@ +name: quality + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# Cancel in-progress runs for pull requests when developers push additional +# changes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + fmt: + name: fmt + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: make fmt + run: make fmt + + - name: Check unstaged + run: | + if [[ -n $(git ls-files --other --modified --exclude-standard) ]]; then + echo "Unexpected difference in directories after formatting. Run 'make fmt' and include the output in the commit." + exit 1 + fi + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Go + uses: ./.github/actions/setup-go + + - name: make lint + run: make lint + + test: + name: test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Go + uses: ./.github/actions/setup-go + + - name: make test + run: make test diff --git a/.gitignore b/.gitignore index c38a693..ac29b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,34 @@ -# Go build artifacts -/testselect +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` *.test + +# Code coverage profiles and other test artifacts *.out +coverage.* +*.coverprofile +profile.cov + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ + +# Key files +*.key +*.pub +*.pem -# Editor scratch -*.swp -.DS_Store +# Output directory +build/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..509c3f1 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,22 @@ +version: "2" + +linters: + enable: + - goconst + - gocritic + - gosec + - misspell + - nakedret + - revive + - unconvert + - unparam + settings: + govet: + enable: + - shadow + misspell: + locale: US + revive: + rules: + - name: package-comments + disabled: true diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..edb389b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "printWidth": 120, + "semi": false, + "trailingComma": "all", + "overrides": [ + { + "files": ["./*.md", "./**/*.md"], + "options": { + "printWidth": 80, + "proseWrap": "always" + } + } + ] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d85aceb --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +FIND_EXCLUSIONS= \ + -not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path '*/.terraform/*' \) -prune \) +GO_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -not -name '*_test.go') +GO_FMT_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -print0 | xargs -0 grep -E --null -L '^// Code generated .* DO NOT EDIT\.$$' | tr '\0' ' ') + +default: build + +build/testselect: $(GO_SRC_FILES) go.mod go.sum + mkdir -p ./build + go build -o ./build/testselect . + +build: build/testselect +.PHONY: build + +fmt: + go mod tidy + go run golang.org/x/tools/cmd/goimports@v0.35.0 -w $(GO_FMT_FILES) + go run mvdan.cc/gofumpt@v0.8.0 -w -l $(GO_FMT_FILES) +.PHONY: fmt + +lint: + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 run ./... +.PHONY: lint + +test: + go test -test.v -timeout 30s -cover ./... +.PHONY: test diff --git a/selection.go b/selection.go index ba4fa67..f8eb9d8 100644 --- a/selection.go +++ b/selection.go @@ -296,6 +296,7 @@ func needsOldSnapshot(hunks []diffHunk) bool { } return false } + func addMatchingTests(selected map[string]struct{}, tests map[string]lineRange, candidate lineRange) { for name, declRange := range tests { if declRange.overlaps(candidate) { From b9253143914a748bffd222b5eb4e2bdc22971916 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 19 May 2026 13:55:30 +0000 Subject: [PATCH 04/14] fix(testselect): address mechanical lint findings - publish.go: surface Close errors via named return on appendFile, fixing errcheck. Close errors on a write path can indicate lost data, so propagating beats silently discarding. - plan.go: drop `:=` in selectTestPlan loop so the inner err reuses the outer binding, fixing govet shadow. - githubactions.go: drop the redundant cfg.config selector and rely on the promoted method, fixing staticcheck QF1008. - helpers_test.go: drop both dir and packageName parameters from mustPackageInventory; all 16 call sites pass the same synthetic values, so hardcode them and document the helper, fixing unparam. go vet, go build, make test, and make fmt are clean. make lint still reports six gosec findings (G301/G304) covering directory permissions and reads from user-provided paths; those are policy decisions. --- githubactions.go | 2 +- helpers_test.go | 7 +++++-- plan.go | 2 +- publish.go | 10 ++++++++-- selection_test.go | 32 ++++++++++++++++---------------- 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/githubactions.go b/githubactions.go index 1ec21fd..088aa13 100644 --- a/githubactions.go +++ b/githubactions.go @@ -37,7 +37,7 @@ type githubEvent struct { } func githubActionsRunRequest(ctx context.Context, cfg commandConfig, git gitRunner) (runRequest, error) { - baseCfg := cfg.config.withDefaults() + baseCfg := cfg.withDefaults() if baseCfg.OutMatrix == "" { return runRequest{}, xerrors.New("--out-matrix is required") } diff --git a/helpers_test.go b/helpers_test.go index c0ae841..7d67614 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -19,10 +19,13 @@ func selectionNames(selection *packageSelection) []string { return slices.Sorted(maps.Keys(selection.Tests)) } -func mustPackageInventory(t *testing.T, dir, packageName string, files map[string]string) packageInventory { +// mustPackageInventory builds a packageInventory for the synthetic "pkg" +// directory and "sample" package used throughout the test suite. +func mustPackageInventory(t *testing.T, files map[string]string) packageInventory { t.Helper() + const packageName = "sample" inventory := packageInventory{ - Key: packageKey{Dir: dir, Name: packageName}, + Key: packageKey{Dir: "pkg", Name: packageName}, Tests: map[string][]testDecl{}, } for filePath, content := range files { diff --git a/plan.go b/plan.go index 0fca466..c3ef811 100644 --- a/plan.go +++ b/plan.go @@ -68,7 +68,7 @@ func selectTestPlan(ctx context.Context, cfg config, git gitRunner) ([]string, b cache := newInventoryCache(cfg, git) selections := map[packageKey]*packageSelection{} for _, change := range changes { - if err := selectChange(ctx, cfg, git, cache, selections, change); err != nil { + if err = selectChange(ctx, cfg, git, cache, selections, change); err != nil { return nil, buildResult{}, err } } diff --git a/publish.go b/publish.go index 6d0e69c..aaa6afb 100644 --- a/publish.go +++ b/publish.go @@ -85,7 +85,7 @@ func writeFile(path string, data []byte) error { return nil } -func appendFile(path string, data []byte) error { +func appendFile(path string, data []byte) (err error) { dir := filepath.Dir(path) if dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -96,7 +96,13 @@ func appendFile(path string, data []byte) error { if err != nil { return xerrors.Errorf("open %s: %w", path, err) } - defer file.Close() + defer func() { + // Surface Close errors only if Write succeeded; write paths can + // lose data on a deferred fsync/flush failure. + if cerr := file.Close(); cerr != nil && err == nil { + err = xerrors.Errorf("close %s: %w", path, cerr) + } + }() if _, err := file.Write(data); err != nil { return xerrors.Errorf("append %s: %w", path, err) } diff --git a/selection_test.go b/selection_test.go index 61cf0eb..91eb829 100644 --- a/selection_test.go +++ b/selection_test.go @@ -48,7 +48,7 @@ func TestBeta(t *testing.T) { t.Log("stable beta") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -112,7 +112,7 @@ func TestBeta(t *testing.T) { t.Log("new beta") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -171,7 +171,7 @@ func TestAlpha(t *testing.T) { setup(t) } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -247,7 +247,7 @@ func TestAlpha(t *testing.T) { t.Log(packageValue) } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -323,7 +323,7 @@ func TestBeta(t *testing.T) { t.Log("beta") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import ( @@ -401,7 +401,7 @@ func TestBeta(t *testing.T) { setupCase(t) } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -484,7 +484,7 @@ func TestAlpha(t *testing.T) { t.Log("alpha") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import ( @@ -556,7 +556,7 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import ( @@ -626,7 +626,7 @@ func init() { register("after") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -686,7 +686,7 @@ func TestBeta(t *testing.T) { t.Log("beta") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -754,7 +754,7 @@ func TestAlpha(t *testing.T) { t.Log("alpha") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -824,7 +824,7 @@ func TestBeta(t *testing.T) { t.Log("new beta") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import "testing" @@ -875,7 +875,7 @@ func TestAlpha(t *T) { t.Log("changed alpha") } `), - inventory: mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory: mustPackageInventory(t, map[string]string{ changedPath: `package sample import . "testing" @@ -955,7 +955,7 @@ func TestAlpha(t *testing.T) { t.Log("alpha") } `) - inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory := mustPackageInventory(t, map[string]string{ "pkg/changed_test.go": string(newData), "pkg/sibling_test.go": `package sample @@ -1006,7 +1006,7 @@ func TestBeta(t *testing.T) { t.Log("beta") } `) - inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory := mustPackageInventory(t, map[string]string{ "pkg/changed_test.go": string(newData), }) selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ @@ -1046,7 +1046,7 @@ func TestAlpha(t *testing.T) { t.Log("alpha") } `) - inventory := mustPackageInventory(t, "pkg", "sample", map[string]string{ + inventory := mustPackageInventory(t, map[string]string{ "pkg/changed_test.go": string(newData), "pkg/sibling_test.go": `package sample From 87aa54d29567c9510a7ecce45a0b9ff6ba4035d0 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 20 May 2026 04:57:48 +0000 Subject: [PATCH 05/14] rename: testselect -> whichtests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous name was honest but generic — many things 'select tests.' 'whichtests' is the question this tool answers: given a Go PR's diff, which tests should run? Renaming makes the intent obvious in workflow YAML, CLI help, and the module path. Touchpoints: - go.mod module github.com/coder/whichtests - Makefile builds build/whichtests - cli.go doc comment - README.md heading and invocation examples Internal symbol names like selectTestsForSnapshots are untouched — they describe what the function does, not the tool's identity. --- Makefile | 6 +++--- README.md | 8 ++++---- cli.go | 2 +- go.mod | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index d85aceb..690c38c 100644 --- a/Makefile +++ b/Makefile @@ -5,11 +5,11 @@ GO_FMT_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -print0 | default: build -build/testselect: $(GO_SRC_FILES) go.mod go.sum +build/whichtests: $(GO_SRC_FILES) go.mod go.sum mkdir -p ./build - go build -o ./build/testselect . + go build -o ./build/whichtests . -build: build/testselect +build: build/whichtests .PHONY: build fmt: diff --git a/README.md b/README.md index 0189829..2786afe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# testselect +# whichtests -`testselect` is the Go test-plan generator that drives the `flake-go` CI +`whichtests` is the Go test-plan generator that drives the `flake-go` CI workflow in `coder/coder`. Given a base/head git revision pair (or a GitHub Actions event), it walks the diff, parses each changed test file, picks the smallest set of tests to rerun, and emits a workflow @@ -10,13 +10,13 @@ matrix plus a human-readable Markdown summary. ```sh go build ./ -./testselect --help +./whichtests --help ``` Typical invocation against the local working tree: ```sh -./testselect \ +./whichtests \ --repo-root . \ --base-sha origin/main \ --head-sha HEAD \ diff --git a/cli.go b/cli.go index 3ea50fc..829fb4b 100644 --- a/cli.go +++ b/cli.go @@ -1,4 +1,4 @@ -// Command testselect produces deterministic Go test plans for the +// Command whichtests produces deterministic Go test plans for the // flake-go workflow. package main diff --git a/go.mod b/go.mod index f7737bf..3be0b03 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/coder/testselect +module github.com/coder/whichtests go 1.26.2 From 38707a6e0557acbe5d30c0f8315a77ee659c64be Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 20 May 2026 06:56:14 +0000 Subject: [PATCH 06/14] fix: address gosec findings --- .golangci.yaml | 6 ++++++ githubactions.go | 1 + helpers_test.go | 2 +- integration_test.go | 2 +- publish.go | 5 +++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 509c3f1..e29a148 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,6 +1,12 @@ version: "2" linters: + exclusions: + rules: + - path: _test\.go + linters: + - gosec + text: "G304: Potential file inclusion via variable" enable: - goconst - gocritic diff --git a/githubactions.go b/githubactions.go index 088aa13..2073e28 100644 --- a/githubactions.go +++ b/githubactions.go @@ -151,6 +151,7 @@ func workflowDispatchRunRequest(req runRequest, event githubEvent) (runRequest, } func readGitHubEvent(path string) (githubEvent, error) { + // #nosec G304: path comes from the GitHub Actions runner environment. data, err := os.ReadFile(path) if err != nil { return githubEvent{}, xerrors.Errorf("read GitHub event payload %s: %w", path, err) diff --git a/helpers_test.go b/helpers_test.go index 7d67614..a40647f 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -93,6 +93,6 @@ func lineNumberForSubstring(t *testing.T, content, needle string) int { func writeTestFile(t *testing.T, root, relativePath, content string) { t.Helper() path := filepath.Join(root, filepath.FromSlash(relativePath)) - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750)) require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) } diff --git a/integration_test.go b/integration_test.go index 5642d51..da5fa00 100644 --- a/integration_test.go +++ b/integration_test.go @@ -114,7 +114,7 @@ func TestEnsureRangeAvailableWithRealGitFetchesMovedBase(t *testing.T) { workRoot := filepath.Join(root, "work") bareRoot := filepath.Join(root, "upstream.git") cloneRoot := filepath.Join(root, "clone") - require.NoError(t, os.MkdirAll(workRoot, 0o755)) + require.NoError(t, os.MkdirAll(workRoot, 0o750)) runGit(t, workRoot, "init") runGit(t, workRoot, "config", "user.email", "test@example.com") runGit(t, workRoot, "config", "user.name", "Test User") diff --git a/publish.go b/publish.go index aaa6afb..2f56bdc 100644 --- a/publish.go +++ b/publish.go @@ -75,7 +75,7 @@ func writeSummary(path, summary string, stdout io.Writer) error { func writeFile(path string, data []byte) error { dir := filepath.Dir(path) if dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return xerrors.Errorf("mkdir %s: %w", dir, err) } } @@ -88,10 +88,11 @@ func writeFile(path string, data []byte) error { func appendFile(path string, data []byte) (err error) { dir := filepath.Dir(path) if dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { + if err = os.MkdirAll(dir, 0o750); err != nil { return xerrors.Errorf("mkdir %s: %w", dir, err) } } + // #nosec G304: path is a user-supplied output path or a GitHub Actions runner path. file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return xerrors.Errorf("open %s: %w", path, err) From 4acef74c36eb2a629a2f1ca6a47e4290506d48e3 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 20 May 2026 08:48:54 +0000 Subject: [PATCH 07/14] fix: address whichtests review feedback --- README.md | 8 +- broadening.go | 18 +++-- broadening_test.go | 51 +++++++++++++ cli.go | 26 ++----- cli_test.go | 36 ++++----- config.go | 18 ++--- diff.go | 18 ++--- diff_test.go | 4 +- gitexec.go | 9 +-- gitfake_test.go | 7 +- githubactions.go | 137 +++++++++++++++------------------- githubactions_test.go | 167 +++++++++++++++++++++++++++++++----------- go.mod | 5 +- go.sum | 2 - helpers_test.go | 10 +-- integration_test.go | 8 +- inventory.go | 17 ++--- inventory_test.go | 33 +++++++++ plan.go | 60 +++++++++------ plan_test.go | 27 +++++-- publish.go | 25 +++---- publish_test.go | 2 +- request.go | 26 +++---- request_test.go | 23 ++++++ selection.go | 37 ++++------ selection_test.go | 37 +++------- snapshot.go | 54 ++------------ snapshot_test.go | 18 ----- 28 files changed, 491 insertions(+), 392 deletions(-) create mode 100644 broadening_test.go create mode 100644 inventory_test.go create mode 100644 request_test.go diff --git a/README.md b/README.md index 2786afe..7fcda2f 100644 --- a/README.md +++ b/README.md @@ -33,18 +33,22 @@ go run ./ \ --out-matrix "$RUNNER_TEMP/flake-matrix.json" ``` +For `pull_request` events, checkout must use the PR head SHA, for example `github.event.pull_request.head.sha`. The default synthetic merge ref is rejected because the checked-out `HEAD` must match `pull_request.head.sha`. + +The matrix JSON contains `include` rows with `package`, `run_regex`, and `test_count`. `package` is normally one safe Go package pattern. If the matrix cap is hit, the final overflow row stores a space-separated list of safe package tokens in `package`, leaves `run_regex` empty, and sets `test_count` to `1`; this is the contract consumed by the current `flake-go` workflow. + ## File layout The binary is a single `package main`, split into focused files: | File | Responsibility | | --------------- | ------------------------------------------------------------------- | -| `cli.go` | `main`, flag parsing, command orchestration (`run`, `runCommand`). | +| `cli.go` | `main`, flag parsing, command orchestration (`runCommand`). | | `config.go` | `config` / `commandConfig` types and defaults. | | `request.go` | `runRequest`, `diffRange`, revision validation. | | `gitexec.go` | `gitRunner` / `gitFetcher` types and the real `exec.Command` impl. | | `diff.go` | Reading and parsing `git diff`, change kinds, hunks, line ranges. | -| `snapshot.go` | AST snapshot parsing, `fileSnapshot`, `sharedDecl`, fallbacks. | +| `snapshot.go` | AST snapshot parsing, `fileSnapshot`, and `sharedDecl`. | | `broadening.go` | Per-kind broadening rules (`broadeningScope`). | | `selection.go` | Per-change selection logic (`selectChange`, broaden vs narrow). | | `inventory.go` | `inventoryCache` for package/directory test discovery. | diff --git a/broadening.go b/broadening.go index b72176b..2ff1be1 100644 --- a/broadening.go +++ b/broadening.go @@ -9,24 +9,25 @@ const ( ) func broadeningScopeForOldHunk(decls []sharedDecl, candidate lineRange) broadeningScope { + scope := broadeningNone for _, decl := range decls { - if decl.Range.overlaps(candidate) { - return decl.broadeningScope() + if !decl.Range.overlaps(candidate) { + continue } + scope = max(scope, decl.broadeningScope()) } - return broadeningNone + return scope } func broadeningScopeForNewHunk(decls []sharedDecl, oldSnapshot *fileSnapshot, candidate lineRange) broadeningScope { + scope := broadeningNone for _, decl := range decls { if !decl.Range.overlaps(candidate) { continue } - if scope := decl.broadeningScopeOnNewSide(oldSnapshot); scope != broadeningNone { - return scope - } + scope = max(scope, decl.broadeningScopeOnNewSide(oldSnapshot)) } - return broadeningNone + return scope } func (decl sharedDecl) broadeningScope() broadeningScope { @@ -43,6 +44,9 @@ func (decl sharedDecl) broadeningScope() broadeningScope { func (decl sharedDecl) broadeningScopeOnNewSide(oldSnapshot *fileSnapshot) broadeningScope { switch decl.Kind { + // TODO: Decide whether new imports should narrow to tests that still + // reference package-local declarations. Today any import edit broadens + // the package. case sharedDeclImport: return broadeningPackage case sharedDeclInit, sharedDeclTestMain: diff --git a/broadening_test.go b/broadening_test.go new file mode 100644 index 0000000..bd67685 --- /dev/null +++ b/broadening_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBroadeningScopeForOldHunkChoosesMaxOverlappingScope(t *testing.T) { + t.Parallel() + + data := []byte(`package sample + +import "testing" + +func init() { + register() +} + +func TestAlpha(t *testing.T) {} +`) + snapshot, err := parseFileSnapshot(data) + require.NoError(t, err) + candidate := rangeSpan( + singleLineRange(t, string(data), `import "testing"`), + singleLineRange(t, string(data), "register()"), + ) + require.Equal(t, broadeningDirectory, broadeningScopeForOldHunk(snapshot.shared, candidate)) +} + +func TestBroadeningScopeForNewHunkChoosesMaxOverlappingScope(t *testing.T) { + t.Parallel() + + data := []byte(`package sample + +import "testing" + +func TestMain(m *testing.M) { + m.Run() +} + +func TestAlpha(t *testing.T) {} +`) + snapshot, err := parseFileSnapshot(data) + require.NoError(t, err) + candidate := rangeSpan( + singleLineRange(t, string(data), `import "testing"`), + singleLineRange(t, string(data), "m.Run()"), + ) + require.Equal(t, broadeningDirectory, broadeningScopeForNewHunk(snapshot.shared, nil, candidate)) +} diff --git a/cli.go b/cli.go index 829fb4b..0c24ac3 100644 --- a/cli.go +++ b/cli.go @@ -4,12 +4,11 @@ package main import ( "context" + "errors" "flag" "fmt" "io" "os" - - "golang.org/x/xerrors" ) func main() { @@ -21,11 +20,6 @@ func main() { flags.StringVar(&cfg.OutMatrix, "out-matrix", cfg.OutMatrix, "path to write workflow matrix JSON") flags.StringVar(&cfg.OutSummary, "out-summary", cfg.OutSummary, "path to write Markdown summary, or - for stdout") flags.BoolVar(&cfg.GitHubActions, "github-actions", cfg.GitHubActions, "read diff range and output paths from GitHub Actions environment") - flags.StringVar(&cfg.GitHubEventName, "github-event-name", cfg.GitHubEventName, "override GITHUB_EVENT_NAME") - flags.StringVar(&cfg.GitHubEventPath, "github-event-path", cfg.GitHubEventPath, "override GITHUB_EVENT_PATH") - flags.StringVar(&cfg.GitHubRepository, "github-repository", cfg.GitHubRepository, "override GITHUB_REPOSITORY") - flags.StringVar(&cfg.GitHubOutput, "github-output", cfg.GitHubOutput, "override GITHUB_OUTPUT") - flags.StringVar(&cfg.GitHubStepSummary, "github-step-summary", cfg.GitHubStepSummary, "override GITHUB_STEP_SUMMARY") if err := flags.Parse(os.Args[1:]); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(2) @@ -36,14 +30,6 @@ func main() { } } -func run(ctx context.Context, cfg config, stdout, stderr io.Writer, git gitRunner) error { - req, err := explicitRunRequest(cfg) - if err != nil { - return err - } - return executeRunRequest(ctx, req, stdout, stderr, git, nil) -} - func runCommand(ctx context.Context, cfg commandConfig, stdout, stderr io.Writer, git gitRunner, fetch gitFetcher) error { var ( req runRequest @@ -63,15 +49,15 @@ func runCommand(ctx context.Context, cfg commandConfig, stdout, stderr io.Writer func explicitRunRequest(cfg config) (runRequest, error) { cfg = cfg.withDefaults() if cfg.BaseSHA == "" { - return runRequest{}, xerrors.New("--base-sha is required") + return runRequest{}, errors.New("--base-sha is required") } if cfg.OutMatrix == "" { - return runRequest{}, xerrors.New("--out-matrix is required") + return runRequest{}, errors.New("--out-matrix is required") } - if err := validateRevision("--base-sha", cfg.BaseSHA); err != nil { + if err := validateRevisionArg("--base-sha", cfg.BaseSHA); err != nil { return runRequest{}, err } - if err := validateRevision("--head-sha", cfg.HeadSHA); err != nil { + if err := validateRevisionArg("--head-sha", cfg.HeadSHA); err != nil { return runRequest{}, err } return runRequest{ @@ -101,7 +87,7 @@ func executeRunRequest(ctx context.Context, req runRequest, stdout, stderr io.Wr return err } summary := renderSummary(changedFiles, result.Summary) - if err := publishPlan(req.Sinks, result.Matrix, summary, stdout, req.OutputSizeLimit); err != nil { + if err := publishPlan(req.Sinks, result.Matrix, summary, stdout); err != nil { return err } _, _ = fmt.Fprintf(stderr, "selected %d package targets from %d changed test files\n", len(result.Matrix.Include), len(changedFiles)) diff --git a/cli_test.go b/cli_test.go index 436e116..7e5b478 100644 --- a/cli_test.go +++ b/cli_test.go @@ -4,12 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "os" "path/filepath" "testing" "github.com/stretchr/testify/require" - "golang.org/x/xerrors" ) func TestRunValidationErrors(t *testing.T) { @@ -18,22 +18,22 @@ func TestRunValidationErrors(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer neverGit := func(_ context.Context, _ string, _ ...string) (gitResult, error) { - return gitResult{}, xerrors.New("git should not be called") + return gitResult{}, errors.New("git should not be called") } - err := run(t.Context(), config{OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + err := runCommand(t.Context(), commandConfig{config: config{OutMatrix: "matrix.json"}}, &stdout, &stderr, neverGit, nil) require.EqualError(t, err, "--base-sha is required") - err = run(t.Context(), config{BaseSHA: "base"}, &stdout, &stderr, neverGit) + err = runCommand(t.Context(), commandConfig{config: config{BaseSHA: "base"}}, &stdout, &stderr, neverGit, nil) require.EqualError(t, err, "--out-matrix is required") - err = run(t.Context(), config{BaseSHA: "-bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + err = runCommand(t.Context(), commandConfig{config: config{BaseSHA: "-bad", OutMatrix: "matrix.json"}}, &stdout, &stderr, neverGit, nil) require.ErrorContains(t, err, "must not start with '-'") - err = run(t.Context(), config{BaseSHA: "base:bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + err = runCommand(t.Context(), commandConfig{config: config{BaseSHA: "base:bad", OutMatrix: "matrix.json"}}, &stdout, &stderr, neverGit, nil) require.ErrorContains(t, err, "must not contain ':'") - err = run(t.Context(), config{BaseSHA: "base\x00bad", OutMatrix: "matrix.json"}, &stdout, &stderr, neverGit) + err = runCommand(t.Context(), commandConfig{config: config{BaseSHA: "base\x00bad", OutMatrix: "matrix.json"}}, &stdout, &stderr, neverGit, nil) require.ErrorContains(t, err, "must not contain NUL bytes") } @@ -99,7 +99,7 @@ func TestShared(t *testing.T) { summaryPath := filepath.Join(repoRoot, "summary.md") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) require.Empty(t, stdout.String()) require.Contains(t, stderr.String(), "selected 2 package targets") @@ -159,7 +159,7 @@ func TestAlpha(t *testing.T) { matrixPath := filepath.Join(repoRoot, "matrix.json") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: "-"}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: "-"}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) require.Contains(t, stdout.String(), "## Go test flake detector selection") require.Contains(t, stdout.String(), "### `./pkg`") @@ -231,7 +231,7 @@ func TestMain(m *testing.M) { summaryPath := filepath.Join(repoRoot, "summary.md") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -289,7 +289,7 @@ func TestRenamed(t *testing.T) { summaryPath := filepath.Join(repoRoot, "summary.md") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -352,7 +352,7 @@ func TestHead(t *testing.T) { matrixPath := filepath.Join(repoRoot, "matrix.json") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -408,7 +408,7 @@ func TestHiddenIgnored(t *testing.T) { summaryPath := filepath.Join(repoRoot, "summary.md") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -477,7 +477,7 @@ func TestPlatform(t *testing.T) { matrixPath := filepath.Join(repoRoot, "matrix.json") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -531,7 +531,7 @@ func TestAlpha(t *testing.T) { summaryPath := filepath.Join(repoRoot, "summary.md") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath, OutSummary: summaryPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -600,7 +600,7 @@ func init() { matrixPath := filepath.Join(repoRoot, "matrix.json") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -660,7 +660,7 @@ func TestMoved(t *testing.T) { matrixPath := filepath.Join(repoRoot, "matrix.json") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput @@ -731,7 +731,7 @@ func TestNewStable(t *testing.T) { matrixPath := filepath.Join(repoRoot, "matrix.json") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}, &stdout, &stderr, repo.runner(t)) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: "base", HeadSHA: "head", OutMatrix: matrixPath}}, &stdout, &stderr, repo.runner(t), nil) require.NoError(t, err) var matrix matrixOutput diff --git a/config.go b/config.go index ed8b029..bebdcd9 100644 --- a/config.go +++ b/config.go @@ -5,11 +5,11 @@ import ( ) const ( - defaultRepoRoot = "." - defaultHeadSHA = "HEAD" - defaultOutSummary = "-" - defaultTargetCount = "10" - runOnceTargetCount = "1" + defaultRepoRoot = "." + defaultHeadSHA = "HEAD" + defaultOutSummary = "-" + defaultTestCount = "10" + runOnceTestCount = "1" // Package-wide and matrix-wide caps keep the detector cheap by // running broad fallback targets once instead of repeatedly. @@ -44,13 +44,7 @@ func (cfg config) withDefaults() config { type commandConfig struct { config - GitHubActions bool - GitHubEventName string - GitHubEventPath string - GitHubRepository string - GitHubOutput string - GitHubStepSummary string - Env map[string]string + GitHubActions bool } func defaultCommandConfig() commandConfig { diff --git a/diff.go b/diff.go index ea3785b..849baa8 100644 --- a/diff.go +++ b/diff.go @@ -3,19 +3,19 @@ package main import ( "cmp" "context" + "fmt" "path/filepath" "regexp" "slices" "strconv" "strings" - - "golang.org/x/xerrors" ) var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) type changeKind string +// changeKind mirrors git diff status letters. T is a type change. const ( changeAdded changeKind = "A" changeDeleted changeKind = "D" @@ -115,7 +115,7 @@ func listChangedTestFiles(ctx context.Context, cfg config, git gitRunner) ([]tes switch kind { case changeRenamed: if index+1 >= len(fields) { - return nil, xerrors.Errorf("rename status %q is missing paths", status) + return nil, fmt.Errorf("rename status %q is missing paths", status) } oldPath := cleanGitPath(fields[index]) newPath := cleanGitPath(fields[index+1]) @@ -127,7 +127,7 @@ func listChangedTestFiles(ctx context.Context, cfg config, git gitRunner) ([]tes changes = append(changes, change) default: if index >= len(fields) { - return nil, xerrors.Errorf("status %q is missing a path", status) + return nil, fmt.Errorf("status %q is missing a path", status) } path := cleanGitPath(fields[index]) index++ @@ -163,7 +163,7 @@ func parseChangeKind(status string) (changeKind, error) { case strings.HasPrefix(status, string(changeType)): return changeType, nil default: - return "", xerrors.Errorf("unsupported diff status %q", status) + return "", fmt.Errorf("unsupported diff status %q", status) } } @@ -247,10 +247,10 @@ func parseRange(startText, countText string) (lineRange, error) { func parseNonNegativeInt(value string) (int, error) { parsed, err := strconv.Atoi(value) if err != nil { - return 0, xerrors.Errorf("parse integer %q: %w", value, err) + return 0, fmt.Errorf("parse integer %q: %w", value, err) } if parsed < 0 { - return 0, xerrors.Errorf("negative value %q", value) + return 0, fmt.Errorf("negative value %q", value) } return parsed, nil } @@ -269,7 +269,7 @@ func readFileAtRevision(ctx context.Context, cfg config, git gitRunner, revision result, err := git(ctx, cfg.RepoRoot, "show", revision+":"+filePath) if err != nil { - return nil, false, xerrors.Errorf("read %s at %s: %w", filePath, revision, err) + return nil, false, fmt.Errorf("read %s at %s: %w", filePath, revision, err) } return []byte(result.Stdout), true, nil } @@ -277,7 +277,7 @@ func readFileAtRevision(ctx context.Context, cfg config, git gitRunner, revision func fileExistsAtRevision(ctx context.Context, cfg config, git gitRunner, revision, filePath string) (bool, error) { result, err := git(ctx, cfg.RepoRoot, "ls-tree", "-z", "--name-only", revision, "--", filePath) if err != nil { - return false, xerrors.Errorf("check whether %s exists at %s: %w", filePath, revision, err) + return false, fmt.Errorf("check whether %s exists at %s: %w", filePath, revision, err) } cleanPath := cleanGitPath(filePath) for part := range strings.SplitSeq(result.Stdout, "\x00") { diff --git a/diff_test.go b/diff_test.go index 260cfe1..7fe01eb 100644 --- a/diff_test.go +++ b/diff_test.go @@ -1,11 +1,11 @@ package main import ( + "errors" "strings" "testing" "github.com/stretchr/testify/require" - "golang.org/x/xerrors" ) func TestParseChangeKindAcceptsTypeChanges(t *testing.T) { @@ -61,7 +61,7 @@ func TestReadFileAtRevisionPropagatesExistenceCheckFailures(t *testing.T) { failures: map[string]gitResponse{ gitKey("ls-tree", "-z", "--name-only", "head", "--", "pkg/sample_test.go"): { result: gitResult{Stderr: "fatal: ls-tree failed", ExitCode: 128}, - err: xerrors.New("fatal: ls-tree failed"), + err: errors.New("fatal: ls-tree failed"), }, }, } diff --git a/gitexec.go b/gitexec.go index 280bb61..1bfab76 100644 --- a/gitexec.go +++ b/gitexec.go @@ -4,11 +4,10 @@ import ( "bytes" "context" "errors" + "fmt" "os" "os/exec" "strings" - - "golang.org/x/xerrors" ) type gitResult struct { @@ -24,7 +23,7 @@ type gitFetcher func(ctx context.Context, dir string, spec fetchSpec) (gitResult func ensureRevisionExists(ctx context.Context, cfg config, git gitRunner, revision string) error { _, err := git(ctx, cfg.RepoRoot, "cat-file", "-e", revision+"^{commit}") if err != nil { - return xerrors.Errorf("revision %s is not available: %w", revision, err) + return fmt.Errorf("revision %s is not available: %w", revision, err) } return nil } @@ -53,12 +52,12 @@ func execGit(ctx context.Context, dir string, args ...string) (gitResult, error) message = strings.TrimSpace(result.Stdout) } if strings.Contains(message, "no merge base") { - return result, xerrors.Errorf("git %s: %s. Ensure both revisions have full history before diffing %q", strings.Join(args, " "), message, args[len(args)-1]) + return result, fmt.Errorf("git %s: %s. Ensure both revisions have full history before diffing %q", strings.Join(args, " "), message, args[len(args)-1]) } if message == "" { message = err.Error() } - return result, xerrors.Errorf("git %s: %s", strings.Join(args, " "), message) + return result, fmt.Errorf("git %s: %s", strings.Join(args, " "), message) } func exitCode(err error) int { diff --git a/gitfake_test.go b/gitfake_test.go index 7d7f6e7..29a598a 100644 --- a/gitfake_test.go +++ b/gitfake_test.go @@ -2,13 +2,13 @@ package main import ( "context" + "errors" "fmt" "slices" "strings" "testing" "github.com/stretchr/testify/require" - "golang.org/x/xerrors" ) type fakeGitRepo struct { @@ -89,8 +89,7 @@ func (repo fakeGitRepo) catFileResponse(t *testing.T, args []string) (gitResult, require.Len(t, args, 3) require.Equal(t, "-e", args[1]) spec := args[2] - if strings.HasSuffix(spec, "^{commit}") { - revision := strings.TrimSuffix(spec, "^{commit}") + if revision, ok := strings.CutSuffix(spec, "^{commit}"); ok { if _, ok := repo.revisions[revision]; ok { return gitResult{}, nil } @@ -167,7 +166,7 @@ func splitRevisionPath(t *testing.T, spec string) (revision string, path string) } func gitFailure(exitCode int, stderr string) (gitResult, error) { - return gitResult{Stderr: stderr, ExitCode: exitCode}, xerrors.New(stderr) + return gitResult{Stderr: stderr, ExitCode: exitCode}, errors.New(stderr) } func gitKey(args ...string) string { diff --git a/githubactions.go b/githubactions.go index 2073e28..cc08481 100644 --- a/githubactions.go +++ b/githubactions.go @@ -3,13 +3,13 @@ package main import ( "context" "encoding/json" + "errors" + "fmt" "os" "regexp" "strings" "unicode" "unicode/utf8" - - "golang.org/x/xerrors" ) // defaultDispatchBaseRef follows coder/coder's default branch name. @@ -39,26 +39,22 @@ type githubEvent struct { func githubActionsRunRequest(ctx context.Context, cfg commandConfig, git gitRunner) (runRequest, error) { baseCfg := cfg.withDefaults() if baseCfg.OutMatrix == "" { - return runRequest{}, xerrors.New("--out-matrix is required") + return runRequest{}, errors.New("--out-matrix is required") } - eventName := cfg.githubValue("GITHUB_EVENT_NAME", cfg.GitHubEventName) + eventName := os.Getenv("GITHUB_EVENT_NAME") if eventName == "" { - return runRequest{}, xerrors.New("GITHUB_EVENT_NAME is required") + return runRequest{}, errors.New("GITHUB_EVENT_NAME is required") } - eventPath := cfg.githubValue("GITHUB_EVENT_PATH", cfg.GitHubEventPath) + eventPath := os.Getenv("GITHUB_EVENT_PATH") if eventPath == "" { - return runRequest{}, xerrors.New("GITHUB_EVENT_PATH is required") + return runRequest{}, errors.New("GITHUB_EVENT_PATH is required") } - githubOutput := cfg.githubValue("GITHUB_OUTPUT", cfg.GitHubOutput) + githubOutput := os.Getenv("GITHUB_OUTPUT") if githubOutput == "" { - return runRequest{}, xerrors.New("GITHUB_OUTPUT is required") - } - githubRepository := cfg.githubValue("GITHUB_REPOSITORY", cfg.GitHubRepository) - if err := validateRepoFullName("GITHUB_REPOSITORY", githubRepository); err != nil { - return runRequest{}, err + return runRequest{}, errors.New("GITHUB_OUTPUT is required") } - stepSummary := cfg.githubValue("GITHUB_STEP_SUMMARY", cfg.GitHubStepSummary) + stepSummary := os.Getenv("GITHUB_STEP_SUMMARY") event, err := readGitHubEvent(eventPath) if err != nil { @@ -88,13 +84,13 @@ func githubActionsRunRequest(ctx context.Context, cfg commandConfig, git gitRunn case "workflow_dispatch": return workflowDispatchRunRequest(req, event) default: - return runRequest{}, xerrors.Errorf("unsupported GitHub event %q", eventName) + return runRequest{}, fmt.Errorf("unsupported GitHub event %q", eventName) } } func pullRequestRunRequest(req runRequest, event githubEvent) (runRequest, error) { baseSHA := event.PullRequest.Base.SHA - if err := validateRevision("pull_request.base.sha", baseSHA); err != nil { + if err := validateRevisionArg("pull_request.base.sha", baseSHA); err != nil { return runRequest{}, err } baseRef := event.PullRequest.Base.Ref @@ -105,19 +101,17 @@ func pullRequestRunRequest(req runRequest, event githubEvent) (runRequest, error if err := validateRepoFullName("pull_request.base.repo.full_name", baseRepo); err != nil { return runRequest{}, err } - expectedHead := event.PullRequest.Head.SHA - if expectedHead != "" { - if err := validateRevision("pull_request.head.sha", expectedHead); err != nil { - return runRequest{}, err - } - if req.Range.HeadSHA != expectedHead { - return runRequest{}, xerrors.Errorf("checked out HEAD %s does not match pull_request.head.sha %s; update actions/checkout ref to the pull request head commit", req.Range.HeadSHA, expectedHead) - } + payloadHead := event.PullRequest.Head.SHA + if err := validateRevisionArg("pull_request.head.sha", payloadHead); err != nil { + return runRequest{}, err + } + if req.Range.HeadSHA != payloadHead { + return runRequest{}, fmt.Errorf("checked out HEAD %s does not match pull_request.head.sha %s; update actions/checkout ref to the pull request head commit", req.Range.HeadSHA, payloadHead) } baseURL := githubRepoURL(baseRepo) req.Range.BaseSHA = baseSHA - req.Prepare = []fetchSpec{ + req.Fetches = []fetchSpec{ {Remote: baseURL, Ref: branchFetchRef(baseRef)}, {Remote: baseURL, Ref: baseSHA}, } @@ -126,26 +120,26 @@ func pullRequestRunRequest(req runRequest, event githubEvent) (runRequest, error func workflowDispatchRunRequest(req runRequest, event githubEvent) (runRequest, error) { if headSHA := event.Inputs.HeadSHA; headSHA != "" { - if err := validateRevision("workflow_dispatch.inputs.head_sha", headSHA); err != nil { + if err := validateRevisionArg("workflow_dispatch.inputs.head_sha", headSHA); err != nil { return runRequest{}, err } if req.Range.HeadSHA != headSHA { - return runRequest{}, xerrors.Errorf("checked out HEAD %s does not match workflow_dispatch.inputs.head_sha %s; update actions/checkout ref to the requested head commit", req.Range.HeadSHA, headSHA) + return runRequest{}, fmt.Errorf("checked out HEAD %s does not match workflow_dispatch.inputs.head_sha %s; update actions/checkout ref to the requested head commit", req.Range.HeadSHA, headSHA) } } baseSHA := event.Inputs.BaseSHA - mainFetch := fetchSpec{Remote: "origin", Ref: remoteTrackingFetchRef(defaultDispatchBaseRef)} + mainFetch := fetchSpec{Remote: "origin", Ref: remoteTrackingRefspec(defaultDispatchBaseRef)} if baseSHA != "" { - if err := validateRevision("workflow_dispatch.inputs.base_sha", baseSHA); err != nil { + if err := validateRevisionArg("workflow_dispatch.inputs.base_sha", baseSHA); err != nil { return runRequest{}, err } req.Range.BaseSHA = baseSHA - req.Prepare = []fetchSpec{mainFetch, {Remote: "origin", Ref: baseSHA}} + req.Fetches = []fetchSpec{mainFetch, {Remote: "origin", Ref: baseSHA}} return req, nil } - req.Prepare = []fetchSpec{mainFetch} + req.Fetches = []fetchSpec{mainFetch} req.MergeBaseRef = "origin/" + defaultDispatchBaseRef return req, nil } @@ -154,32 +148,22 @@ func readGitHubEvent(path string) (githubEvent, error) { // #nosec G304: path comes from the GitHub Actions runner environment. data, err := os.ReadFile(path) if err != nil { - return githubEvent{}, xerrors.Errorf("read GitHub event payload %s: %w", path, err) + return githubEvent{}, fmt.Errorf("read GitHub event payload %s: %w", path, err) } var event githubEvent if err := json.Unmarshal(data, &event); err != nil { - return githubEvent{}, xerrors.Errorf("parse GitHub event payload %s: %w", path, err) + return githubEvent{}, fmt.Errorf("parse GitHub event payload %s: %w", path, err) } return event, nil } -func (cfg commandConfig) githubValue(envName, override string) string { - if override != "" { - return override - } - if cfg.Env != nil { - return cfg.Env[envName] - } - return os.Getenv(envName) -} - func currentHeadSHA(ctx context.Context, repoRoot string, git gitRunner) (string, error) { result, err := git(ctx, repoRoot, "rev-parse", "HEAD") if err != nil { - return "", xerrors.Errorf("resolve checked out HEAD: %w", err) + return "", fmt.Errorf("resolve checked out HEAD: %w", err) } head := strings.TrimSpace(result.Stdout) - if err := validateRevision("checked out HEAD", head); err != nil { + if err := validateRevisionArg("checked out HEAD", head); err != nil { return "", err } return head, nil @@ -189,53 +173,50 @@ func ensureRangeAvailable(ctx context.Context, req *runRequest, git gitRunner, f if req.RepoRoot == "" { req.RepoRoot = defaultRepoRoot } - if err := validateRevision("head revision", req.Range.HeadSHA); err != nil { + if err := validateRevisionArg("head revision", req.Range.HeadSHA); err != nil { return err } if req.Range.BaseSHA != "" { return ensureConcreteRangeAvailable(ctx, req, git, fetch) } if req.MergeBaseRef == "" { - return xerrors.New("base revision is required") + return errors.New("base revision is required") } - if err := fetchSpecs(ctx, req, fetch); err != nil { + if err := runFetches(ctx, req, fetch); err != nil { return err } baseSHA, err := gitMergeBase(ctx, req.RepoRoot, git, req.Range.HeadSHA, req.MergeBaseRef) if err != nil { - return xerrors.Errorf("failed to resolve merge-base between %s and %s after fetching base history: %w", req.Range.HeadSHA, req.MergeBaseRef, err) + return fmt.Errorf("failed to resolve merge-base between %s and %s after fetching base history: %w", req.Range.HeadSHA, req.MergeBaseRef, err) } - if err := validateRevision("resolved base revision", baseSHA); err != nil { + if err := validateRevisionArg("resolved base revision", baseSHA); err != nil { return err } req.Range.BaseSHA = baseSHA - if _, err := gitMergeBase(ctx, req.RepoRoot, git, req.Range.BaseSHA, req.Range.HeadSHA); err != nil { - return xerrors.Errorf("unable to resolve a merge base for %s...%s after fetching base history: %w", req.Range.BaseSHA, req.Range.HeadSHA, err) - } return nil } func ensureConcreteRangeAvailable(ctx context.Context, req *runRequest, git gitRunner, fetch gitFetcher) error { - if err := validateRevision("base revision", req.Range.BaseSHA); err != nil { + if err := validateRevisionArg("base revision", req.Range.BaseSHA); err != nil { return err } _, mergeErr := gitMergeBase(ctx, req.RepoRoot, git, req.Range.BaseSHA, req.Range.HeadSHA) if mergeErr == nil { return nil } - if len(req.Prepare) == 0 { - return xerrors.Errorf("unable to resolve merge base for %s...%s: %w", req.Range.BaseSHA, req.Range.HeadSHA, mergeErr) + if len(req.Fetches) == 0 { + return fmt.Errorf("unable to resolve merge base for %s...%s: %w", req.Range.BaseSHA, req.Range.HeadSHA, mergeErr) } if fetch == nil { - return xerrors.New("history fetch is required but no fetcher was configured") + return errors.New("history fetch is required but no fetcher was configured") } - for _, spec := range req.Prepare { + for _, spec := range req.Fetches { if err := validateFetchSpec(spec); err != nil { return err } if _, err := fetch(ctx, req.RepoRoot, spec); err != nil { - return xerrors.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err) + return fmt.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err) } _, err := gitMergeBase(ctx, req.RepoRoot, git, req.Range.BaseSHA, req.Range.HeadSHA) if err == nil { @@ -243,22 +224,22 @@ func ensureConcreteRangeAvailable(ctx context.Context, req *runRequest, git gitR } mergeErr = err } - return xerrors.Errorf("unable to resolve a merge base for %s...%s after fetching base history: %w", req.Range.BaseSHA, req.Range.HeadSHA, mergeErr) + return fmt.Errorf("unable to resolve a merge base for %s...%s after fetching base history: %w", req.Range.BaseSHA, req.Range.HeadSHA, mergeErr) } -func fetchSpecs(ctx context.Context, req *runRequest, fetch gitFetcher) error { - if len(req.Prepare) == 0 { +func runFetches(ctx context.Context, req *runRequest, fetch gitFetcher) error { + if len(req.Fetches) == 0 { return nil } if fetch == nil { - return xerrors.New("history fetch is required but no fetcher was configured") + return errors.New("history fetch is required but no fetcher was configured") } - for _, spec := range req.Prepare { + for _, spec := range req.Fetches { if err := validateFetchSpec(spec); err != nil { return err } if _, err := fetch(ctx, req.RepoRoot, spec); err != nil { - return xerrors.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err) + return fmt.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err) } } return nil @@ -266,7 +247,7 @@ func fetchSpecs(ctx context.Context, req *runRequest, fetch gitFetcher) error { func validateFetchSpec(spec fetchSpec) error { if spec.Remote == "" || spec.Ref == "" { - return xerrors.Errorf("invalid fetch spec: remote and ref are required") + return fmt.Errorf("invalid fetch spec: remote and ref are required") } return nil } @@ -278,7 +259,7 @@ func gitMergeBase(ctx context.Context, repoRoot string, git gitRunner, left, rig } base := strings.TrimSpace(result.Stdout) if base == "" { - return "", xerrors.Errorf("git merge-base %s %s returned no revision", left, right) + return "", fmt.Errorf("git merge-base %s %s returned no revision", left, right) } return base, nil } @@ -289,32 +270,32 @@ func execGitFetch(ctx context.Context, dir string, spec fetchSpec) (gitResult, e func validateRef(name, value string) error { if value == "" { - return xerrors.Errorf("%s is required", name) + return fmt.Errorf("%s is required", name) } if strings.HasPrefix(value, "-") { - return xerrors.Errorf("%s must not start with '-': %q", name, value) + return fmt.Errorf("%s must not start with '-': %q", name, value) } if !utf8.ValidString(value) || strings.ContainsRune(value, '\x00') { - return xerrors.Errorf("%s must not contain invalid bytes", name) + return fmt.Errorf("%s must not contain invalid bytes", name) } if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") { - return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + return fmt.Errorf("%s must be a safe branch ref: %q", name, value) } if strings.Contains(value, "..") || strings.Contains(value, "@{") || strings.HasSuffix(value, ".lock") { - return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + return fmt.Errorf("%s must be a safe branch ref: %q", name, value) } for _, r := range value { if unicode.IsControl(r) || unicode.IsSpace(r) { - return xerrors.Errorf("%s must not contain control or whitespace characters: %q", name, value) + return fmt.Errorf("%s must not contain control or whitespace characters: %q", name, value) } switch r { case ':', '^', '~', '?', '*', '[', '\\': - return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + return fmt.Errorf("%s must be a safe branch ref: %q", name, value) } } for segment := range strings.SplitSeq(value, "/") { if segment == "" || strings.HasPrefix(segment, ".") { - return xerrors.Errorf("%s must be a safe branch ref: %q", name, value) + return fmt.Errorf("%s must be a safe branch ref: %q", name, value) } } return nil @@ -322,10 +303,10 @@ func validateRef(name, value string) error { func validateRepoFullName(name, value string) error { if value == "" { - return xerrors.Errorf("%s is required", name) + return fmt.Errorf("%s is required", name) } if !repoFullNameRE.MatchString(value) || strings.Contains(value, "..") { - return xerrors.Errorf("%s must be a GitHub owner/repository name: %q", name, value) + return fmt.Errorf("%s must be a GitHub owner/repository name: %q", name, value) } return nil } @@ -338,6 +319,6 @@ func branchFetchRef(ref string) string { return "refs/heads/" + ref } -func remoteTrackingFetchRef(ref string) string { +func remoteTrackingRefspec(ref string) string { return branchFetchRef(ref) + ":refs/remotes/origin/" + ref } diff --git a/githubactions_test.go b/githubactions_test.go index 8e4f793..cd123cd 100644 --- a/githubactions_test.go +++ b/githubactions_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "os" "path/filepath" @@ -10,8 +11,6 @@ import ( ) func TestGitHubActionsRunRequestPullRequest(t *testing.T) { - t.Parallel() - eventPath := writeGitHubEvent(t, `{ "pull_request": { "base": { @@ -23,16 +22,14 @@ func TestGitHubActionsRunRequestPullRequest(t *testing.T) { }, "ignored": true }`) + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + t.Setenv("GITHUB_EVENT_PATH", eventPath) + t.Setenv("GITHUB_OUTPUT", "output.txt") + t.Setenv("GITHUB_STEP_SUMMARY", "summary.md") + t.Setenv("UNRELATED_EXTRA_ENV", "ignored") + req, err := githubActionsRunRequest(t.Context(), commandConfig{ config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, - Env: map[string]string{ - "GITHUB_EVENT_NAME": "pull_request", - "GITHUB_EVENT_PATH": eventPath, - "GITHUB_OUTPUT": "output.txt", - "GITHUB_REPOSITORY": "coder/coder", - "GITHUB_STEP_SUMMARY": "summary.md", - "UNRELATED_EXTRA_ENV": "ignored", - }, }, fakeGitRepo{headSHA: "head123"}.runner(t)) require.NoError(t, err) require.Equal(t, "/repo", req.RepoRoot) @@ -40,15 +37,13 @@ func TestGitHubActionsRunRequestPullRequest(t *testing.T) { require.Equal(t, []fetchSpec{ {Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}, {Remote: "https://github.com/coder/coder.git", Ref: "base123"}, - }, req.Prepare) + }, req.Fetches) require.Equal(t, "matrix.json", req.Sinks.OutMatrix) require.Equal(t, "output.txt", req.Sinks.GitHubOutput) require.Equal(t, "summary.md", req.Sinks.GitHubStepSummary) } func TestGitHubActionsRunRequestVerifiesPullRequestHead(t *testing.T) { - t.Parallel() - eventPath := writeGitHubEvent(t, `{ "pull_request": { "base": { @@ -59,52 +54,146 @@ func TestGitHubActionsRunRequestVerifiesPullRequestHead(t *testing.T) { "head": {"sha": "expected-head"} } }`) + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + t.Setenv("GITHUB_EVENT_PATH", eventPath) + t.Setenv("GITHUB_OUTPUT", "output.txt") + _, err := githubActionsRunRequest(t.Context(), commandConfig{ config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, - Env: map[string]string{ - "GITHUB_EVENT_NAME": "pull_request", - "GITHUB_EVENT_PATH": eventPath, - "GITHUB_OUTPUT": "output.txt", - "GITHUB_REPOSITORY": "coder/coder", - }, }, fakeGitRepo{headSHA: "actual-head"}.runner(t)) require.ErrorContains(t, err, "checked out HEAD actual-head does not match pull_request.head.sha expected-head") } -func TestGitHubActionsRunRequestWorkflowDispatchExplicitRange(t *testing.T) { - t.Parallel() +func TestGitHubActionsRunRequestRequiresPullRequestHead(t *testing.T) { + eventPath := writeGitHubEvent(t, `{ + "pull_request": { + "base": { + "sha": "base123", + "ref": "main", + "repo": {"full_name": "coder/coder"} + }, + "head": {"sha": ""} + } + }`) + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + t.Setenv("GITHUB_EVENT_PATH", eventPath) + t.Setenv("GITHUB_OUTPUT", "output.txt") + + _, err := githubActionsRunRequest(t.Context(), commandConfig{ + config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, + }, fakeGitRepo{headSHA: "head123"}.runner(t)) + require.ErrorContains(t, err, "pull_request.head.sha is required") +} +func TestGitHubActionsRunRequestWorkflowDispatchExplicitRange(t *testing.T) { eventPath := writeGitHubEvent(t, `{ "inputs": { "base_sha": "base123", "head_sha": "head123" } }`) + t.Setenv("GITHUB_EVENT_NAME", "workflow_dispatch") + t.Setenv("GITHUB_EVENT_PATH", eventPath) + t.Setenv("GITHUB_OUTPUT", "output.txt") + req, err := githubActionsRunRequest(t.Context(), commandConfig{ config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, - Env: map[string]string{ - "GITHUB_EVENT_NAME": "workflow_dispatch", - "GITHUB_EVENT_PATH": eventPath, - "GITHUB_OUTPUT": "output.txt", - "GITHUB_REPOSITORY": "coder/coder", - }, }, fakeGitRepo{headSHA: "head123"}.runner(t)) require.NoError(t, err) require.Equal(t, diffRange{BaseSHA: "base123", HeadSHA: "head123"}, req.Range) require.Equal(t, []fetchSpec{ {Remote: "origin", Ref: "refs/heads/main:refs/remotes/origin/main"}, {Remote: "origin", Ref: "base123"}, - }, req.Prepare) + }, req.Fetches) require.Empty(t, req.MergeBaseRef) } +func TestRunCommandGitHubActionsWritesOutputs(t *testing.T) { + oldContent := `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("old") +} +` + newContent := `package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("new") +} +` + rangeForAlpha := singleLineRange(t, newContent, `t.Log("new")`) + repo := fakeGitRepo{ + headSHA: "head123", + changes: []testFileChange{{ + Kind: changeModified, + OldPath: "pkg/sample_test.go", + NewPath: "pkg/sample_test.go", + }}, + revisions: map[string]map[string]string{ + "base123": {"pkg/sample_test.go": oldContent}, + "head123": {"pkg/sample_test.go": newContent}, + }, + diffOutputs: map[string]string{ + "pkg/sample_test.go": diffForChange(rangeForAlpha, rangeForAlpha), + }, + } + tmpDir := t.TempDir() + eventPath := writeGitHubEvent(t, `{ + "pull_request": { + "base": { + "sha": "base123", + "ref": "main", + "repo": {"full_name": "coder/coder"} + }, + "head": {"sha": "head123"} + } + }`) + outputPath := filepath.Join(tmpDir, "github-output.txt") + stepSummaryPath := filepath.Join(tmpDir, "step-summary.md") + localSummaryPath := filepath.Join(tmpDir, "summary.md") + matrixPath := filepath.Join(tmpDir, "matrix.json") + t.Setenv("GITHUB_EVENT_NAME", "pull_request") + t.Setenv("GITHUB_EVENT_PATH", eventPath) + t.Setenv("GITHUB_OUTPUT", outputPath) + t.Setenv("GITHUB_STEP_SUMMARY", stepSummaryPath) + + var stdout bytes.Buffer + var stderr bytes.Buffer + fetch := func(context.Context, string, fetchSpec) (gitResult, error) { + t.Fatal("unexpected fetch") + return gitResult{}, nil + } + err := runCommand(t.Context(), commandConfig{ + config: config{RepoRoot: "/repo", OutMatrix: matrixPath, OutSummary: localSummaryPath}, + GitHubActions: true, + }, &stdout, &stderr, repo.runner(t), fetch) + require.NoError(t, err) + + matrixData, err := os.ReadFile(matrixPath) + require.NoError(t, err) + require.JSONEq(t, `{"include":[{"package":"./pkg","run_regex":"^(TestAlpha)(/.*)?$","test_count":"10"}]}`, string(matrixData)) + outputData, err := os.ReadFile(outputPath) + require.NoError(t, err) + require.Equal(t, "matrix="+string(bytes.TrimSpace(matrixData))+"\n", string(outputData)) + stepSummary, err := os.ReadFile(stepSummaryPath) + require.NoError(t, err) + require.Contains(t, string(stepSummary), `"pkg/sample_test.go"`) + require.Contains(t, string(stepSummary), "TestAlpha") + require.Empty(t, stdout.String()) + require.Contains(t, stderr.String(), "selected 1 package targets") +} + func TestEnsureRangeAvailableWorkflowDispatchDefaultBase(t *testing.T) { t.Parallel() req := runRequest{ RepoRoot: "/repo", Range: diffRange{HeadSHA: "head123"}, - Prepare: []fetchSpec{{Remote: "origin", Ref: "refs/heads/main:refs/remotes/origin/main"}}, + Fetches: []fetchSpec{{Remote: "origin", Ref: "refs/heads/main:refs/remotes/origin/main"}}, MergeBaseRef: "origin/main", } repo := fakeGitRepo{ @@ -133,7 +222,7 @@ func TestEnsureRangeAvailableFetchesLazily(t *testing.T) { req := runRequest{ RepoRoot: "/repo", Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, - Prepare: []fetchSpec{{Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}}, + Fetches: []fetchSpec{{Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}}, } repo := fakeGitRepo{revisions: map[string]map[string]string{"base123": {}, "head123": {}}} fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { @@ -149,7 +238,7 @@ func TestEnsureRangeAvailableFetchesWhenMergeBaseIsMissing(t *testing.T) { req := runRequest{ RepoRoot: "/repo", Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, - Prepare: []fetchSpec{ + Fetches: []fetchSpec{ {Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}, {Remote: "https://github.com/coder/coder.git", Ref: "base123"}, }, @@ -170,12 +259,10 @@ func TestEnsureRangeAvailableFetchesWhenMergeBaseIsMissing(t *testing.T) { } require.NoError(t, ensureRangeAvailable(t.Context(), &req, git, fetch)) require.Equal(t, 2, mergeBaseCalls) - require.Equal(t, req.Prepare[:1], fetches) + require.Equal(t, req.Fetches[:1], fetches) } func TestGitHubActionsRunRequestValidatesInputsBeforeFetch(t *testing.T) { - t.Parallel() - tests := []struct { name string eventName string @@ -210,17 +297,13 @@ func TestGitHubActionsRunRequestValidatesInputsBeforeFetch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - t.Parallel() - eventPath := writeGitHubEvent(t, tc.eventJSON) + t.Setenv("GITHUB_EVENT_NAME", tc.eventName) + t.Setenv("GITHUB_EVENT_PATH", eventPath) + t.Setenv("GITHUB_OUTPUT", "output.txt") + _, err := githubActionsRunRequest(t.Context(), commandConfig{ config: config{RepoRoot: "/repo", OutMatrix: "matrix.json"}, - Env: map[string]string{ - "GITHUB_EVENT_NAME": tc.eventName, - "GITHUB_EVENT_PATH": eventPath, - "GITHUB_OUTPUT": "output.txt", - "GITHUB_REPOSITORY": "coder/coder", - }, }, fakeGitRepo{headSHA: "head123"}.runner(t)) require.ErrorContains(t, err, tc.want) }) diff --git a/go.mod b/go.mod index 3be0b03..0c26715 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,7 @@ module github.com/coder/whichtests go 1.26.2 -require ( - github.com/stretchr/testify v1.10.0 - golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da -) +require github.com/stretchr/testify v1.10.0 require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index e9f82d4..713a0b4 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= -golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/helpers_test.go b/helpers_test.go index a40647f..574e907 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -26,14 +26,14 @@ func mustPackageInventory(t *testing.T, files map[string]string) packageInventor const packageName = "sample" inventory := packageInventory{ Key: packageKey{Dir: "pkg", Name: packageName}, - Tests: map[string][]testDecl{}, + Tests: map[string]struct{}{}, } - for filePath, content := range files { - snapshot, err := parseOrFallbackSnapshot([]byte(content)) + for _, content := range files { + snapshot, err := parseFileSnapshot([]byte(content)) require.NoError(t, err) require.Equal(t, packageName, snapshot.packageName) - for testName, declRange := range snapshot.tests { - inventory.Tests[testName] = append(inventory.Tests[testName], testDecl{FilePath: filePath, Range: declRange}) + for testName := range snapshot.tests { + inventory.Tests[testName] = struct{}{} } } return inventory diff --git a/integration_test.go b/integration_test.go index da5fa00..d2199ce 100644 --- a/integration_test.go +++ b/integration_test.go @@ -38,7 +38,7 @@ func TestAdded(t *testing.T) { summaryPath := filepath.Join(repoRoot, "summary.md") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: baseSHA, HeadSHA: headSHA, OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, execGit) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: baseSHA, HeadSHA: headSHA, OutMatrix: matrixPath, OutSummary: summaryPath}}, &stdout, &stderr, execGit, nil) require.NoError(t, err) var matrix matrixOutput @@ -89,7 +89,7 @@ func TestAlpha(t *testing.T) { summaryPath := filepath.Join(repoRoot, "summary.md") var stdout bytes.Buffer var stderr bytes.Buffer - err := run(t.Context(), config{RepoRoot: repoRoot, BaseSHA: baseSHA, HeadSHA: headSHA, OutMatrix: matrixPath, OutSummary: summaryPath}, &stdout, &stderr, execGit) + err := runCommand(t.Context(), commandConfig{config: config{RepoRoot: repoRoot, BaseSHA: baseSHA, HeadSHA: headSHA, OutMatrix: matrixPath, OutSummary: summaryPath}}, &stdout, &stderr, execGit, nil) require.NoError(t, err) var matrix matrixOutput @@ -158,8 +158,8 @@ func TestAlpha(t *testing.T) { req := runRequest{ RepoRoot: cloneRoot, Range: diffRange{BaseSHA: baseSHA, HeadSHA: headSHA}, - Prepare: []fetchSpec{ - {Remote: "origin", Ref: remoteTrackingFetchRef(defaultDispatchBaseRef)}, + Fetches: []fetchSpec{ + {Remote: "origin", Ref: remoteTrackingRefspec(defaultDispatchBaseRef)}, {Remote: "origin", Ref: baseSHA}, }, } diff --git a/inventory.go b/inventory.go index 8b395e4..91e2b07 100644 --- a/inventory.go +++ b/inventory.go @@ -3,12 +3,11 @@ package main import ( "cmp" "context" + "fmt" "maps" "path/filepath" "slices" "strings" - - "golang.org/x/xerrors" ) type inventoryCache struct { @@ -39,7 +38,7 @@ func (cache *inventoryCache) loadPackageInventory(ctx context.Context, revision } inventory := packageInventory{ Key: key, - Tests: map[string][]testDecl{}, + Tests: map[string]struct{}{}, } for _, filePath := range files { data, exists, err := readFileAtRevision(ctx, cache.cfg, cache.git, revision, filePath) @@ -49,15 +48,15 @@ func (cache *inventoryCache) loadPackageInventory(ctx context.Context, revision if !exists { continue } - snapshot, err := parseOrFallbackSnapshot(data) + snapshot, err := parseFileSnapshot(data) if err != nil { - return packageInventory{}, xerrors.Errorf("parse %s at %s: %w", filePath, revision, err) + return packageInventory{}, fmt.Errorf("parse %s at %s: %w", filePath, revision, err) } if snapshot.packageName != key.Name { continue } - for testName, declRange := range snapshot.tests { - inventory.Tests[testName] = append(inventory.Tests[testName], testDecl{FilePath: filePath, Range: declRange}) + for testName := range snapshot.tests { + inventory.Tests[testName] = struct{}{} } } cache.packages[cacheKey] = inventory @@ -127,9 +126,9 @@ func (cache *inventoryCache) loadDirectoryInventories(ctx context.Context, revis if !exists { continue } - snapshot, err := parseOrFallbackSnapshot(data) + snapshot, err := parseFileSnapshot(data) if err != nil { - return nil, xerrors.Errorf("parse %s at %s: %w", filePath, revision, err) + return nil, fmt.Errorf("parse %s at %s: %w", filePath, revision, err) } packageNames[snapshot.packageName] = struct{}{} } diff --git a/inventory_test.go b/inventory_test.go new file mode 100644 index 0000000..a66336a --- /dev/null +++ b/inventory_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadPackageInventoryReturnsParseErrors(t *testing.T) { + t.Parallel() + + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "head": { + "pkg/good_test.go": `package sample + +import "testing" + +func TestGood(t *testing.T) {} +`, + "pkg/broken_test.go": `package sample + +import "testing" + +func TestBroken(t *testing.T) { +`, + }, + }, + } + cache := newInventoryCache(config{RepoRoot: "/repo"}, repo.runner(t)) + _, err := cache.loadPackageInventory(t.Context(), "head", packageKey{Dir: "pkg", Name: "sample"}) + require.ErrorContains(t, err, "parse pkg/broken_test.go at head") +} diff --git a/plan.go b/plan.go index c3ef811..717a0b5 100644 --- a/plan.go +++ b/plan.go @@ -6,9 +6,8 @@ import ( "maps" "regexp" "slices" + "strconv" "strings" - - "golang.org/x/xerrors" ) var ( @@ -20,6 +19,9 @@ type matrixOutput struct { Include []matrixEntry `json:"include"` } +// matrixEntry.Package is a single safe package token except for overflow rows, +// where it is a space-separated list of safe package tokens consumed by the +// flake-go workflow. type matrixEntry struct { Package string `json:"package"` RunRegex string `json:"run_regex,omitempty"` @@ -84,8 +86,8 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul accumulators := map[string]*executionAccumulator{} for key, selection := range selections { packagePath := packagePattern(key.Dir) - if !safePackagePatternRE.MatchString(packagePath) { - return buildResult{}, xerrors.Errorf("unsafe package path %q", packagePath) + if !isSafePackagePattern(packagePath) { + return buildResult{}, fmt.Errorf("unsafe package path %q", packagePath) } entry := accumulators[packagePath] if entry == nil { @@ -93,7 +95,7 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul Package: packagePath, Files: map[string]struct{}{}, Tests: map[string]struct{}{}, - TestCount: defaultTargetCount, + TestCount: defaultTestCount, } accumulators[packagePath] = entry } @@ -110,21 +112,17 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul files := slices.Sorted(maps.Keys(entry.Files)) if entry.Broadened && len(tests) > maxBroadenedTests { entry.RunAll = true - entry.TestCount = runOnceTargetCount + entry.TestCount = runOnceTestCount entry.Notes = appendUniqueNote(entry.Notes, fmt.Sprintf("Package-wide broadening selected %d tests, above the %d-test cap, so this target will run all tests once.", len(tests), maxBroadenedTests)) } if unsafeTestNames := unsafeRunRegexTestNames(tests); len(unsafeTestNames) > 0 { entry.RunAll = true - entry.TestCount = runOnceTargetCount + entry.TestCount = runOnceTestCount entry.Notes = appendUniqueNote(entry.Notes, fmt.Sprintf("Selected %d test names that cannot be passed safely through RUN, so this target will run all tests once.", len(unsafeTestNames))) } runRegex := "" if !entry.RunAll { - var err error - runRegex, err = buildRunRegex(tests) - if err != nil { - return buildResult{}, xerrors.Errorf("build run regex for %s: %w", packagePath, err) - } + runRegex = buildRunRegex(tests) } result.Matrix.Include = append(result.Matrix.Include, matrixEntry{ Package: packagePath, @@ -157,14 +155,14 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul result.Matrix.Include = result.Matrix.Include[:keep] result.Matrix.Include = append(result.Matrix.Include, matrixEntry{ Package: strings.Join(overflowPackages, " "), - TestCount: runOnceTargetCount, + TestCount: runOnceTestCount, }) result.Summary.Entries = result.Summary.Entries[:keep] result.Summary.Entries = append(result.Summary.Entries, summaryEntry{ Label: fmt.Sprintf("overflow target (%d packages)", len(overflowPackages)), Files: slices.Sorted(maps.Keys(overflowFiles)), RunAll: true, - TestCount: runOnceTargetCount, + TestCount: runOnceTestCount, Notes: []string{ note, summarizePackages(overflowPackages), @@ -199,6 +197,25 @@ func appendUniqueNote(notes []string, note string) []string { return append(notes, note) } +func isSafePackagePattern(packagePath string) bool { + if !safePackagePatternRE.MatchString(packagePath) { + return false + } + if packagePath == "." { + return true + } + trimmed, ok := strings.CutPrefix(packagePath, "./") + if !ok { + return false + } + for segment := range strings.SplitSeq(trimmed, "/") { + if segment == ".." { + return false + } + } + return true +} + func unsafeRunRegexTestNames(tests []string) []string { unsafeNames := make([]string, 0) for _, testName := range tests { @@ -209,15 +226,12 @@ func unsafeRunRegexTestNames(tests []string) []string { return unsafeNames } -func buildRunRegex(tests []string) (string, error) { +func buildRunRegex(tests []string) string { quoted := make([]string, 0, len(tests)) for _, testName := range tests { - if !safeTestNameRE.MatchString(testName) { - return "", xerrors.Errorf("unsafe test name %q", testName) - } quoted = append(quoted, regexp.QuoteMeta(testName)) } - return "^(" + strings.Join(quoted, "|") + ")(/.*)?$", nil + return "^(" + strings.Join(quoted, "|") + ")(/.*)?$" } func renderSummary(changedFiles []string, summary summaryReport) string { @@ -231,7 +245,7 @@ func renderSummary(changedFiles []string, summary summaryReport) string { _, _ = builder.WriteString("Changed `*_test.go` files were detected, but no runnable top-level tests were selected.\n\n") _, _ = builder.WriteString("Files:\n") for _, filePath := range changedFiles { - _, _ = builder.WriteString("- `" + filePath + "`\n") + _, _ = builder.WriteString("- " + renderSummaryFilePath(filePath) + "\n") } return builder.String() } @@ -252,7 +266,7 @@ func renderSummary(changedFiles []string, summary summaryReport) string { _, _ = builder.WriteString("### `" + entry.Label + "`\n\n") _, _ = builder.WriteString("Files:\n") for _, filePath := range entry.Files { - _, _ = builder.WriteString("- `" + filePath + "`\n") + _, _ = builder.WriteString("- " + renderSummaryFilePath(filePath) + "\n") } if len(entry.Notes) > 0 { _, _ = builder.WriteString("\nNotes:\n") @@ -280,6 +294,10 @@ func renderSummary(changedFiles []string, summary summaryReport) string { return builder.String() } +func renderSummaryFilePath(filePath string) string { + return strconv.QuoteToASCII(filePath) +} + func countDescription(count string) string { if count == "1" { return "once" diff --git a/plan_test.go b/plan_test.go index b1d0bde..1c24017 100644 --- a/plan_test.go +++ b/plan_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "strings" "testing" "github.com/stretchr/testify/require" @@ -22,11 +23,13 @@ func TestRenderSummaryNoRunnableTests(t *testing.T) { require.Contains(t, summary, "pkg/changed_test.go") } -func TestBuildRunRegexRejectsUnsafeNames(t *testing.T) { +func TestRenderSummaryQuotesFilenames(t *testing.T) { t.Parallel() - _, err := buildRunRegex([]string{"TestAlpha", "TestO'Brien"}) - require.Error(t, err) + summary := renderSummary([]string{"pkg/with`tick_test.go", "pkg/with\nnewline_test.go"}, summaryReport{}) + require.Contains(t, summary, `"pkg/with`+"`"+`tick_test.go"`) + require.Contains(t, summary, `"pkg/with\nnewline_test.go"`) + require.NotContains(t, summary, "pkg/with\nnewline_test.go") } func TestBuildExecutionPlanRunsAllForUnsafeTestNames(t *testing.T) { @@ -60,6 +63,14 @@ func TestBuildExecutionPlanRejectsUnsafePackagePaths(t *testing.T) { require.ErrorContains(t, err, "unsafe package path") } +func TestBuildExecutionPlanRejectsPackageTraversalSegments(t *testing.T) { + t.Parallel() + + for _, packagePath := range []string{"./foo/../bar", "./..", "./foo/.."} { + require.False(t, isSafePackagePattern(packagePath), packagePath) + } +} + func TestBuildExecutionPlanCapsBroadenedTarget(t *testing.T) { t.Parallel() @@ -97,9 +108,15 @@ func TestBuildExecutionPlanCapsMatrixTargets(t *testing.T) { require.NoError(t, err) require.Len(t, result.Matrix.Include, maxMatrixEntries) overflow := result.Matrix.Include[len(result.Matrix.Include)-1] - require.Equal(t, "1", overflow.TestCount) + require.Equal(t, strings.Join([]string{ + "./pkg19", "./pkg20", "./pkg21", "./pkg22", "./pkg23", "./pkg24", "./pkg25", + "./pkg26", "./pkg27", "./pkg28", "./pkg29", "./pkg30", "./pkg31", + }, " "), overflow.Package) require.Empty(t, overflow.RunRegex) - require.Contains(t, overflow.Package, "./pkg") + require.Equal(t, "1", overflow.TestCount) + for _, packagePath := range strings.Fields(overflow.Package) { + require.True(t, isSafePackagePattern(packagePath), packagePath) + } require.Contains(t, result.Summary.Notes[0], "Matrix target cap") require.Contains(t, result.Summary.Entries[len(result.Summary.Entries)-1].Notes[1], "and 3 more") } diff --git a/publish.go b/publish.go index 2f56bdc..db88ebd 100644 --- a/publish.go +++ b/publish.go @@ -2,17 +2,16 @@ package main import ( "encoding/json" + "fmt" "io" "os" "path/filepath" "strings" - - "golang.org/x/xerrors" ) const defaultGitHubOutputValueLimit = 1024 * 1024 -func publishPlan(sinks outputSinks, matrix matrixOutput, summary string, stdout io.Writer, outputSizeLimit int) error { +func publishPlan(sinks outputSinks, matrix matrixOutput, summary string, stdout io.Writer) error { matrixData, err := marshalMatrix(matrix) if err != nil { return err @@ -28,7 +27,7 @@ func publishPlan(sinks outputSinks, matrix matrixOutput, summary string, stdout } } if sinks.GitHubOutput != "" { - if err := appendGitHubOutput(sinks.GitHubOutput, "matrix", string(matrixData), outputSizeLimit); err != nil { + if err := appendGitHubOutput(sinks.GitHubOutput, "matrix", string(matrixData), 0); err != nil { return err } } @@ -46,20 +45,20 @@ func marshalMatrix(matrix matrixOutput) ([]byte, error) { } data, err := json.Marshal(matrix) if err != nil { - return nil, xerrors.Errorf("marshal matrix json: %w", err) + return nil, fmt.Errorf("marshal matrix json: %w", err) } return data, nil } func appendGitHubOutput(path, name, value string, outputSizeLimit int) error { if strings.ContainsAny(value, "\r\n") { - return xerrors.Errorf("GitHub output %s must be a single line", name) + return fmt.Errorf("GitHub output %s must be a single line", name) } if outputSizeLimit == 0 { outputSizeLimit = defaultGitHubOutputValueLimit } if len(value) > outputSizeLimit { - return xerrors.Errorf("GitHub output %s is %d bytes, above the %d byte limit", name, len(value), outputSizeLimit) + return fmt.Errorf("GitHub output %s is %d bytes, above the %d byte limit", name, len(value), outputSizeLimit) } return appendFile(path, []byte(name+"="+value+"\n")) } @@ -76,11 +75,11 @@ func writeFile(path string, data []byte) error { dir := filepath.Dir(path) if dir != "." { if err := os.MkdirAll(dir, 0o750); err != nil { - return xerrors.Errorf("mkdir %s: %w", dir, err) + return fmt.Errorf("mkdir %s: %w", dir, err) } } if err := os.WriteFile(path, data, 0o600); err != nil { - return xerrors.Errorf("write %s: %w", path, err) + return fmt.Errorf("write %s: %w", path, err) } return nil } @@ -89,23 +88,23 @@ func appendFile(path string, data []byte) (err error) { dir := filepath.Dir(path) if dir != "." { if err = os.MkdirAll(dir, 0o750); err != nil { - return xerrors.Errorf("mkdir %s: %w", dir, err) + return fmt.Errorf("mkdir %s: %w", dir, err) } } // #nosec G304: path is a user-supplied output path or a GitHub Actions runner path. file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { - return xerrors.Errorf("open %s: %w", path, err) + return fmt.Errorf("open %s: %w", path, err) } defer func() { // Surface Close errors only if Write succeeded; write paths can // lose data on a deferred fsync/flush failure. if cerr := file.Close(); cerr != nil && err == nil { - err = xerrors.Errorf("close %s: %w", path, cerr) + err = fmt.Errorf("close %s: %w", path, cerr) } }() if _, err := file.Write(data); err != nil { - return xerrors.Errorf("append %s: %w", path, err) + return fmt.Errorf("append %s: %w", path, err) } return nil } diff --git a/publish_test.go b/publish_test.go index b47cf30..ce83f93 100644 --- a/publish_test.go +++ b/publish_test.go @@ -23,7 +23,7 @@ func TestPublishPlanWritesCompactGitHubOutputs(t *testing.T) { OutSummary: summaryPath, GitHubOutput: outputPath, GitHubStepSummary: stepSummaryPath, - }, matrixOutput{Include: []matrixEntry{{Package: "./pkg", RunRegex: "^(TestAlpha)(/.*)?$", TestCount: "10"}}}, summary, nil, 0) + }, matrixOutput{Include: []matrixEntry{{Package: "./pkg", RunRegex: "^(TestAlpha)(/.*)?$", TestCount: "10"}}}, summary, nil) require.NoError(t, err) matrixData, err := os.ReadFile(matrixPath) diff --git a/request.go b/request.go index 9974162..e5976b1 100644 --- a/request.go +++ b/request.go @@ -1,9 +1,8 @@ package main import ( + "fmt" "strings" - - "golang.org/x/xerrors" ) type diffRange struct { @@ -12,12 +11,11 @@ type diffRange struct { } type runRequest struct { - RepoRoot string - Range diffRange - Prepare []fetchSpec - MergeBaseRef string - Sinks outputSinks - OutputSizeLimit int + RepoRoot string + Range diffRange + Fetches []fetchSpec + MergeBaseRef string + Sinks outputSinks } type fetchSpec struct { @@ -32,18 +30,20 @@ type outputSinks struct { GitHubStepSummary string } -func validateRevision(flagName, revision string) error { +// validateRevisionArg rejects git revision strings that would be unsafe to pass +// as a single argv element. It is not a SHA-format validator. +func validateRevisionArg(name, revision string) error { if revision == "" { - return xerrors.Errorf("%s is required", flagName) + return fmt.Errorf("%s is required", name) } if strings.HasPrefix(revision, "-") { - return xerrors.Errorf("%s must not start with '-': %q", flagName, revision) + return fmt.Errorf("%s must not start with '-': %q", name, revision) } if strings.Contains(revision, ":") { - return xerrors.Errorf("%s must not contain ':': %q", flagName, revision) + return fmt.Errorf("%s must not contain ':': %q", name, revision) } if strings.ContainsRune(revision, '\x00') { - return xerrors.Errorf("%s must not contain NUL bytes", flagName) + return fmt.Errorf("%s must not contain NUL bytes", name) } return nil } diff --git a/request_test.go b/request_test.go new file mode 100644 index 0000000..e63616f --- /dev/null +++ b/request_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateRevisionArgAllowsCommonGitRevisions(t *testing.T) { + t.Parallel() + + for _, revision := range []string{ + "HEAD", + "HEAD~3", + "origin/main", + "refs/heads/main", + "v1.2.3", + "abc1234", + "0123456789abcdef0123456789abcdef01234567", + } { + require.NoError(t, validateRevisionArg("revision", revision), revision) + } +} diff --git a/selection.go b/selection.go index f8eb9d8..f0066ac 100644 --- a/selection.go +++ b/selection.go @@ -6,8 +6,6 @@ import ( "maps" "path/filepath" "slices" - - "golang.org/x/xerrors" ) type packageKey struct { @@ -15,14 +13,9 @@ type packageKey struct { Name string } -type testDecl struct { - FilePath string - Range lineRange -} - type packageInventory struct { Key packageKey - Tests map[string][]testDecl + Tests map[string]struct{} } func (inventory packageInventory) allTests() []string { @@ -45,7 +38,7 @@ type packageSelection struct { func selectChange(ctx context.Context, cfg config, git gitRunner, cache *inventoryCache, selections map[packageKey]*packageSelection, change testFileChange) error { hunks, err := listDiffHunks(ctx, cfg, git, change) if err != nil { - return xerrors.Errorf("list diff hunks for %s: %w", change.displayPath(), err) + return fmt.Errorf("list diff hunks for %s: %w", change.displayPath(), err) } if len(hunks) == 0 { return nil @@ -60,7 +53,7 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento return err } if change.NewPath != "" && isRunnableTestFilePath(change.NewPath) && !newExists { - return xerrors.Errorf("head revision %s is missing %s", cfg.HeadSHA, change.NewPath) + return fmt.Errorf("head revision %s is missing %s", cfg.HeadSHA, change.NewPath) } var oldKey packageKey @@ -68,7 +61,7 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento if oldExists { oldKey, err = packageKeyForData(change.OldPath, oldData) if err != nil { - return xerrors.Errorf("resolve old package for %s: %w", change.displayPath(), err) + return fmt.Errorf("resolve old package for %s: %w", change.displayPath(), err) } } var newKey packageKey @@ -76,14 +69,14 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento if newExists { newKey, err = packageKeyForData(change.NewPath, newData) if err != nil { - return xerrors.Errorf("resolve new package for %s: %w", change.displayPath(), err) + return fmt.Errorf("resolve new package for %s: %w", change.displayPath(), err) } } if newKeyOK { inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, newKey) if err != nil { - return xerrors.Errorf("load package inventory for %s: %w", newKey.String(), err) + return fmt.Errorf("load package inventory for %s: %w", newKey.String(), err) } selectionOldData := oldData selectionHunks := hunks @@ -91,7 +84,7 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento selectionOldData = nil selectionHunks = newSideOnlyHunks(hunks) } - selection := selectTestsForSnapshots(change, selectionOldData, newData, inventory, selectionHunks) + selection := selectTestsFromHunks(change, selectionOldData, newData, inventory, selectionHunks) if err := mergeSelection(ctx, cache, cfg.HeadSHA, selections, selection); err != nil { return err } @@ -100,10 +93,10 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento if oldKeyOK && (!newKeyOK || oldKey != newKey) { inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, oldKey) if err != nil { - return xerrors.Errorf("load package inventory for %s: %w", oldKey.String(), err) + return fmt.Errorf("load package inventory for %s: %w", oldKey.String(), err) } sourceChange := testFileChange{Kind: changeDeleted, OldPath: change.OldPath} - selection := selectSourceRemovalForSnapshots(sourceChange, oldData, inventory, hunks) + selection := selectSourceRemoval(sourceChange, oldData, inventory, hunks) if err := mergeSelection(ctx, cache, cfg.HeadSHA, selections, selection); err != nil { return err } @@ -115,11 +108,7 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento func packageKeyForData(filePath string, data []byte) (packageKey, error) { snapshot, err := parseFileSnapshot(data) if err != nil { - packageName, ok := fallbackPackageName(data) - if !ok { - return packageKey{}, xerrors.Errorf("parse package clause: %w", err) - } - return packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: packageName}, nil + return packageKey{}, fmt.Errorf("parse package clause: %w", err) } return packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: snapshot.packageName}, nil } @@ -137,7 +126,7 @@ func mergeSelection(ctx context.Context, cache *inventoryCache, revision string, expanded, err := cache.directoryWideSelections(ctx, revision, selection.Key.Dir, selection.Files) if err != nil { - return xerrors.Errorf("load directory-wide inventory for %s: %w", packagePattern(selection.Key.Dir), err) + return fmt.Errorf("load directory-wide inventory for %s: %w", packagePattern(selection.Key.Dir), err) } for _, expandedSelection := range expanded { mergePackageSelection(selections, expandedSelection) @@ -160,7 +149,7 @@ func mergePackageSelection(selections map[packageKey]*packageSelection, selectio maps.Copy(merged.Tests, selection.Tests) } -func selectTestsForSnapshots(change testFileChange, oldData, newData []byte, newInventory packageInventory, hunks []diffHunk) *packageSelection { +func selectTestsFromHunks(change testFileChange, oldData, newData []byte, newInventory packageInventory, hunks []diffHunk) *packageSelection { newSnapshot, err := parseFileSnapshot(newData) if err != nil { return allPackageTestsSelection(newInventory, change.displayPath()) @@ -221,7 +210,7 @@ func selectTestsForSnapshots(change testFileChange, oldData, newData []byte, new } } -func selectSourceRemovalForSnapshots(change testFileChange, oldData []byte, inventory packageInventory, hunks []diffHunk) *packageSelection { +func selectSourceRemoval(change testFileChange, oldData []byte, inventory packageInventory, hunks []diffHunk) *packageSelection { oldSnapshot, err := parseFileSnapshot(oldData) if err != nil { if needsOldSnapshot(hunks) { diff --git a/selection_test.go b/selection_test.go index 91eb829..7f1c1de 100644 --- a/selection_test.go +++ b/selection_test.go @@ -686,27 +686,14 @@ func TestBeta(t *testing.T) { t.Log("beta") } `), - inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestGamma(t *testing.T) { - t.Log("gamma") -} -`, - }), + inventory: packageInventory{ + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{ + "TestAlpha": {}, + "TestBeta": {}, + "TestGamma": {}, + }, + }, hunks: []diffHunk{{ Old: singleLineRange(t, `package sample @@ -911,7 +898,7 @@ func TestAlpha(t *T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - selection := selectTestsForSnapshots(change, tt.oldData, tt.newData, tt.inventory, tt.hunks) + selection := selectTestsFromHunks(change, tt.oldData, tt.newData, tt.inventory, tt.hunks) if tt.wantNoSelection { require.Nil(t, selection) return @@ -966,7 +953,7 @@ func TestBeta(t *testing.T) { } `, }) - selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + selection := selectTestsFromHunks(change, oldData, newData, inventory, []diffHunk{{ Old: singleLineRange(t, string(oldData), `t.Log("before method")`), New: singleLineRange(t, string(newData), `t.Log("changed method")`), }}) @@ -1009,7 +996,7 @@ func TestBeta(t *testing.T) { inventory := mustPackageInventory(t, map[string]string{ "pkg/changed_test.go": string(newData), }) - selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + selection := selectTestsFromHunks(change, oldData, newData, inventory, []diffHunk{{ Old: emptyRangeAt(7), New: rangeSpan( singleLineRange(t, string(newData), tt.needle), @@ -1057,7 +1044,7 @@ func TestBeta(t *testing.T) { } `, }) - selection := selectTestsForSnapshots(change, oldData, newData, inventory, []diffHunk{{ + selection := selectTestsFromHunks(change, oldData, newData, inventory, []diffHunk{{ Old: emptyRangeAt(3), New: singleLineRange(t, string(newData), `_ "example.com/sideeffect"`), }}) diff --git a/snapshot.go b/snapshot.go index 9b68621..fa9379f 100644 --- a/snapshot.go +++ b/snapshot.go @@ -7,19 +7,12 @@ import ( "go/parser" "go/printer" "go/token" - "maps" - "regexp" "slices" "strings" "unicode" "unicode/utf8" ) -var ( - packagePatternRE = regexp.MustCompile(`(?m)^package\s+([A-Za-z_][A-Za-z0-9_]*)\b`) - fallbackTestRE = regexp.MustCompile(`(?m)^func\s+((?:Test|Fuzz)[A-Z_][A-Za-z0-9_]*|Example(?:[A-Z_][A-Za-z0-9_]*)?)\s*\(`) -) - type fileSnapshot struct { packageName string tests map[string]lineRange @@ -208,10 +201,13 @@ func isTopLevelExampleFunc(fn *ast.FuncDecl) bool { } func hasRunnableName(name *ast.Ident, prefix string, allowBare bool) bool { - if name == nil || !strings.HasPrefix(name.Name, prefix) { + if name == nil { + return false + } + rest, ok := strings.CutPrefix(name.Name, prefix) + if !ok { return false } - rest := strings.TrimPrefix(name.Name, prefix) if rest == "" { return allowBare } @@ -267,46 +263,6 @@ func pointerIdentName(expr ast.Expr) (string, bool) { return ident.Name, true } -func fallbackPackageName(data []byte) (string, bool) { - matches := packagePatternRE.FindSubmatch(data) - if len(matches) < 2 { - return "", false - } - return string(matches[1]), true -} - -func fallbackTestNames(data []byte) []string { - matches := fallbackTestRE.FindAllSubmatch(data, -1) - selected := map[string]struct{}{} - for _, match := range matches { - if len(match) < 2 { - continue - } - selected[string(match[1])] = struct{}{} - } - return slices.Sorted(maps.Keys(selected)) -} - -func parseOrFallbackSnapshot(data []byte) (fileSnapshot, error) { - snapshot, err := parseFileSnapshot(data) - if err == nil { - return snapshot, nil - } - packageName, ok := fallbackPackageName(data) - if !ok { - return fileSnapshot{}, err - } - fallback := fileSnapshot{ - packageName: packageName, - tests: map[string]lineRange{}, - sharedKeys: map[string]struct{}{}, - } - for _, testName := range fallbackTestNames(data) { - fallback.tests[testName] = lineRange{} - } - return fallback, nil -} - func (snapshot *fileSnapshot) addSharedDecl(decl sharedDecl) { snapshot.shared = append(snapshot.shared, decl) for _, key := range decl.Keys { diff --git a/snapshot_test.go b/snapshot_test.go index e51d858..c19ee04 100644 --- a/snapshot_test.go +++ b/snapshot_test.go @@ -26,21 +26,3 @@ func Examplefoo() {} require.NoError(t, err) require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) } - -func TestFallbackTestNamesRejectsLowercaseSuffixes(t *testing.T) { - t.Parallel() - - data := []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) {} -func Testify(t *testing.T) {} -func FuzzAlpha(f *testing.F) {} -func Fuzzbar(f *testing.F) {} -func Example() {} -func ExampleFoo() {} -func Examplefoo() {} -`) - require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, fallbackTestNames(data)) -} From f3da5e5da0d7225d7fa7c515cfc8286fdc56e7f7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 21 May 2026 02:52:49 +0000 Subject: [PATCH 08/14] refactor: simplify whichtests after round 2 review Apply deletion-first cleanups identified in the round 2 deep review: - gitexec: shrink gitResult to just Stdout; keep stderr/exit local to execGit - config: collapse defaultConfig through withDefaults via cmp.Or - publish: inline the trailing-newline append at its single call site - snapshot: stop caching testingDotImport on fileSnapshot; pass it directly - plan: drop unused executionAccumulator.Package, fold appendUniqueNote, rename unsafeRunRegexTestNames -> unsafeRunRegexTestCount - diff: inline oldRevisionPath/newRevisionPath into pathspecs; fold isRunnableGoTestPath into isRunnableTestFilePath - selection: parse each changed file once via parsedFileSnapshot; thread cache through selectChange instead of re-passing cfg/git; drop unreachable parse fallback branches; simplify allDirectoryTestsSelection --- config.go | 6 +- diff.go | 25 ++++---- diff_test.go | 2 +- gitexec.go | 21 +------ gitfake_test.go | 12 ++-- githubactions_test.go | 2 +- helpers_test.go | 16 +++++ plan.go | 27 +++----- publish.go | 9 +-- selection.go | 142 +++++++++++++++++------------------------- selection_test.go | 100 +++++++++-------------------- snapshot.go | 19 +++--- 12 files changed, 144 insertions(+), 237 deletions(-) diff --git a/config.go b/config.go index bebdcd9..db9984d 100644 --- a/config.go +++ b/config.go @@ -27,11 +27,7 @@ type config struct { } func defaultConfig() config { - return config{ - RepoRoot: defaultRepoRoot, - HeadSHA: defaultHeadSHA, - OutSummary: defaultOutSummary, - } + return config{}.withDefaults() } func (cfg config) withDefaults() config { diff --git a/diff.go b/diff.go index 849baa8..f9c939a 100644 --- a/diff.go +++ b/diff.go @@ -34,17 +34,15 @@ func (change testFileChange) displayPath() string { return cmp.Or(change.NewPath, change.OldPath) } -func (change testFileChange) oldRevisionPath() string { - return cmp.Or(change.OldPath, change.NewPath) -} - -func (change testFileChange) newRevisionPath() string { - return cmp.Or(change.NewPath, change.OldPath) -} - func (change testFileChange) pathspecs() []string { - oldPath := change.oldRevisionPath() - newPath := change.newRevisionPath() + oldPath := change.OldPath + if oldPath == "" { + oldPath = change.NewPath + } + newPath := change.NewPath + if newPath == "" { + newPath = change.OldPath + } if oldPath == "" { return []string{newPath} } @@ -172,10 +170,9 @@ func cleanGitPath(path string) string { } func isRunnableTestFilePath(path string) bool { - return strings.HasSuffix(path, "_test.go") && isRunnableGoTestPath(path) -} - -func isRunnableGoTestPath(path string) bool { + if !strings.HasSuffix(path, "_test.go") { + return false + } cleanPath := cleanGitPath(path) baseName := filepath.Base(cleanPath) if strings.HasPrefix(baseName, ".") || strings.HasPrefix(baseName, "_") { diff --git a/diff_test.go b/diff_test.go index 7fe01eb..91a4df5 100644 --- a/diff_test.go +++ b/diff_test.go @@ -60,7 +60,7 @@ func TestReadFileAtRevisionPropagatesExistenceCheckFailures(t *testing.T) { }, failures: map[string]gitResponse{ gitKey("ls-tree", "-z", "--name-only", "head", "--", "pkg/sample_test.go"): { - result: gitResult{Stderr: "fatal: ls-tree failed", ExitCode: 128}, + result: gitResult{}, err: errors.New("fatal: ls-tree failed"), }, }, diff --git a/gitexec.go b/gitexec.go index 1bfab76..84c8be2 100644 --- a/gitexec.go +++ b/gitexec.go @@ -3,7 +3,6 @@ package main import ( "bytes" "context" - "errors" "fmt" "os" "os/exec" @@ -11,9 +10,7 @@ import ( ) type gitResult struct { - Stdout string - Stderr string - ExitCode int + Stdout string } type gitRunner func(ctx context.Context, dir string, args ...string) (gitResult, error) @@ -38,16 +35,11 @@ func execGit(ctx context.Context, dir string, args ...string) (gitResult, error) cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() - result := gitResult{ - Stdout: stdout.String(), - Stderr: stderr.String(), - ExitCode: 0, - } + result := gitResult{Stdout: stdout.String()} if err == nil { return result, nil } - result.ExitCode = exitCode(err) - message := strings.TrimSpace(result.Stderr) + message := strings.TrimSpace(stderr.String()) if message == "" { message = strings.TrimSpace(result.Stdout) } @@ -59,10 +51,3 @@ func execGit(ctx context.Context, dir string, args ...string) (gitResult, error) } return result, fmt.Errorf("git %s: %s", strings.Join(args, " "), message) } - -func exitCode(err error) int { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { - return exitErr.ExitCode() - } - return -1 -} diff --git a/gitfake_test.go b/gitfake_test.go index 29a598a..e45f32d 100644 --- a/gitfake_test.go +++ b/gitfake_test.go @@ -93,13 +93,13 @@ func (repo fakeGitRepo) catFileResponse(t *testing.T, args []string) (gitResult, if _, ok := repo.revisions[revision]; ok { return gitResult{}, nil } - return gitFailure(128, fmt.Sprintf("fatal: bad revision %q", revision)) + return gitFailure(fmt.Sprintf("fatal: bad revision %q", revision)) } revision, path := splitRevisionPath(t, spec) if _, ok := repo.revisions[revision][path]; ok { return gitResult{}, nil } - return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) + return gitFailure(fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) } func (repo fakeGitRepo) showResponse(t *testing.T, args []string) (gitResult, error) { @@ -108,7 +108,7 @@ func (repo fakeGitRepo) showResponse(t *testing.T, args []string) (gitResult, er revision, path := splitRevisionPath(t, args[1]) content, ok := repo.revisions[revision][path] if !ok { - return gitFailure(128, fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) + return gitFailure(fmt.Sprintf("fatal: path %q does not exist in %q", path, revision)) } return gitResult{Stdout: content}, nil } @@ -145,7 +145,7 @@ func (repo fakeGitRepo) mergeBaseResponse(t *testing.T, args []string) (gitResul if _, ok := repo.revisions[left]; ok { return gitResult{Stdout: left + "\n"}, nil } - return gitFailure(1, fmt.Sprintf("fatal: no merge base for %s and %s", args[1], args[2])) + return gitFailure(fmt.Sprintf("fatal: no merge base for %s and %s", args[1], args[2])) } func (repo fakeGitRepo) revParseResponse(t *testing.T, args []string) (gitResult, error) { @@ -165,8 +165,8 @@ func splitRevisionPath(t *testing.T, spec string) (revision string, path string) return revision, cleanGitPath(path) } -func gitFailure(exitCode int, stderr string) (gitResult, error) { - return gitResult{Stderr: stderr, ExitCode: exitCode}, errors.New(stderr) +func gitFailure(stderr string) (gitResult, error) { + return gitResult{}, errors.New(stderr) } func gitKey(args ...string) string { diff --git a/githubactions_test.go b/githubactions_test.go index cd123cd..b58e3e8 100644 --- a/githubactions_test.go +++ b/githubactions_test.go @@ -248,7 +248,7 @@ func TestEnsureRangeAvailableFetchesWhenMergeBaseIsMissing(t *testing.T) { require.Equal(t, []string{"merge-base", "base123", "head123"}, args) mergeBaseCalls++ if mergeBaseCalls == 1 { - return gitFailure(1, "fatal: no merge base") + return gitFailure("fatal: no merge base") } return gitResult{Stdout: "base123\n"}, nil } diff --git a/helpers_test.go b/helpers_test.go index 574e907..ae801a3 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -39,6 +39,22 @@ func mustPackageInventory(t *testing.T, files map[string]string) packageInventor return inventory } +func mustFileSnapshot(t *testing.T, data []byte) fileSnapshot { + t.Helper() + snapshot, err := parseFileSnapshot(data) + require.NoError(t, err) + return snapshot +} + +func mustOptionalFileSnapshot(t *testing.T, data []byte) *fileSnapshot { + t.Helper() + if data == nil { + return nil + } + snapshot := mustFileSnapshot(t, data) + return &snapshot +} + func diffForChange(oldRange, newRange lineRange) string { return fmt.Sprintf("@@ -%s +%s @@\n", formatDiffRange(oldRange), formatDiffRange(newRange)) } diff --git a/plan.go b/plan.go index 717a0b5..e581d85 100644 --- a/plan.go +++ b/plan.go @@ -48,7 +48,6 @@ type buildResult struct { } type executionAccumulator struct { - Package string Files map[string]struct{} Tests map[string]struct{} Broadened bool @@ -70,7 +69,7 @@ func selectTestPlan(ctx context.Context, cfg config, git gitRunner) ([]string, b cache := newInventoryCache(cfg, git) selections := map[packageKey]*packageSelection{} for _, change := range changes { - if err = selectChange(ctx, cfg, git, cache, selections, change); err != nil { + if err = selectChange(ctx, cache, selections, change); err != nil { return nil, buildResult{}, err } } @@ -92,7 +91,6 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul entry := accumulators[packagePath] if entry == nil { entry = &executionAccumulator{ - Package: packagePath, Files: map[string]struct{}{}, Tests: map[string]struct{}{}, TestCount: defaultTestCount, @@ -113,12 +111,12 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul if entry.Broadened && len(tests) > maxBroadenedTests { entry.RunAll = true entry.TestCount = runOnceTestCount - entry.Notes = appendUniqueNote(entry.Notes, fmt.Sprintf("Package-wide broadening selected %d tests, above the %d-test cap, so this target will run all tests once.", len(tests), maxBroadenedTests)) + entry.Notes = append(entry.Notes, fmt.Sprintf("Package-wide broadening selected %d tests, above the %d-test cap, so this target will run all tests once.", len(tests), maxBroadenedTests)) } - if unsafeTestNames := unsafeRunRegexTestNames(tests); len(unsafeTestNames) > 0 { + if unsafeTestCount := unsafeRunRegexTestCount(tests); unsafeTestCount > 0 { entry.RunAll = true entry.TestCount = runOnceTestCount - entry.Notes = appendUniqueNote(entry.Notes, fmt.Sprintf("Selected %d test names that cannot be passed safely through RUN, so this target will run all tests once.", len(unsafeTestNames))) + entry.Notes = append(entry.Notes, fmt.Sprintf("Selected %d test names that cannot be passed safely through RUN, so this target will run all tests once.", unsafeTestCount)) } runRegex := "" if !entry.RunAll { @@ -168,7 +166,7 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul summarizePackages(overflowPackages), }, }) - result.Summary.Notes = appendUniqueNote(result.Summary.Notes, note) + result.Summary.Notes = append(result.Summary.Notes, note) } return result, nil @@ -190,13 +188,6 @@ func summarizePackages(packages []string) string { return note } -func appendUniqueNote(notes []string, note string) []string { - if note == "" || slices.Contains(notes, note) { - return notes - } - return append(notes, note) -} - func isSafePackagePattern(packagePath string) bool { if !safePackagePatternRE.MatchString(packagePath) { return false @@ -216,14 +207,14 @@ func isSafePackagePattern(packagePath string) bool { return true } -func unsafeRunRegexTestNames(tests []string) []string { - unsafeNames := make([]string, 0) +func unsafeRunRegexTestCount(tests []string) int { + count := 0 for _, testName := range tests { if !safeTestNameRE.MatchString(testName) { - unsafeNames = append(unsafeNames, testName) + count++ } } - return unsafeNames + return count } func buildRunRegex(tests []string) string { diff --git a/publish.go b/publish.go index db88ebd..735abad 100644 --- a/publish.go +++ b/publish.go @@ -17,7 +17,7 @@ func publishPlan(sinks outputSinks, matrix matrixOutput, summary string, stdout return err } if sinks.OutMatrix != "" { - if err := writeFile(sinks.OutMatrix, appendNewline(matrixData)); err != nil { + if err := writeFile(sinks.OutMatrix, append(matrixData, '\n')); err != nil { return err } } @@ -108,10 +108,3 @@ func appendFile(path string, data []byte) (err error) { } return nil } - -func appendNewline(data []byte) []byte { - withNewline := make([]byte, 0, len(data)+1) - withNewline = append(withNewline, data...) - withNewline = append(withNewline, '\n') - return withNewline -} diff --git a/selection.go b/selection.go index f0066ac..16adfe8 100644 --- a/selection.go +++ b/selection.go @@ -5,7 +5,6 @@ import ( "fmt" "maps" "path/filepath" - "slices" ) type packageKey struct { @@ -18,15 +17,6 @@ type packageInventory struct { Tests map[string]struct{} } -func (inventory packageInventory) allTests() []string { - return slices.Sorted(maps.Keys(inventory.Tests)) -} - -func (inventory packageInventory) hasTest(name string) bool { - _, ok := inventory.Tests[name] - return ok -} - type packageSelection struct { Key packageKey Tests map[string]struct{} @@ -35,8 +25,14 @@ type packageSelection struct { DirectoryWide bool } -func selectChange(ctx context.Context, cfg config, git gitRunner, cache *inventoryCache, selections map[packageKey]*packageSelection, change testFileChange) error { - hunks, err := listDiffHunks(ctx, cfg, git, change) +type parsedFileSnapshot struct { + key packageKey + snapshot fileSnapshot +} + +func selectChange(ctx context.Context, cache *inventoryCache, selections map[packageKey]*packageSelection, change testFileChange) error { + cfg := cache.cfg + hunks, err := listDiffHunks(ctx, cfg, cache.git, change) if err != nil { return fmt.Errorf("list diff hunks for %s: %w", change.displayPath(), err) } @@ -44,60 +40,61 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento return nil } - oldData, oldExists, err := readChangeFile(ctx, cfg, git, cfg.BaseSHA, change.OldPath) + oldData, oldExists, err := readChangeFile(ctx, cfg, cache.git, cfg.BaseSHA, change.OldPath) if err != nil { return err } - newData, newExists, err := readChangeFile(ctx, cfg, git, cfg.HeadSHA, change.NewPath) + newData, newExists, err := readChangeFile(ctx, cfg, cache.git, cfg.HeadSHA, change.NewPath) if err != nil { return err } - if change.NewPath != "" && isRunnableTestFilePath(change.NewPath) && !newExists { + if isRunnableTestFilePath(change.NewPath) && !newExists { return fmt.Errorf("head revision %s is missing %s", cfg.HeadSHA, change.NewPath) } - var oldKey packageKey - oldKeyOK := oldExists + var oldFile *parsedFileSnapshot if oldExists { - oldKey, err = packageKeyForData(change.OldPath, oldData) + parsed, err := parseSnapshotForPath(change.OldPath, oldData) if err != nil { return fmt.Errorf("resolve old package for %s: %w", change.displayPath(), err) } + oldFile = &parsed } - var newKey packageKey - newKeyOK := newExists + var newFile *parsedFileSnapshot if newExists { - newKey, err = packageKeyForData(change.NewPath, newData) + parsed, err := parseSnapshotForPath(change.NewPath, newData) if err != nil { return fmt.Errorf("resolve new package for %s: %w", change.displayPath(), err) } + newFile = &parsed } - if newKeyOK { - inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, newKey) + if newFile != nil { + inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, newFile.key) if err != nil { - return fmt.Errorf("load package inventory for %s: %w", newKey.String(), err) + return fmt.Errorf("load package inventory for %s: %w", newFile.key.String(), err) } - selectionOldData := oldData + var oldSnapshot *fileSnapshot selectionHunks := hunks - if !oldKeyOK || oldKey != newKey { - selectionOldData = nil + if oldFile != nil && oldFile.key == newFile.key { + oldSnapshot = &oldFile.snapshot + } else { selectionHunks = newSideOnlyHunks(hunks) } - selection := selectTestsFromHunks(change, selectionOldData, newData, inventory, selectionHunks) - if err := mergeSelection(ctx, cache, cfg.HeadSHA, selections, selection); err != nil { + selection := selectTestsFromHunks(change, oldSnapshot, newFile.snapshot, inventory, selectionHunks) + if err := mergeSelection(ctx, cache, selections, selection); err != nil { return err } } - if oldKeyOK && (!newKeyOK || oldKey != newKey) { - inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, oldKey) + if oldFile != nil && (newFile == nil || oldFile.key != newFile.key) { + inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, oldFile.key) if err != nil { - return fmt.Errorf("load package inventory for %s: %w", oldKey.String(), err) + return fmt.Errorf("load package inventory for %s: %w", oldFile.key.String(), err) } sourceChange := testFileChange{Kind: changeDeleted, OldPath: change.OldPath} - selection := selectSourceRemoval(sourceChange, oldData, inventory, hunks) - if err := mergeSelection(ctx, cache, cfg.HeadSHA, selections, selection); err != nil { + selection := selectSourceRemoval(sourceChange, oldFile.snapshot, inventory, hunks) + if err := mergeSelection(ctx, cache, selections, selection); err != nil { return err } } @@ -105,15 +102,18 @@ func selectChange(ctx context.Context, cfg config, git gitRunner, cache *invento return nil } -func packageKeyForData(filePath string, data []byte) (packageKey, error) { +func parseSnapshotForPath(filePath string, data []byte) (parsedFileSnapshot, error) { snapshot, err := parseFileSnapshot(data) if err != nil { - return packageKey{}, fmt.Errorf("parse package clause: %w", err) + return parsedFileSnapshot{}, fmt.Errorf("parse package clause: %w", err) } - return packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: snapshot.packageName}, nil + return parsedFileSnapshot{ + key: packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: snapshot.packageName}, + snapshot: snapshot, + }, nil } -func mergeSelection(ctx context.Context, cache *inventoryCache, revision string, selections map[packageKey]*packageSelection, selection *packageSelection) error { +func mergeSelection(ctx context.Context, cache *inventoryCache, selections map[packageKey]*packageSelection, selection *packageSelection) error { if selection == nil { return nil } @@ -124,7 +124,7 @@ func mergeSelection(ctx context.Context, cache *inventoryCache, revision string, return nil } - expanded, err := cache.directoryWideSelections(ctx, revision, selection.Key.Dir, selection.Files) + expanded, err := cache.directoryWideSelections(ctx, cache.cfg.HeadSHA, selection.Key.Dir, selection.Files) if err != nil { return fmt.Errorf("load directory-wide inventory for %s: %w", packagePattern(selection.Key.Dir), err) } @@ -149,41 +149,24 @@ func mergePackageSelection(selections map[packageKey]*packageSelection, selectio maps.Copy(merged.Tests, selection.Tests) } -func selectTestsFromHunks(change testFileChange, oldData, newData []byte, newInventory packageInventory, hunks []diffHunk) *packageSelection { - newSnapshot, err := parseFileSnapshot(newData) - if err != nil { - return allPackageTestsSelection(newInventory, change.displayPath()) - } - - if oldData == nil && needsOldSnapshot(hunks) { +func selectTestsFromHunks(change testFileChange, oldSnapshot *fileSnapshot, newSnapshot fileSnapshot, newInventory packageInventory, hunks []diffHunk) *packageSelection { + if oldSnapshot == nil && needsOldSnapshot(hunks) { return allPackageTestsSelection(newInventory, change.displayPath()) } - var oldSnapshot *fileSnapshot - if len(oldData) > 0 { - snapshot, err := parseFileSnapshot(oldData) - if err != nil { - if needsOldSnapshot(hunks) { - return allPackageTestsSelection(newInventory, change.displayPath()) - } - } else { - oldSnapshot = &snapshot - } - } - selected := map[string]struct{}{} for _, hunk := range hunks { if oldSnapshot != nil { switch scope := broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old); scope { case broadeningDirectory: - return allDirectoryTestsSelection(newInventory, change.displayPath()) + return allDirectoryTestsSelection(newInventory.Key.Dir, change.displayPath()) case broadeningPackage: return allPackageTestsSelection(newInventory, change.displayPath()) } } switch scope := broadeningScopeForNewHunk(newSnapshot.shared, oldSnapshot, hunk.New); scope { case broadeningDirectory: - return allDirectoryTestsSelection(newInventory, change.displayPath()) + return allDirectoryTestsSelection(newInventory.Key.Dir, change.displayPath()) case broadeningPackage: return allPackageTestsSelection(newInventory, change.displayPath()) } @@ -195,7 +178,7 @@ func selectTestsFromHunks(change testFileChange, oldData, newData []byte, newInv if !declRange.overlaps(hunk.Old) { continue } - if newInventory.hasTest(name) { + if _, ok := newInventory.Tests[name]; ok { selected[name] = struct{}{} } } @@ -210,25 +193,20 @@ func selectTestsFromHunks(change testFileChange, oldData, newData []byte, newInv } } -func selectSourceRemoval(change testFileChange, oldData []byte, inventory packageInventory, hunks []diffHunk) *packageSelection { - oldSnapshot, err := parseFileSnapshot(oldData) - if err != nil { - if needsOldSnapshot(hunks) { - return allPackageTestsSelection(inventory, change.displayPath()) - } - return nil - } - +func selectSourceRemoval(change testFileChange, oldSnapshot fileSnapshot, inventory packageInventory, hunks []diffHunk) *packageSelection { selected := map[string]struct{}{} for _, hunk := range hunks { switch scope := broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old); scope { case broadeningDirectory: - return allDirectoryTestsSelection(inventory, change.displayPath()) + return allDirectoryTestsSelection(inventory.Key.Dir, change.displayPath()) case broadeningPackage: return allPackageTestsSelection(inventory, change.displayPath()) } for name, declRange := range oldSnapshot.tests { - if declRange.overlaps(hunk.Old) && inventory.hasTest(name) { + if !declRange.overlaps(hunk.Old) { + continue + } + if _, ok := inventory.Tests[name]; ok { selected[name] = struct{}{} } } @@ -254,27 +232,19 @@ func allPackageTestsSelectionForFiles(inventory packageInventory, files map[stri Files: files, Broadened: true, } - for _, testName := range inventory.allTests() { - selection.Tests[testName] = struct{}{} - } + maps.Copy(selection.Tests, inventory.Tests) if len(selection.Tests) == 0 { return nil } return selection } -func allDirectoryTestsSelection(inventory packageInventory, filePath string) *packageSelection { - selection := allPackageTestsSelection(inventory, filePath) - if selection == nil { - selection = &packageSelection{ - Key: inventory.Key, - Tests: map[string]struct{}{}, - Files: map[string]struct{}{filePath: {}}, - Broadened: true, - } +func allDirectoryTestsSelection(dir, filePath string) *packageSelection { + return &packageSelection{ + Key: packageKey{Dir: dir}, + Files: map[string]struct{}{filePath: {}}, + DirectoryWide: true, } - selection.DirectoryWide = true - return selection } func needsOldSnapshot(hunks []diffHunk) bool { diff --git a/selection_test.go b/selection_test.go index 7f1c1de..93b9cc8 100644 --- a/selection_test.go +++ b/selection_test.go @@ -13,14 +13,15 @@ func TestSelectTestsForSnapshots(t *testing.T) { change := testFileChange{Kind: changeModified, OldPath: changedPath, NewPath: changedPath} tests := []struct { - name string - oldData []byte - newData []byte - inventory packageInventory - hunks []diffHunk - wantTests []string - wantBroadened bool - wantNoSelection bool + name string + oldData []byte + newData []byte + inventory packageInventory + hunks []diffHunk + wantTests []string + wantBroadened bool + wantDirectoryWide bool + wantNoSelection bool }{ { name: "body change selects only changed test", @@ -605,8 +606,7 @@ func TestMain(m *testing.M) { } `, `fmt.Println("setup")`), }}, - wantTests: []string{"TestAlpha"}, - wantBroadened: true, + wantDirectoryWide: true, }, { name: "init broadens across sibling files in same package", @@ -662,61 +662,7 @@ func init() { } `, `register("after")`), }}, - wantTests: []string{"TestAlpha"}, - wantBroadened: true, - }, - { - name: "malformed changed file broadens package conservatively", - oldData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before alpha") -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`), - inventory: packageInventory{ - Key: packageKey{Dir: "pkg", Name: "sample"}, - Tests: map[string]struct{}{ - "TestAlpha": {}, - "TestBeta": {}, - "TestGamma": {}, - }, - }, - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before alpha") -} -`, `t.Log("before alpha")`), - New: singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, `t.Log("changed alpha")`), - }}, - wantTests: []string{"TestAlpha", "TestBeta", "TestGamma"}, - wantBroadened: true, + wantDirectoryWide: true, }, { name: "deleted helper uses old snapshot to broaden package", @@ -898,13 +844,21 @@ func TestAlpha(t *T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - selection := selectTestsFromHunks(change, tt.oldData, tt.newData, tt.inventory, tt.hunks) + oldSnapshot := mustOptionalFileSnapshot(t, tt.oldData) + newSnapshot := mustFileSnapshot(t, tt.newData) + selection := selectTestsFromHunks(change, oldSnapshot, newSnapshot, tt.inventory, tt.hunks) if tt.wantNoSelection { require.Nil(t, selection) return } require.NotNil(t, selection) - require.Equal(t, tt.wantTests, selectionNames(selection)) + require.Equal(t, tt.wantDirectoryWide, selection.DirectoryWide) + if tt.wantDirectoryWide { + require.Empty(t, selection.Tests) + require.Contains(t, selection.Files, changedPath) + } else { + require.Equal(t, tt.wantTests, selectionNames(selection)) + } require.Equal(t, tt.wantBroadened, selection.Broadened) }) } @@ -953,7 +907,9 @@ func TestBeta(t *testing.T) { } `, }) - selection := selectTestsFromHunks(change, oldData, newData, inventory, []diffHunk{{ + oldSnapshot := mustOptionalFileSnapshot(t, oldData) + newSnapshot := mustFileSnapshot(t, newData) + selection := selectTestsFromHunks(change, oldSnapshot, newSnapshot, inventory, []diffHunk{{ Old: singleLineRange(t, string(oldData), `t.Log("before method")`), New: singleLineRange(t, string(newData), `t.Log("changed method")`), }}) @@ -996,7 +952,9 @@ func TestBeta(t *testing.T) { inventory := mustPackageInventory(t, map[string]string{ "pkg/changed_test.go": string(newData), }) - selection := selectTestsFromHunks(change, oldData, newData, inventory, []diffHunk{{ + oldSnapshot := mustOptionalFileSnapshot(t, oldData) + newSnapshot := mustFileSnapshot(t, newData) + selection := selectTestsFromHunks(change, oldSnapshot, newSnapshot, inventory, []diffHunk{{ Old: emptyRangeAt(7), New: rangeSpan( singleLineRange(t, string(newData), tt.needle), @@ -1044,7 +1002,9 @@ func TestBeta(t *testing.T) { } `, }) - selection := selectTestsFromHunks(change, oldData, newData, inventory, []diffHunk{{ + oldSnapshot := mustOptionalFileSnapshot(t, oldData) + newSnapshot := mustFileSnapshot(t, newData) + selection := selectTestsFromHunks(change, oldSnapshot, newSnapshot, inventory, []diffHunk{{ Old: emptyRangeAt(3), New: singleLineRange(t, string(newData), `_ "example.com/sideeffect"`), }}) diff --git a/snapshot.go b/snapshot.go index fa9379f..a163c13 100644 --- a/snapshot.go +++ b/snapshot.go @@ -14,11 +14,10 @@ import ( ) type fileSnapshot struct { - packageName string - tests map[string]lineRange - shared []sharedDecl - sharedKeys map[string]struct{} - testingDotImport bool + packageName string + tests map[string]lineRange + shared []sharedDecl + sharedKeys map[string]struct{} } type sharedDeclKind uint8 @@ -46,11 +45,11 @@ func parseFileSnapshot(data []byte) (fileSnapshot, error) { return fileSnapshot{}, err } + testingDotImport := hasTestingDotImport(file) snapshot := fileSnapshot{ - packageName: file.Name.Name, - tests: map[string]lineRange{}, - sharedKeys: map[string]struct{}{}, - testingDotImport: hasTestingDotImport(file), + packageName: file.Name.Name, + tests: map[string]lineRange{}, + sharedKeys: map[string]struct{}{}, } for _, decl := range file.Decls { rangeForDecl := nodeRange(fset, decl) @@ -79,7 +78,7 @@ func parseFileSnapshot(data []byte) (fileSnapshot, error) { }) case name == "init": snapshot.addSharedDecl(sharedDecl{Range: rangeForDecl, Kind: sharedDeclInit}) - case isTopLevelTestFunc(funcDecl, snapshot.testingDotImport), isTopLevelFuzzFunc(funcDecl, snapshot.testingDotImport), isTopLevelExampleFunc(funcDecl): + case isTopLevelTestFunc(funcDecl, testingDotImport), isTopLevelFuzzFunc(funcDecl, testingDotImport), isTopLevelExampleFunc(funcDecl): snapshot.tests[name] = rangeForDecl default: snapshot.addSharedDecl(sharedDecl{ From ca7e92b1ab9ef99c0722f5fda99a691d82d8033c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 21 May 2026 04:53:52 +0000 Subject: [PATCH 09/14] fix: address remaining whichtests review threads --- broadening.go | 6 +-- diff.go | 21 ++------ diff_test.go | 25 +++++++-- gitexec.go | 2 +- gitexec_test.go | 36 +++++++++++++ githubactions.go | 9 ++-- githubactions_test.go | 59 ++++++++++++++++++++ integration_test.go | 2 + inventory.go | 122 ++++++++++++++++++++++++++++++++---------- inventory_test.go | 89 ++++++++++++++++++++++++++++++ plan.go | 9 ---- plan_test.go | 14 +++-- publish.go | 20 ++++--- publish_test.go | 4 +- request_test.go | 21 ++++++++ selection.go | 97 ++++++++++++++++++++------------- selection_test.go | 61 +++++++++++++++++++++ snapshot.go | 2 +- snapshot_test.go | 30 +++++++++++ 19 files changed, 512 insertions(+), 117 deletions(-) create mode 100644 gitexec_test.go diff --git a/broadening.go b/broadening.go index 2ff1be1..969bf7b 100644 --- a/broadening.go +++ b/broadening.go @@ -14,7 +14,7 @@ func broadeningScopeForOldHunk(decls []sharedDecl, candidate lineRange) broadeni if !decl.Range.overlaps(candidate) { continue } - scope = max(scope, decl.broadeningScope()) + scope = max(scope, decl.broadeningScopeOnOldSide()) } return scope } @@ -30,7 +30,7 @@ func broadeningScopeForNewHunk(decls []sharedDecl, oldSnapshot *fileSnapshot, ca return scope } -func (decl sharedDecl) broadeningScope() broadeningScope { +func (decl sharedDecl) broadeningScopeOnOldSide() broadeningScope { switch decl.Kind { case sharedDeclInit, sharedDeclTestMain: // Go builds package and package_test files into one test binary. @@ -52,7 +52,7 @@ func (decl sharedDecl) broadeningScopeOnNewSide(oldSnapshot *fileSnapshot) broad case sharedDeclInit, sharedDeclTestMain: return broadeningDirectory case sharedDeclVar, sharedDeclConst, sharedDeclType, sharedDeclHelper: - if oldSnapshot != nil && oldSnapshot.hasSharedKey(decl.Keys) { + if oldSnapshot != nil && oldSnapshot.hasAnySharedKey(decl.Keys) { return broadeningPackage } } diff --git a/diff.go b/diff.go index f9c939a..5e0ccc0 100644 --- a/diff.go +++ b/diff.go @@ -11,7 +11,7 @@ import ( "strings" ) -var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) +var hunkHeaderRE = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) type changeKind string @@ -35,14 +35,8 @@ func (change testFileChange) displayPath() string { } func (change testFileChange) pathspecs() []string { - oldPath := change.OldPath - if oldPath == "" { - oldPath = change.NewPath - } - newPath := change.NewPath - if newPath == "" { - newPath = change.OldPath - } + oldPath := cmp.Or(change.OldPath, change.NewPath) + newPath := cmp.Or(change.NewPath, change.OldPath) if oldPath == "" { return []string{newPath} } @@ -73,13 +67,6 @@ func newSideOnlyHunks(hunks []diffHunk) []diffHunk { return trimmed } -func readChangeFile(ctx context.Context, cfg config, git gitRunner, revision, filePath string) ([]byte, bool, error) { - if filePath == "" || !isRunnableTestFilePath(filePath) { - return nil, false, nil - } - return readFileAtRevision(ctx, cfg, git, revision, filePath) -} - func listChangedTestFiles(ctx context.Context, cfg config, git gitRunner) ([]testFileChange, error) { result, err := git( ctx, @@ -203,7 +190,7 @@ func parseDiffHunks(diff string) ([]diffHunk, error) { hunks := make([]diffHunk, 0) for line := range strings.Lines(diff) { line = strings.TrimSuffix(line, "\n") - matches := hunkHeaderPattern.FindStringSubmatch(line) + matches := hunkHeaderRE.FindStringSubmatch(line) if matches == nil { continue } diff --git a/diff_test.go b/diff_test.go index 91a4df5..69abfdf 100644 --- a/diff_test.go +++ b/diff_test.go @@ -8,12 +8,29 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseChangeKindAcceptsTypeChanges(t *testing.T) { +func TestParseChangeKind(t *testing.T) { t.Parallel() - kind, err := parseChangeKind("T") - require.NoError(t, err) - require.Equal(t, changeType, kind) + tests := []struct { + status string + want changeKind + }{ + {status: "A", want: changeAdded}, + {status: "D", want: changeDeleted}, + {status: "M", want: changeModified}, + {status: "R100", want: changeRenamed}, + {status: "T", want: changeType}, + } + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + t.Parallel() + kind, err := parseChangeKind(tt.status) + require.NoError(t, err) + require.Equal(t, tt.want, kind) + }) + } + _, err := parseChangeKind("X") + require.ErrorContains(t, err, "unsupported diff status") } func TestParseDiffHunks(t *testing.T) { diff --git a/gitexec.go b/gitexec.go index 84c8be2..2e49c74 100644 --- a/gitexec.go +++ b/gitexec.go @@ -44,7 +44,7 @@ func execGit(ctx context.Context, dir string, args ...string) (gitResult, error) message = strings.TrimSpace(result.Stdout) } if strings.Contains(message, "no merge base") { - return result, fmt.Errorf("git %s: %s. Ensure both revisions have full history before diffing %q", strings.Join(args, " "), message, args[len(args)-1]) + return result, fmt.Errorf("git %s: %s. Ensure both revisions have full history before diffing", strings.Join(args, " "), message) } if message == "" { message = err.Error() diff --git a/gitexec_test.go b/gitexec_test.go new file mode 100644 index 0000000..671b6fc --- /dev/null +++ b/gitexec_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExecGitNoMergeBaseDiagnosticIsGeneric(t *testing.T) { + t.Parallel() + + requireGit(t) + repoRoot := t.TempDir() + runGit(t, repoRoot, "init") + runGit(t, repoRoot, "config", "user.email", "test@example.com") + runGit(t, repoRoot, "config", "user.name", "Test User") + writeTestFile(t, repoRoot, "pkg/sample_test.go", `package sample +`) + runGit(t, repoRoot, "add", ".") + runGit(t, repoRoot, "commit", "-m", "base") + runGit(t, repoRoot, "branch", "left") + + runGit(t, repoRoot, "checkout", "--orphan", "right") + require.NoError(t, os.Remove(filepath.Join(repoRoot, "pkg", "sample_test.go"))) + writeTestFile(t, repoRoot, "pkg/sample_test.go", `package sample +`) + runGit(t, repoRoot, "add", ".") + runGit(t, repoRoot, "commit", "-m", "right") + + _, err := execGit(t.Context(), repoRoot, "diff", "left...right", "--", "pkg/sample_test.go") + require.Error(t, err) + require.ErrorContains(t, err, "Ensure both revisions have full history before diffing") + require.NotContains(t, err.Error(), `diffing "pkg/sample_test.go"`) +} diff --git a/githubactions.go b/githubactions.go index cc08481..fa6558d 100644 --- a/githubactions.go +++ b/githubactions.go @@ -211,20 +211,23 @@ func ensureConcreteRangeAvailable(ctx context.Context, req *runRequest, git gitR if fetch == nil { return errors.New("history fetch is required but no fetcher was configured") } + + attempts := []error{fmt.Errorf("initial merge-base: %w", mergeErr)} for _, spec := range req.Fetches { if err := validateFetchSpec(spec); err != nil { return err } if _, err := fetch(ctx, req.RepoRoot, spec); err != nil { - return fmt.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err) + attempts = append(attempts, fmt.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err)) + continue } _, err := gitMergeBase(ctx, req.RepoRoot, git, req.Range.BaseSHA, req.Range.HeadSHA) if err == nil { return nil } - mergeErr = err + attempts = append(attempts, fmt.Errorf("merge-base after fetching %s from %s: %w", spec.Ref, spec.Remote, err)) } - return fmt.Errorf("unable to resolve a merge base for %s...%s after fetching base history: %w", req.Range.BaseSHA, req.Range.HeadSHA, mergeErr) + return fmt.Errorf("unable to resolve a merge base for %s...%s after fetching base history: %w", req.Range.BaseSHA, req.Range.HeadSHA, errors.Join(attempts...)) } func runFetches(ctx context.Context, req *runRequest, fetch gitFetcher) error { diff --git a/githubactions_test.go b/githubactions_test.go index b58e3e8..bbb46d2 100644 --- a/githubactions_test.go +++ b/githubactions_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "errors" "os" "path/filepath" "testing" @@ -310,6 +311,64 @@ func TestGitHubActionsRunRequestValidatesInputsBeforeFetch(t *testing.T) { } } +func TestEnsureRangeAvailableFallsBackWhenFirstFetchFails(t *testing.T) { + t.Parallel() + + req := runRequest{ + RepoRoot: "/repo", + Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, + Fetches: []fetchSpec{ + {Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}, + {Remote: "https://github.com/coder/coder.git", Ref: "base123"}, + }, + } + mergeBaseCalls := 0 + git := func(_ context.Context, _ string, args ...string) (gitResult, error) { + require.Equal(t, []string{"merge-base", "base123", "head123"}, args) + mergeBaseCalls++ + if mergeBaseCalls == 1 { + return gitFailure("fatal: no merge base") + } + return gitResult{Stdout: "base123\n"}, nil + } + var fetches []fetchSpec + fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { + fetches = append(fetches, spec) + if len(fetches) == 1 { + return gitResult{}, errors.New("network refused") + } + return gitResult{}, nil + } + require.NoError(t, ensureRangeAvailable(t.Context(), &req, git, fetch)) + require.Equal(t, 2, mergeBaseCalls) + require.Equal(t, req.Fetches, fetches) +} + +func TestEnsureRangeAvailableReportsAllFetchFailures(t *testing.T) { + t.Parallel() + + req := runRequest{ + RepoRoot: "/repo", + Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, + Fetches: []fetchSpec{ + {Remote: "https://github.com/coder/coder.git", Ref: "refs/heads/main"}, + {Remote: "https://github.com/coder/coder.git", Ref: "base123"}, + }, + } + git := func(_ context.Context, _ string, args ...string) (gitResult, error) { + require.Equal(t, []string{"merge-base", "base123", "head123"}, args) + return gitFailure("fatal: no merge base") + } + fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { + return gitResult{}, errors.New("fetch failed for " + spec.Ref) + } + err := ensureRangeAvailable(t.Context(), &req, git, fetch) + require.Error(t, err) + require.ErrorContains(t, err, "initial merge-base") + require.ErrorContains(t, err, "fetch refs/heads/main from https://github.com/coder/coder.git") + require.ErrorContains(t, err, "fetch base123 from https://github.com/coder/coder.git") +} + func writeGitHubEvent(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "event.json") diff --git a/integration_test.go b/integration_test.go index d2199ce..eae49a8 100644 --- a/integration_test.go +++ b/integration_test.go @@ -15,6 +15,7 @@ import ( func TestRunWithRealGitHandlesAddedFileAtRevision(t *testing.T) { t.Parallel() + requireGit(t) repoRoot := t.TempDir() runGit(t, repoRoot, "init") runGit(t, repoRoot, "config", "user.email", "test@example.com") @@ -57,6 +58,7 @@ func TestAdded(t *testing.T) { func TestRunWithRealGitHandlesDeletedSetupFile(t *testing.T) { t.Parallel() + requireGit(t) repoRoot := t.TempDir() runGit(t, repoRoot, "init") runGit(t, repoRoot, "config", "user.email", "test@example.com") diff --git a/inventory.go b/inventory.go index 91e2b07..d79f365 100644 --- a/inventory.go +++ b/inventory.go @@ -11,19 +11,99 @@ import ( ) type inventoryCache struct { - cfg config - git gitRunner - fileLists map[string][]string - packages map[string]packageInventory + cfg config + git gitRunner + validRevisions map[string]struct{} + fileReads map[revisionFileKey]fileReadResult + fileSnapshots map[revisionFileKey]parsedFileSnapshot + fileLists map[string][]string + packages map[string]packageInventory +} + +type revisionFileKey struct { + Revision string + Path string +} + +type fileReadResult struct { + Data []byte + Exists bool } func newInventoryCache(cfg config, git gitRunner) *inventoryCache { return &inventoryCache{ - cfg: cfg, - git: git, - fileLists: map[string][]string{}, - packages: map[string]packageInventory{}, + cfg: cfg, + git: git, + validRevisions: map[string]struct{}{}, + fileReads: map[revisionFileKey]fileReadResult{}, + fileSnapshots: map[revisionFileKey]parsedFileSnapshot{}, + fileLists: map[string][]string{}, + packages: map[string]packageInventory{}, + } +} + +func (cache *inventoryCache) ensureRevisionExists(ctx context.Context, revision string) error { + if _, ok := cache.validRevisions[revision]; ok { + return nil + } + if err := ensureRevisionExists(ctx, cache.cfg, cache.git, revision); err != nil { + return err } + cache.validRevisions[revision] = struct{}{} + return nil +} + +func (cache *inventoryCache) readFileAtRevision(ctx context.Context, revision, filePath string) ([]byte, bool, error) { + key := revisionFileKey{Revision: revision, Path: cleanGitPath(filePath)} + if result, ok := cache.fileReads[key]; ok { + return result.Data, result.Exists, nil + } + if err := cache.ensureRevisionExists(ctx, revision); err != nil { + return nil, false, err + } + fileExists, err := fileExistsAtRevision(ctx, cache.cfg, cache.git, revision, key.Path) + if err != nil { + return nil, false, err + } + if !fileExists { + cache.fileReads[key] = fileReadResult{} + return nil, false, nil + } + + result, err := cache.git(ctx, cache.cfg.RepoRoot, "show", revision+":"+key.Path) + if err != nil { + return nil, false, fmt.Errorf("read %s at %s: %w", key.Path, revision, err) + } + read := fileReadResult{Data: []byte(result.Stdout), Exists: true} + cache.fileReads[key] = read + return read.Data, true, nil +} + +func (cache *inventoryCache) parseFileAtRevision(ctx context.Context, revision, filePath string) (parsedFileSnapshot, bool, error) { + key := revisionFileKey{Revision: revision, Path: cleanGitPath(filePath)} + if snapshot, ok := cache.fileSnapshots[key]; ok { + return snapshot, true, nil + } + data, exists, err := cache.readFileAtRevision(ctx, revision, key.Path) + if err != nil { + return parsedFileSnapshot{}, false, err + } + if !exists { + return parsedFileSnapshot{}, false, nil + } + parsed, err := parseSnapshotForPath(key.Path, data) + if err != nil { + return parsedFileSnapshot{}, true, fmt.Errorf("parse %s at %s: %w", key.Path, revision, err) + } + cache.fileSnapshots[key] = parsed + return parsed, true, nil +} + +func (cache *inventoryCache) parseChangeFileAtRevision(ctx context.Context, revision, filePath string) (parsedFileSnapshot, bool, error) { + if filePath == "" || !isRunnableTestFilePath(filePath) { + return parsedFileSnapshot{}, false, nil + } + return cache.parseFileAtRevision(ctx, revision, filePath) } func (cache *inventoryCache) loadPackageInventory(ctx context.Context, revision string, key packageKey) (packageInventory, error) { @@ -41,21 +121,14 @@ func (cache *inventoryCache) loadPackageInventory(ctx context.Context, revision Tests: map[string]struct{}{}, } for _, filePath := range files { - data, exists, err := readFileAtRevision(ctx, cache.cfg, cache.git, revision, filePath) + parsed, exists, err := cache.parseFileAtRevision(ctx, revision, filePath) if err != nil { return packageInventory{}, err } - if !exists { + if !exists || parsed.Snapshot.packageName != key.Name { continue } - snapshot, err := parseFileSnapshot(data) - if err != nil { - return packageInventory{}, fmt.Errorf("parse %s at %s: %w", filePath, revision, err) - } - if snapshot.packageName != key.Name { - continue - } - for testName := range snapshot.tests { + for testName := range parsed.Snapshot.tests { inventory.Tests[testName] = struct{}{} } } @@ -69,10 +142,7 @@ func (cache *inventoryCache) listTestFilesInDir(ctx context.Context, revision, d if files, ok := cache.fileLists[cacheKey]; ok { return files, nil } - pathspec := cleanDir - if pathspec == "" { - pathspec = "." - } + pathspec := cmp.Or(cleanDir, ".") result, err := cache.git(ctx, cache.cfg.RepoRoot, "ls-tree", "-r", "-z", "--name-only", revision, "--", pathspec) if err != nil { return nil, err @@ -119,18 +189,14 @@ func (cache *inventoryCache) loadDirectoryInventories(ctx context.Context, revis } packageNames := map[string]struct{}{} for _, filePath := range files { - data, exists, err := readFileAtRevision(ctx, cache.cfg, cache.git, revision, filePath) + parsed, exists, err := cache.parseFileAtRevision(ctx, revision, filePath) if err != nil { return nil, err } if !exists { continue } - snapshot, err := parseFileSnapshot(data) - if err != nil { - return nil, fmt.Errorf("parse %s at %s: %w", filePath, revision, err) - } - packageNames[snapshot.packageName] = struct{}{} + packageNames[parsed.Snapshot.packageName] = struct{}{} } keys := make([]packageKey, 0, len(packageNames)) for packageName := range packageNames { diff --git a/inventory_test.go b/inventory_test.go index a66336a..184bacd 100644 --- a/inventory_test.go +++ b/inventory_test.go @@ -1,6 +1,9 @@ package main import ( + "context" + "maps" + "slices" "testing" "github.com/stretchr/testify/require" @@ -31,3 +34,89 @@ func TestBroken(t *testing.T) { _, err := cache.loadPackageInventory(t.Context(), "head", packageKey{Dir: "pkg", Name: "sample"}) require.ErrorContains(t, err, "parse pkg/broken_test.go at head") } + +func TestLoadPackageInventoryCachesResults(t *testing.T) { + t.Parallel() + + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "head": { + "pkg/alpha_test.go": `package sample + +import "testing" + +func TestAlpha(t *testing.T) {} +`, + }, + }, + } + counter := newCountingGitRunner(repo.runner(t)) + cache := newInventoryCache(config{RepoRoot: "/repo"}, counter.run) + key := packageKey{Dir: "pkg", Name: "sample"} + inventory, err := cache.loadPackageInventory(t.Context(), "head", key) + require.NoError(t, err) + require.Equal(t, []string{"TestAlpha"}, slices.Sorted(maps.Keys(inventory.Tests))) + firstCommandCount := counter.total + + inventory, err = cache.loadPackageInventory(t.Context(), "head", key) + require.NoError(t, err) + require.Equal(t, []string{"TestAlpha"}, slices.Sorted(maps.Keys(inventory.Tests))) + require.Equal(t, firstCommandCount, counter.total) +} + +func TestLoadDirectoryInventoriesSharesSnapshotsAcrossPackages(t *testing.T) { + t.Parallel() + + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "head": { + "pkg/internal_test.go": `package foo + +import "testing" + +func TestInternal(t *testing.T) {} +`, + "pkg/external_test.go": `package foo_test + +import "testing" + +func TestExternal(t *testing.T) {} +`, + }, + }, + } + counter := newCountingGitRunner(repo.runner(t)) + cache := newInventoryCache(config{RepoRoot: "/repo"}, counter.run) + inventories, err := cache.loadDirectoryInventories(t.Context(), "head", "pkg") + require.NoError(t, err) + require.Len(t, inventories, 2) + require.Equal(t, []packageKey{ + {Dir: "pkg", Name: "foo"}, + {Dir: "pkg", Name: "foo_test"}, + }, []packageKey{inventories[0].Key, inventories[1].Key}) + require.Equal(t, 1, counter.counts["cat-file"]) + require.Equal(t, 3, counter.counts["ls-tree"]) + require.Equal(t, 2, counter.counts["show"]) + firstCommandCount := counter.total + + inventories, err = cache.loadDirectoryInventories(t.Context(), "head", "pkg") + require.NoError(t, err) + require.Len(t, inventories, 2) + require.Equal(t, firstCommandCount, counter.total) +} + +type countingGitRunner struct { + runner gitRunner + counts map[string]int + total int +} + +func newCountingGitRunner(runner gitRunner) *countingGitRunner { + return &countingGitRunner{runner: runner, counts: map[string]int{}} +} + +func (counter *countingGitRunner) run(ctx context.Context, dir string, args ...string) (gitResult, error) { + counter.counts[args[0]]++ + counter.total++ + return counter.runner(ctx, dir, args...) +} diff --git a/plan.go b/plan.go index e581d85..8ea8c06 100644 --- a/plan.go +++ b/plan.go @@ -30,7 +30,6 @@ type matrixEntry struct { type summaryReport struct { Entries []summaryEntry - Notes []string } type summaryEntry struct { @@ -166,7 +165,6 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul summarizePackages(overflowPackages), }, }) - result.Summary.Notes = append(result.Summary.Notes, note) } return result, nil @@ -246,13 +244,6 @@ func renderSummary(changedFiles []string, summary summaryReport) string { totalTests += len(entry.Tests) } _, _ = fmt.Fprintf(&builder, "Selected %d tests across %d package targets.\n\n", totalTests, len(summary.Entries)) - if len(summary.Notes) > 0 { - _, _ = builder.WriteString("Notes:\n") - for _, note := range summary.Notes { - _, _ = builder.WriteString("- " + note + "\n") - } - _, _ = builder.WriteString("\n") - } for _, entry := range summary.Entries { _, _ = builder.WriteString("### `" + entry.Label + "`\n\n") _, _ = builder.WriteString("Files:\n") diff --git a/plan_test.go b/plan_test.go index 1c24017..4fa8e12 100644 --- a/plan_test.go +++ b/plan_test.go @@ -63,10 +63,13 @@ func TestBuildExecutionPlanRejectsUnsafePackagePaths(t *testing.T) { require.ErrorContains(t, err, "unsafe package path") } -func TestBuildExecutionPlanRejectsPackageTraversalSegments(t *testing.T) { +func TestIsSafePackagePatternAllowsSafeNamesAndRejectsTraversal(t *testing.T) { t.Parallel() - for _, packagePath := range []string{"./foo/../bar", "./..", "./foo/.."} { + for _, packagePath := range []string{".", "./foo_bar", "./foo-bar", "./foo.bar", "./foo/bar_baz"} { + require.True(t, isSafePackagePattern(packagePath), packagePath) + } + for _, packagePath := range []string{"./foo/../bar", "./..", "./foo/..", "../foo", "./foo bar"} { require.False(t, isSafePackagePattern(packagePath), packagePath) } } @@ -117,8 +120,11 @@ func TestBuildExecutionPlanCapsMatrixTargets(t *testing.T) { for _, packagePath := range strings.Fields(overflow.Package) { require.True(t, isSafePackagePattern(packagePath), packagePath) } - require.Contains(t, result.Summary.Notes[0], "Matrix target cap") - require.Contains(t, result.Summary.Entries[len(result.Summary.Entries)-1].Notes[1], "and 3 more") + overflowSummary := result.Summary.Entries[len(result.Summary.Entries)-1] + require.Contains(t, overflowSummary.Notes[0], "Matrix target cap") + require.Contains(t, overflowSummary.Notes[1], "and 3 more") + summary := renderSummary([]string{"pkg00/file_test.go"}, result.Summary) + require.Equal(t, 1, strings.Count(summary, "Matrix target cap")) } func TestBuildExecutionPlanKeepsSameNamePackageAndExternalTestsPrecise(t *testing.T) { diff --git a/publish.go b/publish.go index 735abad..30d4502 100644 --- a/publish.go +++ b/publish.go @@ -27,7 +27,7 @@ func publishPlan(sinks outputSinks, matrix matrixOutput, summary string, stdout } } if sinks.GitHubOutput != "" { - if err := appendGitHubOutput(sinks.GitHubOutput, "matrix", string(matrixData), 0); err != nil { + if err := appendGitHubOutput(sinks.GitHubOutput, "matrix", string(matrixData)); err != nil { return err } } @@ -50,17 +50,21 @@ func marshalMatrix(matrix matrixOutput) ([]byte, error) { return data, nil } -func appendGitHubOutput(path, name, value string, outputSizeLimit int) error { +func appendGitHubOutput(path, name, value string) error { + if err := ensureGitHubOutputFits(name, value, defaultGitHubOutputValueLimit); err != nil { + return err + } + return appendFile(path, []byte(name+"="+value+"\n")) +} + +func ensureGitHubOutputFits(name, value string, limit int) error { if strings.ContainsAny(value, "\r\n") { return fmt.Errorf("GitHub output %s must be a single line", name) } - if outputSizeLimit == 0 { - outputSizeLimit = defaultGitHubOutputValueLimit - } - if len(value) > outputSizeLimit { - return fmt.Errorf("GitHub output %s is %d bytes, above the %d byte limit", name, len(value), outputSizeLimit) + if len(value) > limit { + return fmt.Errorf("GitHub output %s is %d bytes, above the %d byte limit", name, len(value), limit) } - return appendFile(path, []byte(name+"="+value+"\n")) + return nil } func writeSummary(path, summary string, stdout io.Writer) error { diff --git a/publish_test.go b/publish_test.go index ce83f93..26eb816 100644 --- a/publish_test.go +++ b/publish_test.go @@ -52,9 +52,9 @@ func TestPublishPlanWritesEmptyMatrixAndRejectsUnsafeOutput(t *testing.T) { require.NoError(t, err) require.Equal(t, `{"include":[]}`, string(matrixData)) - err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "first\nsecond", 0) + err = ensureGitHubOutputFits("matrix", "first\nsecond", defaultGitHubOutputValueLimit) require.ErrorContains(t, err, "single line") - err = appendGitHubOutput(filepath.Join(t.TempDir(), "output.txt"), "matrix", "too-long", 3) + err = ensureGitHubOutputFits("matrix", "too-long", 3) require.ErrorContains(t, err, "above the 3 byte limit") } diff --git a/request_test.go b/request_test.go index e63616f..53cf13a 100644 --- a/request_test.go +++ b/request_test.go @@ -21,3 +21,24 @@ func TestValidateRevisionArgAllowsCommonGitRevisions(t *testing.T) { require.NoError(t, validateRevisionArg("revision", revision), revision) } } + +func TestValidateRevisionArgRejectsUnsafeValues(t *testing.T) { + t.Parallel() + + tests := []struct { + revision string + want string + }{ + {revision: "", want: "is required"}, + {revision: "-bad", want: "must not start with '-'"}, + {revision: "head:bad", want: "must not contain ':'"}, + {revision: "head\x00bad", want: "must not contain NUL bytes"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + err := validateRevisionArg("--head-sha", tt.revision) + require.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/selection.go b/selection.go index 16adfe8..db7398b 100644 --- a/selection.go +++ b/selection.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "path/filepath" + "slices" ) type packageKey struct { @@ -26,10 +27,14 @@ type packageSelection struct { } type parsedFileSnapshot struct { - key packageKey - snapshot fileSnapshot + Key packageKey + Snapshot fileSnapshot } +// selectChange handles the four diff states for a runnable test file: add +// (old absent, new present), delete (old present, new absent), in-place modify +// (both sides present with the same package key), and cross-package move or +// package rename (both sides present with different package keys). func selectChange(ctx context.Context, cache *inventoryCache, selections map[packageKey]*packageSelection, change testFileChange) error { cfg := cache.cfg hunks, err := listDiffHunks(ctx, cfg, cache.git, change) @@ -40,60 +45,55 @@ func selectChange(ctx context.Context, cache *inventoryCache, selections map[pac return nil } - oldData, oldExists, err := readChangeFile(ctx, cfg, cache.git, cfg.BaseSHA, change.OldPath) + oldParsed, oldExists, err := cache.parseChangeFileAtRevision(ctx, cfg.BaseSHA, change.OldPath) if err != nil { - return err + return fmt.Errorf("resolve old package for %s: %w", change.displayPath(), err) } - newData, newExists, err := readChangeFile(ctx, cfg, cache.git, cfg.HeadSHA, change.NewPath) + newParsed, newExists, err := cache.parseChangeFileAtRevision(ctx, cfg.HeadSHA, change.NewPath) if err != nil { - return err + return fmt.Errorf("resolve new package for %s: %w", change.displayPath(), err) } - if isRunnableTestFilePath(change.NewPath) && !newExists { + if change.expectsOldFile() && !oldExists { + return fmt.Errorf("base revision %s is missing %s", cfg.BaseSHA, change.OldPath) + } + if change.expectsNewFile() && !newExists { return fmt.Errorf("head revision %s is missing %s", cfg.HeadSHA, change.NewPath) } var oldFile *parsedFileSnapshot if oldExists { - parsed, err := parseSnapshotForPath(change.OldPath, oldData) - if err != nil { - return fmt.Errorf("resolve old package for %s: %w", change.displayPath(), err) - } - oldFile = &parsed + oldFile = &oldParsed } var newFile *parsedFileSnapshot if newExists { - parsed, err := parseSnapshotForPath(change.NewPath, newData) - if err != nil { - return fmt.Errorf("resolve new package for %s: %w", change.displayPath(), err) - } - newFile = &parsed + newFile = &newParsed } if newFile != nil { - inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, newFile.key) + inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, newFile.Key) if err != nil { - return fmt.Errorf("load package inventory for %s: %w", newFile.key.String(), err) + return fmt.Errorf("load package inventory for %s: %w", newFile.Key.String(), err) } var oldSnapshot *fileSnapshot selectionHunks := hunks - if oldFile != nil && oldFile.key == newFile.key { - oldSnapshot = &oldFile.snapshot + if oldFile != nil && oldFile.Key == newFile.Key { + oldSnapshot = &oldFile.Snapshot } else { selectionHunks = newSideOnlyHunks(hunks) } - selection := selectTestsFromHunks(change, oldSnapshot, newFile.snapshot, inventory, selectionHunks) + selection := selectTestsFromHunks(change, oldSnapshot, newFile.Snapshot, inventory, selectionHunks) if err := mergeSelection(ctx, cache, selections, selection); err != nil { return err } } - if oldFile != nil && (newFile == nil || oldFile.key != newFile.key) { - inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, oldFile.key) + if oldFile != nil && (newFile == nil || oldFile.Key != newFile.Key) { + inventory, err := cache.loadPackageInventory(ctx, cfg.HeadSHA, oldFile.Key) if err != nil { - return fmt.Errorf("load package inventory for %s: %w", oldFile.key.String(), err) + return fmt.Errorf("load package inventory for %s: %w", oldFile.Key.String(), err) } sourceChange := testFileChange{Kind: changeDeleted, OldPath: change.OldPath} - selection := selectSourceRemoval(sourceChange, oldFile.snapshot, inventory, hunks) + selection := selectSourceRemoval(sourceChange, oldFile.Snapshot, inventory, hunks) if err := mergeSelection(ctx, cache, selections, selection); err != nil { return err } @@ -102,14 +102,40 @@ func selectChange(ctx context.Context, cache *inventoryCache, selections map[pac return nil } +func (change testFileChange) expectsOldFile() bool { + if !isRunnableTestFilePath(change.OldPath) { + return false + } + switch change.Kind { + case changeAdded: + return false + case changeDeleted, changeModified, changeRenamed, changeType: + return true + } + return true +} + +func (change testFileChange) expectsNewFile() bool { + if !isRunnableTestFilePath(change.NewPath) { + return false + } + switch change.Kind { + case changeDeleted: + return false + case changeAdded, changeModified, changeRenamed, changeType: + return true + } + return true +} + func parseSnapshotForPath(filePath string, data []byte) (parsedFileSnapshot, error) { snapshot, err := parseFileSnapshot(data) if err != nil { return parsedFileSnapshot{}, fmt.Errorf("parse package clause: %w", err) } return parsedFileSnapshot{ - key: packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: snapshot.packageName}, - snapshot: snapshot, + Key: packageKey{Dir: filepath.ToSlash(filepath.Dir(filePath)), Name: snapshot.packageName}, + Snapshot: snapshot, }, nil } @@ -157,14 +183,14 @@ func selectTestsFromHunks(change testFileChange, oldSnapshot *fileSnapshot, newS selected := map[string]struct{}{} for _, hunk := range hunks { if oldSnapshot != nil { - switch scope := broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old); scope { + switch broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old) { case broadeningDirectory: return allDirectoryTestsSelection(newInventory.Key.Dir, change.displayPath()) case broadeningPackage: return allPackageTestsSelection(newInventory, change.displayPath()) } } - switch scope := broadeningScopeForNewHunk(newSnapshot.shared, oldSnapshot, hunk.New); scope { + switch broadeningScopeForNewHunk(newSnapshot.shared, oldSnapshot, hunk.New) { case broadeningDirectory: return allDirectoryTestsSelection(newInventory.Key.Dir, change.displayPath()) case broadeningPackage: @@ -196,7 +222,7 @@ func selectTestsFromHunks(change testFileChange, oldSnapshot *fileSnapshot, newS func selectSourceRemoval(change testFileChange, oldSnapshot fileSnapshot, inventory packageInventory, hunks []diffHunk) *packageSelection { selected := map[string]struct{}{} for _, hunk := range hunks { - switch scope := broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old); scope { + switch broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old) { case broadeningDirectory: return allDirectoryTestsSelection(inventory.Key.Dir, change.displayPath()) case broadeningPackage: @@ -248,12 +274,9 @@ func allDirectoryTestsSelection(dir, filePath string) *packageSelection { } func needsOldSnapshot(hunks []diffHunk) bool { - for _, hunk := range hunks { - if hunk.Old.hasLines() { - return true - } - } - return false + return slices.ContainsFunc(hunks, func(hunk diffHunk) bool { + return hunk.Old.hasLines() + }) } func addMatchingTests(selected map[string]struct{}, tests map[string]lineRange, candidate lineRange) { diff --git a/selection_test.go b/selection_test.go index 93b9cc8..3605159 100644 --- a/selection_test.go +++ b/selection_test.go @@ -1035,3 +1035,64 @@ func TestMergePackageSelectionCombinesSamePackageFiles(t *testing.T) { require.Contains(t, selections[key].Files, "pkg/alpha_test.go") require.Contains(t, selections[key].Files, "pkg/beta_test.go") } + +func TestSelectChangeRequiresOldFileWhenKindExpectsIt(t *testing.T) { + t.Parallel() + + oldPath := "pkg/old_test.go" + newPath := "pkg/new_test.go" + newContent := `package sample + +import "testing" + +func TestAlpha(t *testing.T) {} +` + tests := []struct { + name string + change testFileChange + key string + }{ + { + name: "modified", + change: testFileChange{Kind: changeModified, OldPath: oldPath, NewPath: oldPath}, + key: oldPath, + }, + { + name: "renamed", + change: testFileChange{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}, + key: oldPath + "\x00" + newPath, + }, + { + name: "deleted", + change: testFileChange{Kind: changeDeleted, OldPath: oldPath}, + key: oldPath, + }, + { + name: "type", + change: testFileChange{Kind: changeType, OldPath: oldPath, NewPath: oldPath}, + key: oldPath, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + headFiles := map[string]string{} + if tt.change.NewPath != "" { + headFiles[tt.change.NewPath] = newContent + } + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "base": {}, + "head": headFiles, + }, + diffOutputs: map[string]string{ + tt.key: diffForChange(lineRange{Start: 5, End: 5}, lineRange{Start: 5, End: 5}), + }, + } + cache := newInventoryCache(config{RepoRoot: "/repo", BaseSHA: "base", HeadSHA: "head"}, repo.runner(t)) + err := selectChange(t.Context(), cache, map[packageKey]*packageSelection{}, tt.change) + require.ErrorContains(t, err, "base revision base is missing "+oldPath) + }) + } +} diff --git a/snapshot.go b/snapshot.go index a163c13..19b6e48 100644 --- a/snapshot.go +++ b/snapshot.go @@ -272,7 +272,7 @@ func (snapshot *fileSnapshot) addSharedDecl(decl sharedDecl) { } } -func (snapshot *fileSnapshot) hasSharedKey(keys []string) bool { +func (snapshot *fileSnapshot) hasAnySharedKey(keys []string) bool { for _, key := range keys { if _, ok := snapshot.sharedKeys[key]; ok { return true diff --git a/snapshot_test.go b/snapshot_test.go index c19ee04..38db136 100644 --- a/snapshot_test.go +++ b/snapshot_test.go @@ -26,3 +26,33 @@ func Examplefoo() {} require.NoError(t, err) require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) } + +func TestParseFileSnapshotRecordsStructure(t *testing.T) { + t.Parallel() + + snapshot, err := parseFileSnapshot([]byte(`package sample + +import . "testing" + +const answer = 42 +var packageValue = answer +type fixture struct{} + +func helper() {} +func init() {} +func TestMain(m *M) {} +func TestAlpha(t *T) {} +func FuzzAlpha(f *F) {} +`)) + require.NoError(t, err) + require.Equal(t, "sample", snapshot.packageName) + require.Equal(t, []string{"FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) + require.Contains(t, snapshot.sharedKeys, "const:answer") + require.Contains(t, snapshot.sharedKeys, "var:packageValue") + require.Contains(t, snapshot.sharedKeys, "type:fixture") + require.Contains(t, snapshot.sharedKeys, "func:helper") + require.Contains(t, snapshot.sharedKeys, "func:TestMain") + require.True(t, slices.ContainsFunc(snapshot.shared, func(decl sharedDecl) bool { + return decl.Kind == sharedDeclInit + })) +} From 3051d91979450fed167716e295b8a0092545fce0 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 21 May 2026 04:55:27 +0000 Subject: [PATCH 10/14] test: hoist selection fixtures --- selection_test.go | 833 +++++++++++++--------------------------------- 1 file changed, 227 insertions(+), 606 deletions(-) diff --git a/selection_test.go b/selection_test.go index 3605159..94c105f 100644 --- a/selection_test.go +++ b/selection_test.go @@ -12,59 +12,9 @@ func TestSelectTestsForSnapshots(t *testing.T) { const changedPath = "pkg/changed_test.go" change := testFileChange{Kind: changeModified, OldPath: changedPath, NewPath: changedPath} - tests := []struct { - name string - oldData []byte - newData []byte - inventory packageInventory - hunks []diffHunk - wantTests []string - wantBroadened bool - wantDirectoryWide bool - wantNoSelection bool - }{ - { - name: "body change selects only changed test", - oldData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("before alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`), - inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("changed alpha") -} - -func TestBeta(t *testing.T) { - t.Log("stable beta") -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample + const ( + // These fixtures hoist repeated row sources. + selectionFixture01 = `package sample import "testing" @@ -75,8 +25,8 @@ func TestAlpha(t *testing.T) { func TestBeta(t *testing.T) { t.Log("stable beta") } -`, `t.Log("before alpha")`), - New: singleLineRange(t, `package sample +` + selectionFixture02 = `package sample import "testing" @@ -87,49 +37,16 @@ func TestAlpha(t *testing.T) { func TestBeta(t *testing.T) { t.Log("stable beta") } -`, `t.Log("changed alpha")`), - }}, - wantTests: []string{"TestAlpha"}, - }, - { - name: "new top-level test selects only new test", - oldData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`), - inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample +` + selectionFixture03 = `package sample import "testing" func TestAlpha(t *testing.T) { t.Log("alpha") } - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, - }), - hunks: []diffHunk{{ - Old: emptyRangeAt(7), - New: singleLineRange(t, `package sample +` + selectionFixture04 = `package sample import "testing" @@ -140,63 +57,8 @@ func TestAlpha(t *testing.T) { func TestBeta(t *testing.T) { t.Log("new beta") } -`, `t.Log("new beta")`), - }}, - wantTests: []string{"TestBeta"}, - }, - { - name: "existing helper change broadens across package", - oldData: []byte(`package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("before helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`), - newData: []byte(`package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("changed helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`), - inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("changed helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - setup(t) -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample +` + selectionFixture05 = `package sample import "testing" @@ -208,8 +70,8 @@ func setup(t *testing.T) { func TestAlpha(t *testing.T) { setup(t) } -`, `t.Log("before helper")`), - New: singleLineRange(t, `package sample +` + selectionFixture06 = `package sample import "testing" @@ -221,14 +83,8 @@ func setup(t *testing.T) { func TestAlpha(t *testing.T) { setup(t) } -`, `t.Log("changed helper")`), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "package variable change broadens across package", - oldData: []byte(`package sample +` + selectionFixture07 = `package sample import "testing" @@ -237,8 +93,8 @@ var packageValue = 1 func TestAlpha(t *testing.T) { t.Log(packageValue) } -`), - newData: []byte(`package sample +` + selectionFixture08 = `package sample import "testing" @@ -247,57 +103,25 @@ var packageValue = 2 func TestAlpha(t *testing.T) { t.Log(packageValue) } -`), - inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import "testing" +` + selectionFixture09 = `package sample -var packageValue = 2 +import ( + "testing" +) func TestAlpha(t *testing.T) { - t.Log(packageValue) + t.Log("alpha") } -`, - "pkg/sibling_test.go": `package sample - -import "testing" func TestBeta(t *testing.T) { - t.Log(packageValue) -} -`, - }), - hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -var packageValue = 1 - -func TestAlpha(t *testing.T) { - t.Log(packageValue) -} -`, "var packageValue = 1"), - New: singleLineRange(t, `package sample - -import "testing" - -var packageValue = 2 - -func TestAlpha(t *testing.T) { - t.Log(packageValue) + t.Log("beta") } -`, "var packageValue = 2"), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "additive import broadens package", - oldData: []byte(`package sample +` + selectionFixture10 = `package sample import ( + "fmt" "testing" ) @@ -308,24 +132,25 @@ func TestAlpha(t *testing.T) { func TestBeta(t *testing.T) { t.Log("beta") } -`), - newData: []byte(`package sample +` + selectionFixture11 = `package sample -import ( - "fmt" - "testing" -) +import "testing" func TestAlpha(t *testing.T) { t.Log("alpha") } +func setupCase(t *testing.T) { + t.Helper() + t.Log("beta helper") +} + func TestBeta(t *testing.T) { - t.Log("beta") + setupCase(t) } -`), - inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample +` + selectionFixture12 = `package sample import ( "fmt" @@ -335,14 +160,8 @@ import ( func TestAlpha(t *testing.T) { t.Log("alpha") } - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, - }), - hunks: []diffHunk{{ - Old: emptyRangeAt(singleLineRange(t, `package sample +` + selectionFixture13 = `package sample import ( "testing" @@ -351,397 +170,262 @@ import ( func TestAlpha(t *testing.T) { t.Log("alpha") } +` + selectionFixture14 = `package sample + +import "testing" func TestBeta(t *testing.T) { t.Log("beta") } -`, `"testing"`).Start), - New: singleLineRange(t, `package sample +` + selectionFixture15 = `package sample import ( - "fmt" + "os" "testing" ) -func TestAlpha(t *testing.T) { - t.Log("alpha") +func TestMain(m *testing.M) { + os.Exit(m.Run()) } +` + selectionFixture16 = `package sample -func TestBeta(t *testing.T) { - t.Log("beta") +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + fmt.Println("setup") + os.Exit(m.Run()) } -`, `"fmt"`), - }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, - }, - { - name: "additive helper with new test stays narrow", - oldData: []byte(`package sample +` + selectionFixture17 = `package sample import "testing" -func TestAlpha(t *testing.T) { - t.Log("alpha") +func init() { + register("before") } -`), - newData: []byte(`package sample +` + selectionFixture18 = `package sample import "testing" -func TestAlpha(t *testing.T) { - t.Log("alpha") +func init() { + register("after") } +` + selectionFixture19 = `package sample -func setupCase(t *testing.T) { +import "testing" + +func setup(t *testing.T) { t.Helper() - t.Log("beta helper") + t.Log("helper") } -func TestBeta(t *testing.T) { - setupCase(t) +func TestAlpha(t *testing.T) { + setup(t) } -`), - inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample +` + selectionFixture20 = `package sample import "testing" -func TestAlpha(t *testing.T) { - t.Log("alpha") +func TestBeta(t *testing.T) { + t.Log("new beta") } +` + selectionFixture21 = `package sample -func setupCase(t *testing.T) { - t.Helper() - t.Log("beta helper") -} - -func TestBeta(t *testing.T) { - setupCase(t) -} -`, - }), - hunks: []diffHunk{{ - Old: emptyRangeAt(7), - New: rangeSpan( - singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} - -func setupCase(t *testing.T) { - t.Helper() - t.Log("beta helper") -} +import . "testing" -func TestBeta(t *testing.T) { - setupCase(t) +func TestAlpha(t *T) { + t.Log("before alpha") } -`, "func setupCase(t *testing.T) {"), - singleLineRange(t, `package sample - -import "testing" +` + selectionFixture22 = `package sample -func TestAlpha(t *testing.T) { - t.Log("alpha") -} +import . "testing" -func setupCase(t *testing.T) { - t.Helper() - t.Log("beta helper") +func TestAlpha(t *T) { + t.Log("changed alpha") } +` + ) -func TestBeta(t *testing.T) { - setupCase(t) -} -`, "setupCase(t)"), - ), + tests := []struct { + name string + oldData []byte + newData []byte + inventory packageInventory + hunks []diffHunk + wantTests []string + wantBroadened bool + wantDirectoryWide bool + wantNoSelection bool + }{ + { + name: "body change selects only changed test", + oldData: []byte(selectionFixture01), + newData: []byte(selectionFixture02), + inventory: mustPackageInventory(t, map[string]string{ + changedPath: selectionFixture02, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, selectionFixture01, `t.Log("before alpha")`), + New: singleLineRange(t, selectionFixture02, `t.Log("changed alpha")`), + }}, + wantTests: []string{"TestAlpha"}, + }, + { + name: "new top-level test selects only new test", + oldData: []byte(selectionFixture03), + newData: []byte(selectionFixture04), + inventory: mustPackageInventory(t, map[string]string{ + changedPath: selectionFixture04, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(7), + New: singleLineRange(t, selectionFixture04, `t.Log("new beta")`), }}, wantTests: []string{"TestBeta"}, }, { - name: "removed import broadens across package", - oldData: []byte(`package sample - -import ( - "fmt" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), - newData: []byte(`package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), + name: "existing helper change broadens across package", + oldData: []byte(selectionFixture05), + newData: []byte(selectionFixture06), inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, + changedPath: selectionFixture06, "pkg/sibling_test.go": `package sample import "testing" func TestBeta(t *testing.T) { - t.Log("beta") + setup(t) } `, }), hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import ( - "fmt" - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, `"fmt"`), - New: emptyRangeAt(singleLineRange(t, `package sample - -import ( - "testing" -) - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, `"testing"`).Start), + Old: singleLineRange(t, selectionFixture05, `t.Log("before helper")`), + New: singleLineRange(t, selectionFixture06, `t.Log("changed helper")`), }}, wantTests: []string{"TestAlpha", "TestBeta"}, wantBroadened: true, }, { - name: "TestMain broadens across sibling files in same package", - oldData: []byte(`package sample - -import ( - "os" - "testing" -) - -func TestMain(m *testing.M) { - os.Exit(m.Run()) -} -`), - newData: []byte(`package sample - -import ( - "fmt" - "os" - "testing" -) - -func TestMain(m *testing.M) { - fmt.Println("setup") - os.Exit(m.Run()) -} -`), + name: "package variable change broadens across package", + oldData: []byte(selectionFixture07), + newData: []byte(selectionFixture08), inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import ( - "fmt" - "os" - "testing" -) - -func TestMain(m *testing.M) { - fmt.Println("setup") - os.Exit(m.Run()) -} -`, - "pkg/internal_test.go": `package sample + changedPath: selectionFixture08, + "pkg/sibling_test.go": `package sample import "testing" -func TestAlpha(t *testing.T) { - t.Log("alpha") +func TestBeta(t *testing.T) { + t.Log(packageValue) } `, }), hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import ( - "os" - "testing" -) - -func TestMain(m *testing.M) { - os.Exit(m.Run()) -} -`, `os.Exit(m.Run())`), - New: singleLineRange(t, `package sample - -import ( - "fmt" - "os" - "testing" -) - -func TestMain(m *testing.M) { - fmt.Println("setup") - os.Exit(m.Run()) -} -`, `fmt.Println("setup")`), + Old: singleLineRange(t, selectionFixture07, "var packageValue = 1"), + New: singleLineRange(t, selectionFixture08, "var packageValue = 2"), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "additive import broadens package", + oldData: []byte(selectionFixture09), + newData: []byte(selectionFixture10), + inventory: mustPackageInventory(t, map[string]string{ + changedPath: selectionFixture10, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(singleLineRange(t, selectionFixture09, `"testing"`).Start), + New: singleLineRange(t, selectionFixture10, `"fmt"`), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "additive helper with new test stays narrow", + oldData: []byte(selectionFixture03), + newData: []byte(selectionFixture11), + inventory: mustPackageInventory(t, map[string]string{ + changedPath: selectionFixture11, + }), + hunks: []diffHunk{{ + Old: emptyRangeAt(7), + New: rangeSpan( + singleLineRange(t, selectionFixture11, "func setupCase(t *testing.T) {"), + singleLineRange(t, selectionFixture11, "setupCase(t)"), + ), + }}, + wantTests: []string{"TestBeta"}, + }, + { + name: "removed import broadens across package", + oldData: []byte(selectionFixture12), + newData: []byte(selectionFixture13), + inventory: mustPackageInventory(t, map[string]string{ + changedPath: selectionFixture13, + "pkg/sibling_test.go": selectionFixture14, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, selectionFixture12, `"fmt"`), + New: emptyRangeAt(singleLineRange(t, selectionFixture13, `"testing"`).Start), + }}, + wantTests: []string{"TestAlpha", "TestBeta"}, + wantBroadened: true, + }, + { + name: "TestMain broadens across sibling files in same package", + oldData: []byte(selectionFixture15), + newData: []byte(selectionFixture16), + inventory: mustPackageInventory(t, map[string]string{ + changedPath: selectionFixture16, + "pkg/internal_test.go": selectionFixture03, + }), + hunks: []diffHunk{{ + Old: singleLineRange(t, selectionFixture15, `os.Exit(m.Run())`), + New: singleLineRange(t, selectionFixture16, `fmt.Println("setup")`), }}, wantDirectoryWide: true, }, { - name: "init broadens across sibling files in same package", - oldData: []byte(`package sample - -import "testing" - -func init() { - register("before") -} -`), - newData: []byte(`package sample - -import "testing" - -func init() { - register("after") -} -`), + name: "init broadens across sibling files in same package", + oldData: []byte(selectionFixture17), + newData: []byte(selectionFixture18), inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import "testing" - -func init() { - register("after") -} -`, - "pkg/internal_test.go": `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, + changedPath: selectionFixture18, + "pkg/internal_test.go": selectionFixture03, }), hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import "testing" - -func init() { - register("before") -} -`, `register("before")`), - New: singleLineRange(t, `package sample - -import "testing" - -func init() { - register("after") -} -`, `register("after")`), + Old: singleLineRange(t, selectionFixture17, `register("before")`), + New: singleLineRange(t, selectionFixture18, `register("after")`), }}, wantDirectoryWide: true, }, { - name: "deleted helper uses old snapshot to broaden package", - oldData: []byte(`package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`), - newData: []byte(`package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`), + name: "deleted helper uses old snapshot to broaden package", + oldData: []byte(selectionFixture19), + newData: []byte(selectionFixture03), inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, - "pkg/sibling_test.go": `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("beta") -} -`, + changedPath: selectionFixture03, + "pkg/sibling_test.go": selectionFixture14, }), hunks: []diffHunk{{ Old: rangeSpan( - singleLineRange(t, `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, "func setup(t *testing.T) {"), - singleLineRange(t, `package sample - -import "testing" - -func setup(t *testing.T) { - t.Helper() - t.Log("helper") -} - -func TestAlpha(t *testing.T) { - setup(t) -} -`, `t.Log("helper")`), + singleLineRange(t, selectionFixture19, "func setup(t *testing.T) {"), + singleLineRange(t, selectionFixture19, `t.Log("helper")`), ), - New: emptyRangeAt(singleLineRange(t, `package sample - -import "testing" - -func TestAlpha(t *testing.T) { - t.Log("alpha") -} -`, `func TestAlpha(t *testing.T) {`).Start), + New: emptyRangeAt(singleLineRange(t, selectionFixture03, `func TestAlpha(t *testing.T) {`).Start), }}, wantTests: []string{"TestAlpha", "TestBeta"}, wantBroadened: true, @@ -749,92 +433,29 @@ func TestAlpha(t *testing.T) { { name: "brand-new file with additive hunk selects only new tests", oldData: nil, - newData: []byte(`package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`), + newData: []byte(selectionFixture20), inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, + changedPath: selectionFixture20, }), hunks: []diffHunk{{ Old: emptyRangeAt(1), New: rangeSpan( - singleLineRange(t, `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, "func TestBeta(t *testing.T) {"), - singleLineRange(t, `package sample - -import "testing" - -func TestBeta(t *testing.T) { - t.Log("new beta") -} -`, `t.Log("new beta")`), + singleLineRange(t, selectionFixture20, "func TestBeta(t *testing.T) {"), + singleLineRange(t, selectionFixture20, `t.Log("new beta")`), ), }}, wantTests: []string{"TestBeta"}, }, { - name: "dot imported testing is recognized", - oldData: []byte(`package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("before alpha") -} -`), - newData: []byte(`package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("changed alpha") -} -`), + name: "dot imported testing is recognized", + oldData: []byte(selectionFixture21), + newData: []byte(selectionFixture22), inventory: mustPackageInventory(t, map[string]string{ - changedPath: `package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("changed alpha") -} -`, + changedPath: selectionFixture22, }), hunks: []diffHunk{{ - Old: singleLineRange(t, `package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("before alpha") -} -`, `t.Log("before alpha")`), - New: singleLineRange(t, `package sample - -import . "testing" - -func TestAlpha(t *T) { - t.Log("changed alpha") -} -`, `t.Log("changed alpha")`), + Old: singleLineRange(t, selectionFixture21, `t.Log("before alpha")`), + New: singleLineRange(t, selectionFixture22, `t.Log("changed alpha")`), }}, wantTests: []string{"TestAlpha"}, }, From 7405766bad21de277307428da0734108086bd2ea Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 21 May 2026 04:58:53 +0000 Subject: [PATCH 11/14] test: drop unnecessary loop variable shadowing --- selection_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/selection_test.go b/selection_test.go index 94c105f..229b16d 100644 --- a/selection_test.go +++ b/selection_test.go @@ -695,7 +695,6 @@ func TestAlpha(t *testing.T) {} }, } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() headFiles := map[string]string{} From 3d8b911b6991ec5a2bcc20f2d7e801afff7db241 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 21 May 2026 05:46:40 +0000 Subject: [PATCH 12/14] fix: address round 4 review comments --- diff.go | 19 ---------- diff_test.go | 4 +-- githubactions.go | 3 +- githubactions_test.go | 55 ++++++++++++++++++++++++++++ inventory.go | 83 ++++++++++++++++++++++++------------------- inventory_test.go | 2 +- selection.go | 34 +++++++++--------- selection_test.go | 65 +++++++++++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 78 deletions(-) diff --git a/diff.go b/diff.go index 5e0ccc0..1780b60 100644 --- a/diff.go +++ b/diff.go @@ -239,25 +239,6 @@ func parseNonNegativeInt(value string) (int, error) { return parsed, nil } -func readFileAtRevision(ctx context.Context, cfg config, git gitRunner, revision, filePath string) ([]byte, bool, error) { - if err := ensureRevisionExists(ctx, cfg, git, revision); err != nil { - return nil, false, err - } - fileExists, err := fileExistsAtRevision(ctx, cfg, git, revision, filePath) - if err != nil { - return nil, false, err - } - if !fileExists { - return nil, false, nil - } - - result, err := git(ctx, cfg.RepoRoot, "show", revision+":"+filePath) - if err != nil { - return nil, false, fmt.Errorf("read %s at %s: %w", filePath, revision, err) - } - return []byte(result.Stdout), true, nil -} - func fileExistsAtRevision(ctx context.Context, cfg config, git gitRunner, revision, filePath string) (bool, error) { result, err := git(ctx, cfg.RepoRoot, "ls-tree", "-z", "--name-only", revision, "--", filePath) if err != nil { diff --git a/diff_test.go b/diff_test.go index 69abfdf..a9b379b 100644 --- a/diff_test.go +++ b/diff_test.go @@ -65,7 +65,7 @@ func TestParseNonNegativeInt(t *testing.T) { require.Error(t, err) } -func TestReadFileAtRevisionPropagatesExistenceCheckFailures(t *testing.T) { +func TestFileExistsAtRevisionPropagatesLsTreeFailures(t *testing.T) { t.Parallel() repo := fakeGitRepo{ @@ -82,6 +82,6 @@ func TestReadFileAtRevisionPropagatesExistenceCheckFailures(t *testing.T) { }, }, } - _, _, err := readFileAtRevision(t.Context(), config{RepoRoot: t.TempDir()}, repo.runner(t), "head", "pkg/sample_test.go") + _, err := fileExistsAtRevision(t.Context(), config{RepoRoot: t.TempDir()}, repo.runner(t), "head", "pkg/sample_test.go") require.ErrorContains(t, err, "check whether pkg/sample_test.go exists at head") } diff --git a/githubactions.go b/githubactions.go index fa6558d..5adddc0 100644 --- a/githubactions.go +++ b/githubactions.go @@ -215,7 +215,8 @@ func ensureConcreteRangeAvailable(ctx context.Context, req *runRequest, git gitR attempts := []error{fmt.Errorf("initial merge-base: %w", mergeErr)} for _, spec := range req.Fetches { if err := validateFetchSpec(spec); err != nil { - return err + attempts = append(attempts, fmt.Errorf("validate fetch spec %s: %w", spec.Ref, err)) + continue } if _, err := fetch(ctx, req.RepoRoot, spec); err != nil { attempts = append(attempts, fmt.Errorf("fetch %s from %s: %w", spec.Ref, spec.Remote, err)) diff --git a/githubactions_test.go b/githubactions_test.go index bbb46d2..59a89e0 100644 --- a/githubactions_test.go +++ b/githubactions_test.go @@ -344,6 +344,61 @@ func TestEnsureRangeAvailableFallsBackWhenFirstFetchFails(t *testing.T) { require.Equal(t, req.Fetches, fetches) } +func TestEnsureRangeAvailableSkipsInvalidFetchSpecWhenLaterFetchSucceeds(t *testing.T) { + t.Parallel() + + req := runRequest{ + RepoRoot: "/repo", + Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, + Fetches: []fetchSpec{ + {Remote: "", Ref: "refs/heads/main"}, + {Remote: "https://github.com/coder/coder.git", Ref: "base123"}, + }, + } + mergeBaseCalls := 0 + git := func(_ context.Context, _ string, args ...string) (gitResult, error) { + require.Equal(t, []string{"merge-base", "base123", "head123"}, args) + mergeBaseCalls++ + if mergeBaseCalls == 1 { + return gitFailure("fatal: no merge base") + } + return gitResult{Stdout: "base123\n"}, nil + } + var fetches []fetchSpec + fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { + fetches = append(fetches, spec) + return gitResult{}, nil + } + require.NoError(t, ensureRangeAvailable(t.Context(), &req, git, fetch)) + require.Equal(t, 2, mergeBaseCalls) + require.Equal(t, []fetchSpec{{Remote: "https://github.com/coder/coder.git", Ref: "base123"}}, fetches) +} + +func TestEnsureRangeAvailableReportsInvalidFetchSpecWithAttempts(t *testing.T) { + t.Parallel() + + req := runRequest{ + RepoRoot: "/repo", + Range: diffRange{BaseSHA: "base123", HeadSHA: "head123"}, + Fetches: []fetchSpec{ + {Remote: "", Ref: "refs/heads/main"}, + }, + } + git := func(_ context.Context, _ string, args ...string) (gitResult, error) { + require.Equal(t, []string{"merge-base", "base123", "head123"}, args) + return gitFailure("fatal: no merge base") + } + fetch := func(_ context.Context, _ string, spec fetchSpec) (gitResult, error) { + t.Fatalf("unexpected fetch for invalid spec: %+v", spec) + return gitResult{}, nil + } + err := ensureRangeAvailable(t.Context(), &req, git, fetch) + require.Error(t, err) + require.ErrorContains(t, err, "initial merge-base") + require.ErrorContains(t, err, "validate fetch spec refs/heads/main") + require.ErrorContains(t, err, "invalid fetch spec") +} + func TestEnsureRangeAvailableReportsAllFetchFailures(t *testing.T) { t.Parallel() diff --git a/inventory.go b/inventory.go index d79f365..6456746 100644 --- a/inventory.go +++ b/inventory.go @@ -10,12 +10,14 @@ import ( "strings" ) +// inventoryCache owns repository facts for a single run. Returned package +// inventories and parsed snapshots alias cached maps and slices, so callers must +// treat them as read-only unless they clone before mutating. type inventoryCache struct { cfg config git gitRunner validRevisions map[string]struct{} - fileReads map[revisionFileKey]fileReadResult - fileSnapshots map[revisionFileKey]parsedFileSnapshot + files map[revisionFileKey]cachedFile fileLists map[string][]string packages map[string]packageInventory } @@ -25,9 +27,11 @@ type revisionFileKey struct { Path string } -type fileReadResult struct { - Data []byte - Exists bool +type cachedFile struct { + existenceKnown bool + exists bool + parsed bool + snapshot parsedFileSnapshot } func newInventoryCache(cfg config, git gitRunner) *inventoryCache { @@ -35,8 +39,7 @@ func newInventoryCache(cfg config, git gitRunner) *inventoryCache { cfg: cfg, git: git, validRevisions: map[string]struct{}{}, - fileReads: map[revisionFileKey]fileReadResult{}, - fileSnapshots: map[revisionFileKey]parsedFileSnapshot{}, + files: map[revisionFileKey]cachedFile{}, fileLists: map[string][]string{}, packages: map[string]packageInventory{}, } @@ -53,49 +56,49 @@ func (cache *inventoryCache) ensureRevisionExists(ctx context.Context, revision return nil } -func (cache *inventoryCache) readFileAtRevision(ctx context.Context, revision, filePath string) ([]byte, bool, error) { +func (cache *inventoryCache) noteFileExists(revision, filePath string) { key := revisionFileKey{Revision: revision, Path: cleanGitPath(filePath)} - if result, ok := cache.fileReads[key]; ok { - return result.Data, result.Exists, nil - } - if err := cache.ensureRevisionExists(ctx, revision); err != nil { - return nil, false, err - } - fileExists, err := fileExistsAtRevision(ctx, cache.cfg, cache.git, revision, key.Path) - if err != nil { - return nil, false, err - } - if !fileExists { - cache.fileReads[key] = fileReadResult{} - return nil, false, nil - } - - result, err := cache.git(ctx, cache.cfg.RepoRoot, "show", revision+":"+key.Path) - if err != nil { - return nil, false, fmt.Errorf("read %s at %s: %w", key.Path, revision, err) - } - read := fileReadResult{Data: []byte(result.Stdout), Exists: true} - cache.fileReads[key] = read - return read.Data, true, nil + file := cache.files[key] + file.existenceKnown = true + file.exists = true + cache.files[key] = file } +// parseFileAtRevision returns a parsed snapshot for an existing file. The +// returned snapshot aliases cache state and must be treated as read-only. func (cache *inventoryCache) parseFileAtRevision(ctx context.Context, revision, filePath string) (parsedFileSnapshot, bool, error) { key := revisionFileKey{Revision: revision, Path: cleanGitPath(filePath)} - if snapshot, ok := cache.fileSnapshots[key]; ok { - return snapshot, true, nil + file := cache.files[key] + if file.parsed { + return file.snapshot, true, nil } - data, exists, err := cache.readFileAtRevision(ctx, revision, key.Path) - if err != nil { + if err := cache.ensureRevisionExists(ctx, revision); err != nil { return parsedFileSnapshot{}, false, err } - if !exists { + if !file.existenceKnown { + exists, err := fileExistsAtRevision(ctx, cache.cfg, cache.git, revision, key.Path) + if err != nil { + return parsedFileSnapshot{}, false, err + } + file.existenceKnown = true + file.exists = exists + cache.files[key] = file + } + if !file.exists { return parsedFileSnapshot{}, false, nil } - parsed, err := parseSnapshotForPath(key.Path, data) + + result, err := cache.git(ctx, cache.cfg.RepoRoot, "show", revision+":"+key.Path) + if err != nil { + return parsedFileSnapshot{}, false, fmt.Errorf("read %s at %s: %w", key.Path, revision, err) + } + parsed, err := parseSnapshotForPath(key.Path, []byte(result.Stdout)) if err != nil { return parsedFileSnapshot{}, true, fmt.Errorf("parse %s at %s: %w", key.Path, revision, err) } - cache.fileSnapshots[key] = parsed + file.parsed = true + file.snapshot = parsed + cache.files[key] = file return parsed, true, nil } @@ -106,6 +109,8 @@ func (cache *inventoryCache) parseChangeFileAtRevision(ctx context.Context, revi return cache.parseFileAtRevision(ctx, revision, filePath) } +// loadPackageInventory returns an inventory whose maps alias cache state. Callers +// must treat the result as read-only or clone maps before mutating. func (cache *inventoryCache) loadPackageInventory(ctx context.Context, revision string, key packageKey) (packageInventory, error) { cacheKey := revision + "\x00" + key.Dir + "\x00" + key.Name if inventory, ok := cache.packages[cacheKey]; ok { @@ -142,6 +147,9 @@ func (cache *inventoryCache) listTestFilesInDir(ctx context.Context, revision, d if files, ok := cache.fileLists[cacheKey]; ok { return files, nil } + if err := cache.ensureRevisionExists(ctx, revision); err != nil { + return nil, err + } pathspec := cmp.Or(cleanDir, ".") result, err := cache.git(ctx, cache.cfg.RepoRoot, "ls-tree", "-r", "-z", "--name-only", revision, "--", pathspec) if err != nil { @@ -160,6 +168,7 @@ func (cache *inventoryCache) listTestFilesInDir(ctx context.Context, revision, d continue } files = append(files, filePath) + cache.noteFileExists(revision, filePath) } slices.Sort(files) cache.fileLists[cacheKey] = files diff --git a/inventory_test.go b/inventory_test.go index 184bacd..190d5c1 100644 --- a/inventory_test.go +++ b/inventory_test.go @@ -95,7 +95,7 @@ func TestExternal(t *testing.T) {} {Dir: "pkg", Name: "foo_test"}, }, []packageKey{inventories[0].Key, inventories[1].Key}) require.Equal(t, 1, counter.counts["cat-file"]) - require.Equal(t, 3, counter.counts["ls-tree"]) + require.Equal(t, 1, counter.counts["ls-tree"]) require.Equal(t, 2, counter.counts["show"]) firstCommandCount := counter.total diff --git a/selection.go b/selection.go index db7398b..879ad44 100644 --- a/selection.go +++ b/selection.go @@ -103,29 +103,27 @@ func selectChange(ctx context.Context, cache *inventoryCache, selections map[pac } func (change testFileChange) expectsOldFile() bool { - if !isRunnableTestFilePath(change.OldPath) { - return false - } - switch change.Kind { - case changeAdded: - return false - case changeDeleted, changeModified, changeRenamed, changeType: - return true - } - return true + oldRequired, _ := change.Kind.expectedFileSides() + return oldRequired && isRunnableTestFilePath(change.OldPath) } func (change testFileChange) expectsNewFile() bool { - if !isRunnableTestFilePath(change.NewPath) { - return false - } - switch change.Kind { + _, newRequired := change.Kind.expectedFileSides() + return newRequired && isRunnableTestFilePath(change.NewPath) +} + +func (kind changeKind) expectedFileSides() (oldRequired bool, newRequired bool) { + switch kind { + case changeAdded: + return false, true case changeDeleted: - return false - case changeAdded, changeModified, changeRenamed, changeType: - return true + return true, false + case changeModified, changeRenamed, changeType: + return true, true } - return true + // Unknown change kinds intentionally require both sides so selectChange fails + // loud. parseChangeKind is the choke point for supported git diff statuses. + return true, true } func parseSnapshotForPath(filePath string, data []byte) (parsedFileSnapshot, error) { diff --git a/selection_test.go b/selection_test.go index 229b16d..1b8c11a 100644 --- a/selection_test.go +++ b/selection_test.go @@ -716,3 +716,68 @@ func TestAlpha(t *testing.T) {} }) } } + +func TestSelectChangeRequiresNewFileWhenKindExpectsIt(t *testing.T) { + t.Parallel() + + oldPath := "pkg/base_side_test.go" + newPath := "pkg/head_side_test.go" + oldContent := `package sample + +import "testing" + +func TestAlpha(t *testing.T) {} +` + tests := []struct { + name string + change testFileChange + key string + wantPath string + }{ + { + name: "added", + change: testFileChange{Kind: changeAdded, NewPath: newPath}, + key: newPath, + wantPath: newPath, + }, + { + name: "modified", + change: testFileChange{Kind: changeModified, OldPath: oldPath, NewPath: oldPath}, + key: oldPath, + wantPath: oldPath, + }, + { + name: "renamed", + change: testFileChange{Kind: changeRenamed, OldPath: oldPath, NewPath: newPath}, + key: oldPath + "\x00" + newPath, + wantPath: newPath, + }, + { + name: "type", + change: testFileChange{Kind: changeType, OldPath: oldPath, NewPath: oldPath}, + key: oldPath, + wantPath: oldPath, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + baseFiles := map[string]string{} + if tt.change.OldPath != "" { + baseFiles[tt.change.OldPath] = oldContent + } + repo := fakeGitRepo{ + revisions: map[string]map[string]string{ + "base": baseFiles, + "head": {}, + }, + diffOutputs: map[string]string{ + tt.key: diffForChange(lineRange{Start: 5, End: 5}, lineRange{Start: 5, End: 5}), + }, + } + cache := newInventoryCache(config{RepoRoot: "/repo", BaseSHA: "base", HeadSHA: "head"}, repo.runner(t)) + err := selectChange(t.Context(), cache, map[packageKey]*packageSelection{}, tt.change) + require.ErrorContains(t, err, "head revision head is missing "+tt.wantPath) + }) + } +} From 31eacf5e534712f9aa5fe8124ba3eb43147ba5e8 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 21 May 2026 08:02:44 +0000 Subject: [PATCH 13/14] fix: select only directly changed tests --- README.md | 7 +-- broadening.go | 60 ------------------- broadening_test.go | 51 ----------------- cli_test.go | 30 +++++----- config.go | 7 --- integration_test.go | 9 ++- inventory.go | 17 ------ plan.go | 90 ++--------------------------- plan_test.go | 58 +++++++------------ selection.go | 85 ++------------------------- selection_test.go | 136 +++++++++++++++++++++++++++---------------- snapshot.go | 137 +------------------------------------------- snapshot_test.go | 10 +--- 13 files changed, 143 insertions(+), 554 deletions(-) delete mode 100644 broadening.go delete mode 100644 broadening_test.go diff --git a/README.md b/README.md index 7fcda2f..cb429ae 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ go run ./ \ For `pull_request` events, checkout must use the PR head SHA, for example `github.event.pull_request.head.sha`. The default synthetic merge ref is rejected because the checked-out `HEAD` must match `pull_request.head.sha`. -The matrix JSON contains `include` rows with `package`, `run_regex`, and `test_count`. `package` is normally one safe Go package pattern. If the matrix cap is hit, the final overflow row stores a space-separated list of safe package tokens in `package`, leaves `run_regex` empty, and sets `test_count` to `1`; this is the contract consumed by the current `flake-go` workflow. +The matrix JSON contains `include` rows with `package`, `run_regex`, and `test_count`. Each row represents one safe Go package pattern and a precise regex for the directly changed runnable tests in that package. The generator does not emit whole-package fallback rows. ## File layout @@ -48,9 +48,8 @@ The binary is a single `package main`, split into focused files: | `request.go` | `runRequest`, `diffRange`, revision validation. | | `gitexec.go` | `gitRunner` / `gitFetcher` types and the real `exec.Command` impl. | | `diff.go` | Reading and parsing `git diff`, change kinds, hunks, line ranges. | -| `snapshot.go` | AST snapshot parsing, `fileSnapshot`, and `sharedDecl`. | -| `broadening.go` | Per-kind broadening rules (`broadeningScope`). | -| `selection.go` | Per-change selection logic (`selectChange`, broaden vs narrow). | +| `snapshot.go` | AST snapshot parsing, `fileSnapshot`, and top-level test ranges. | +| `selection.go` | Per-change direct test selection logic (`selectChange`). | | `inventory.go` | `inventoryCache` for package/directory test discovery. | | `plan.go` | Plan construction, matrix and summary rendering (`buildExecutionPlan`, `selectTestPlan`). | | `githubactions.go` | GitHub Actions request builder and history preparation. | diff --git a/broadening.go b/broadening.go deleted file mode 100644 index 969bf7b..0000000 --- a/broadening.go +++ /dev/null @@ -1,60 +0,0 @@ -package main - -type broadeningScope uint8 - -const ( - broadeningNone broadeningScope = iota - broadeningPackage - broadeningDirectory -) - -func broadeningScopeForOldHunk(decls []sharedDecl, candidate lineRange) broadeningScope { - scope := broadeningNone - for _, decl := range decls { - if !decl.Range.overlaps(candidate) { - continue - } - scope = max(scope, decl.broadeningScopeOnOldSide()) - } - return scope -} - -func broadeningScopeForNewHunk(decls []sharedDecl, oldSnapshot *fileSnapshot, candidate lineRange) broadeningScope { - scope := broadeningNone - for _, decl := range decls { - if !decl.Range.overlaps(candidate) { - continue - } - scope = max(scope, decl.broadeningScopeOnNewSide(oldSnapshot)) - } - return scope -} - -func (decl sharedDecl) broadeningScopeOnOldSide() broadeningScope { - switch decl.Kind { - case sharedDeclInit, sharedDeclTestMain: - // Go builds package and package_test files into one test binary. - // Init and TestMain changes can affect every test in the directory. - return broadeningDirectory - case sharedDeclImport, sharedDeclVar, sharedDeclConst, sharedDeclType, sharedDeclHelper: - return broadeningPackage - } - return broadeningNone -} - -func (decl sharedDecl) broadeningScopeOnNewSide(oldSnapshot *fileSnapshot) broadeningScope { - switch decl.Kind { - // TODO: Decide whether new imports should narrow to tests that still - // reference package-local declarations. Today any import edit broadens - // the package. - case sharedDeclImport: - return broadeningPackage - case sharedDeclInit, sharedDeclTestMain: - return broadeningDirectory - case sharedDeclVar, sharedDeclConst, sharedDeclType, sharedDeclHelper: - if oldSnapshot != nil && oldSnapshot.hasAnySharedKey(decl.Keys) { - return broadeningPackage - } - } - return broadeningNone -} diff --git a/broadening_test.go b/broadening_test.go deleted file mode 100644 index bd67685..0000000 --- a/broadening_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestBroadeningScopeForOldHunkChoosesMaxOverlappingScope(t *testing.T) { - t.Parallel() - - data := []byte(`package sample - -import "testing" - -func init() { - register() -} - -func TestAlpha(t *testing.T) {} -`) - snapshot, err := parseFileSnapshot(data) - require.NoError(t, err) - candidate := rangeSpan( - singleLineRange(t, string(data), `import "testing"`), - singleLineRange(t, string(data), "register()"), - ) - require.Equal(t, broadeningDirectory, broadeningScopeForOldHunk(snapshot.shared, candidate)) -} - -func TestBroadeningScopeForNewHunkChoosesMaxOverlappingScope(t *testing.T) { - t.Parallel() - - data := []byte(`package sample - -import "testing" - -func TestMain(m *testing.M) { - m.Run() -} - -func TestAlpha(t *testing.T) {} -`) - snapshot, err := parseFileSnapshot(data) - require.NoError(t, err) - candidate := rangeSpan( - singleLineRange(t, string(data), `import "testing"`), - singleLineRange(t, string(data), "m.Run()"), - ) - require.Equal(t, broadeningDirectory, broadeningScopeForNewHunk(snapshot.shared, nil, candidate)) -} diff --git a/cli_test.go b/cli_test.go index 7e5b478..2505c50 100644 --- a/cli_test.go +++ b/cli_test.go @@ -166,7 +166,7 @@ func TestAlpha(t *testing.T) { require.Contains(t, stderr.String(), "selected 1 package targets") } -func TestRunBroadensTestMainAcrossPackageAndPackageTest(t *testing.T) { +func TestRunIgnoresTestMainChanges(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -238,14 +238,13 @@ func TestMain(m *testing.M) { matrixData, err := os.ReadFile(matrixPath) require.NoError(t, err) require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "./pkg", matrix.Include[0].Package) - require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) + require.Empty(t, matrix.Include) summary, err := os.ReadFile(summaryPath) require.NoError(t, err) - require.Contains(t, string(summary), "TestInternal") - require.Contains(t, string(summary), "TestExternal") + require.Contains(t, string(summary), "no runnable top-level tests were selected") + require.NotContains(t, string(summary), "TestInternal") + require.NotContains(t, string(summary), "TestExternal") } func TestRunHandlesRename(t *testing.T) { @@ -488,7 +487,7 @@ func TestPlatform(t *testing.T) { require.Equal(t, "^(TestPlatform)(/.*)?$", matrix.Include[0].RunRegex) } -func TestRunHandlesDeletedSetupFile(t *testing.T) { +func TestRunIgnoresDeletedSetupFile(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -538,16 +537,16 @@ func TestAlpha(t *testing.T) { matrixData, err := os.ReadFile(matrixPath) require.NoError(t, err) require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "^(TestAlpha)(/.*)?$", matrix.Include[0].RunRegex) + require.Empty(t, matrix.Include) summary, err := os.ReadFile(summaryPath) require.NoError(t, err) require.Contains(t, string(summary), setupPath) - require.Contains(t, string(summary), "TestAlpha") + require.Contains(t, string(summary), "no runnable top-level tests were selected") + require.NotContains(t, string(summary), "TestAlpha") } -func TestRunBroadensInitAcrossPackageAndPackageTest(t *testing.T) { +func TestRunIgnoresInitChanges(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -607,8 +606,7 @@ func init() { matrixData, err := os.ReadFile(matrixPath) require.NoError(t, err) require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "^(TestExternal|TestInternal)(/.*)?$", matrix.Include[0].RunRegex) + require.Empty(t, matrix.Include) } func TestRunHandlesCrossDirectoryRenamePrecisely(t *testing.T) { @@ -672,7 +670,7 @@ func TestMoved(t *testing.T) { require.Equal(t, "^(TestMoved)(/.*)?$", matrix.Include[0].RunRegex) } -func TestRunHandlesCrossDirectoryRenameSourceFallout(t *testing.T) { +func TestRunIgnoresCrossDirectoryHelperRenameSource(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -738,7 +736,5 @@ func TestNewStable(t *testing.T) { matrixData, err := os.ReadFile(matrixPath) require.NoError(t, err) require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "./oldpkg", matrix.Include[0].Package) - require.Equal(t, "^(TestOldStable)(/.*)?$", matrix.Include[0].RunRegex) + require.Empty(t, matrix.Include) } diff --git a/config.go b/config.go index db9984d..6ebd944 100644 --- a/config.go +++ b/config.go @@ -9,13 +9,6 @@ const ( defaultHeadSHA = "HEAD" defaultOutSummary = "-" defaultTestCount = "10" - runOnceTestCount = "1" - - // Package-wide and matrix-wide caps keep the detector cheap by - // running broad fallback targets once instead of repeatedly. - maxMatrixEntries = 20 - maxBroadenedTests = 50 - maxOverflowSummaries = 10 ) type config struct { diff --git a/integration_test.go b/integration_test.go index eae49a8..6c2ca14 100644 --- a/integration_test.go +++ b/integration_test.go @@ -55,7 +55,7 @@ func TestAdded(t *testing.T) { require.Contains(t, string(summary), "TestAdded") } -func TestRunWithRealGitHandlesDeletedSetupFile(t *testing.T) { +func TestRunWithRealGitIgnoresDeletedSetupFile(t *testing.T) { t.Parallel() requireGit(t) @@ -98,14 +98,13 @@ func TestAlpha(t *testing.T) { matrixData, err := os.ReadFile(matrixPath) require.NoError(t, err) require.NoError(t, json.Unmarshal(matrixData, &matrix)) - require.Len(t, matrix.Include, 1) - require.Equal(t, "./pkg", matrix.Include[0].Package) - require.Equal(t, "^(TestAlpha)(/.*)?$", matrix.Include[0].RunRegex) + require.Empty(t, matrix.Include) summary, err := os.ReadFile(summaryPath) require.NoError(t, err) require.Contains(t, string(summary), "pkg/setup_test.go") - require.Contains(t, string(summary), "TestAlpha") + require.Contains(t, string(summary), "no runnable top-level tests were selected") + require.NotContains(t, string(summary), "TestAlpha") } func TestEnsureRangeAvailableWithRealGitFetchesMovedBase(t *testing.T) { diff --git a/inventory.go b/inventory.go index 6456746..97c9cbf 100644 --- a/inventory.go +++ b/inventory.go @@ -4,7 +4,6 @@ import ( "cmp" "context" "fmt" - "maps" "path/filepath" "slices" "strings" @@ -175,22 +174,6 @@ func (cache *inventoryCache) listTestFilesInDir(ctx context.Context, revision, d return files, nil } -func (cache *inventoryCache) directoryWideSelections(ctx context.Context, revision, dir string, files map[string]struct{}) ([]*packageSelection, error) { - inventories, err := cache.loadDirectoryInventories(ctx, revision, dir) - if err != nil { - return nil, err - } - selections := make([]*packageSelection, 0, len(inventories)) - for _, inventory := range inventories { - selection := allPackageTestsSelectionForFiles(inventory, maps.Clone(files)) - if selection == nil { - continue - } - selections = append(selections, selection) - } - return selections, nil -} - func (cache *inventoryCache) loadDirectoryInventories(ctx context.Context, revision, dir string) ([]packageInventory, error) { files, err := cache.listTestFilesInDir(ctx, revision, dir) if err != nil { diff --git a/plan.go b/plan.go index 8ea8c06..5d9776d 100644 --- a/plan.go +++ b/plan.go @@ -19,9 +19,6 @@ type matrixOutput struct { Include []matrixEntry `json:"include"` } -// matrixEntry.Package is a single safe package token except for overflow rows, -// where it is a space-separated list of safe package tokens consumed by the -// flake-go workflow. type matrixEntry struct { Package string `json:"package"` RunRegex string `json:"run_regex,omitempty"` @@ -36,7 +33,6 @@ type summaryEntry struct { Label string Files []string Tests []string - RunAll bool TestCount string Notes []string } @@ -49,8 +45,6 @@ type buildResult struct { type executionAccumulator struct { Files map[string]struct{} Tests map[string]struct{} - Broadened bool - RunAll bool TestCount string Notes []string } @@ -83,6 +77,9 @@ func selectTestPlan(ctx context.Context, cfg config, git gitRunner) ([]string, b func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResult, error) { accumulators := map[string]*executionAccumulator{} for key, selection := range selections { + if len(selection.Tests) == 0 { + continue + } packagePath := packagePattern(key.Dir) if !isSafePackagePattern(packagePath) { return buildResult{}, fmt.Errorf("unsafe package path %q", packagePath) @@ -96,7 +93,6 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul } accumulators[packagePath] = entry } - entry.Broadened = entry.Broadened || selection.Broadened maps.Copy(entry.Files, selection.Files) maps.Copy(entry.Tests, selection.Tests) } @@ -107,20 +103,10 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul entry := accumulators[packagePath] tests := slices.Sorted(maps.Keys(entry.Tests)) files := slices.Sorted(maps.Keys(entry.Files)) - if entry.Broadened && len(tests) > maxBroadenedTests { - entry.RunAll = true - entry.TestCount = runOnceTestCount - entry.Notes = append(entry.Notes, fmt.Sprintf("Package-wide broadening selected %d tests, above the %d-test cap, so this target will run all tests once.", len(tests), maxBroadenedTests)) - } if unsafeTestCount := unsafeRunRegexTestCount(tests); unsafeTestCount > 0 { - entry.RunAll = true - entry.TestCount = runOnceTestCount - entry.Notes = append(entry.Notes, fmt.Sprintf("Selected %d test names that cannot be passed safely through RUN, so this target will run all tests once.", unsafeTestCount)) - } - runRegex := "" - if !entry.RunAll { - runRegex = buildRunRegex(tests) + return buildResult{}, fmt.Errorf("selected %d test names for %s that cannot be passed safely through RUN", unsafeTestCount, packagePath) } + runRegex := buildRunRegex(tests) result.Matrix.Include = append(result.Matrix.Include, matrixEntry{ Package: packagePath, RunRegex: runRegex, @@ -130,62 +116,14 @@ func buildExecutionPlan(selections map[packageKey]*packageSelection) (buildResul Label: packagePath, Files: files, Tests: tests, - RunAll: entry.RunAll, TestCount: entry.TestCount, Notes: entry.Notes, }) } - if len(result.Matrix.Include) > maxMatrixEntries { - keep := maxMatrixEntries - 1 - overflowPackages := make([]string, 0, len(result.Matrix.Include)-keep) - overflowFiles := map[string]struct{}{} - for _, entry := range result.Matrix.Include[keep:] { - overflowPackages = append(overflowPackages, entry.Package) - } - for _, entry := range result.Summary.Entries[keep:] { - for _, filePath := range entry.Files { - overflowFiles[filePath] = struct{}{} - } - } - note := fmt.Sprintf("Matrix target cap %d hit. Collapsed %d additional packages into one overflow target that runs once.", maxMatrixEntries, len(overflowPackages)) - result.Matrix.Include = result.Matrix.Include[:keep] - result.Matrix.Include = append(result.Matrix.Include, matrixEntry{ - Package: strings.Join(overflowPackages, " "), - TestCount: runOnceTestCount, - }) - result.Summary.Entries = result.Summary.Entries[:keep] - result.Summary.Entries = append(result.Summary.Entries, summaryEntry{ - Label: fmt.Sprintf("overflow target (%d packages)", len(overflowPackages)), - Files: slices.Sorted(maps.Keys(overflowFiles)), - RunAll: true, - TestCount: runOnceTestCount, - Notes: []string{ - note, - summarizePackages(overflowPackages), - }, - }) - } - return result, nil } -func summarizePackages(packages []string) string { - display := packages - if len(display) > maxOverflowSummaries { - display = display[:maxOverflowSummaries] - } - quoted := make([]string, 0, len(display)) - for _, packagePath := range display { - quoted = append(quoted, "`"+packagePath+"`") - } - note := "Packages: " + strings.Join(quoted, ", ") - if len(packages) > len(display) { - note += fmt.Sprintf(", and %d more.", len(packages)-len(display)) - } - return note -} - func isSafePackagePattern(packagePath string) bool { if !safePackagePatternRE.MatchString(packagePath) { return false @@ -256,17 +194,6 @@ func renderSummary(changedFiles []string, summary summaryReport) string { _, _ = builder.WriteString("- " + note + "\n") } } - if entry.RunAll { - _, _ = builder.WriteString("\nRuns all tests in this target " + countDescription(entry.TestCount) + ".\n") - if len(entry.Tests) > 0 { - _, _ = builder.WriteString("\nAttributed tests:\n") - for _, testName := range entry.Tests { - _, _ = builder.WriteString("- `" + testName + "`\n") - } - } - _, _ = builder.WriteString("\n") - continue - } _, _ = builder.WriteString("\nTests:\n") for _, testName := range entry.Tests { _, _ = builder.WriteString("- `" + testName + "`\n") @@ -279,10 +206,3 @@ func renderSummary(changedFiles []string, summary summaryReport) string { func renderSummaryFilePath(filePath string) string { return strconv.QuoteToASCII(filePath) } - -func countDescription(count string) string { - if count == "1" { - return "once" - } - return count + " times" -} diff --git a/plan_test.go b/plan_test.go index 4fa8e12..950eafa 100644 --- a/plan_test.go +++ b/plan_test.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "strings" "testing" "github.com/stretchr/testify/require" @@ -32,7 +31,7 @@ func TestRenderSummaryQuotesFilenames(t *testing.T) { require.NotContains(t, summary, "pkg/with\nnewline_test.go") } -func TestBuildExecutionPlanRunsAllForUnsafeTestNames(t *testing.T) { +func TestBuildExecutionPlanRejectsUnsafeTestNames(t *testing.T) { t.Parallel() selection := &packageSelection{ @@ -40,13 +39,8 @@ func TestBuildExecutionPlanRunsAllForUnsafeTestNames(t *testing.T) { Tests: map[string]struct{}{"TestAlpha": {}, "TestĪ›": {}}, Files: map[string]struct{}{"pkg/sample_test.go": {}}, } - result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) - require.NoError(t, err) - require.Len(t, result.Matrix.Include, 1) - require.Empty(t, result.Matrix.Include[0].RunRegex) - require.Equal(t, "1", result.Matrix.Include[0].TestCount) - require.True(t, result.Summary.Entries[0].RunAll) - require.Contains(t, result.Summary.Entries[0].Notes[0], "cannot be passed safely") + _, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) + require.ErrorContains(t, err, "cannot be passed safely through RUN") } func TestBuildExecutionPlanRejectsUnsafePackagePaths(t *testing.T) { @@ -74,32 +68,33 @@ func TestIsSafePackagePatternAllowsSafeNamesAndRejectsTraversal(t *testing.T) { } } -func TestBuildExecutionPlanCapsBroadenedTarget(t *testing.T) { +func TestBuildExecutionPlanKeepsManyExactTestsPrecise(t *testing.T) { t.Parallel() selection := &packageSelection{ - Key: packageKey{Dir: "pkg", Name: "sample"}, - Tests: map[string]struct{}{}, - Files: map[string]struct{}{"pkg/setup_test.go": {}}, - Broadened: true, + Key: packageKey{Dir: "pkg", Name: "sample"}, + Tests: map[string]struct{}{}, + Files: map[string]struct{}{"pkg/sample_test.go": {}}, } - for index := range maxBroadenedTests + 1 { + for index := range 75 { selection.Tests[fmt.Sprintf("Test%03d", index)] = struct{}{} } result, err := buildExecutionPlan(map[packageKey]*packageSelection{selection.Key: selection}) require.NoError(t, err) require.Len(t, result.Matrix.Include, 1) - require.Equal(t, "1", result.Matrix.Include[0].TestCount) - require.Empty(t, result.Matrix.Include[0].RunRegex) - require.True(t, result.Summary.Entries[0].RunAll) - require.Contains(t, result.Summary.Entries[0].Notes[0], "above the 50-test cap") + require.Equal(t, "10", result.Matrix.Include[0].TestCount) + require.NotEmpty(t, result.Matrix.Include[0].RunRegex) + require.Contains(t, result.Matrix.Include[0].RunRegex, "Test000") + require.Contains(t, result.Matrix.Include[0].RunRegex, "Test074") + require.Empty(t, result.Summary.Entries[0].Notes) + require.Len(t, result.Summary.Entries[0].Tests, 75) } -func TestBuildExecutionPlanCapsMatrixTargets(t *testing.T) { +func TestBuildExecutionPlanDoesNotCollapseManyMatrixTargets(t *testing.T) { t.Parallel() selections := map[packageKey]*packageSelection{} - for index := range maxMatrixEntries + maxOverflowSummaries + 2 { + for index := range 33 { key := packageKey{Dir: fmt.Sprintf("pkg%02d", index), Name: "sample"} selections[key] = &packageSelection{ Key: key, @@ -109,22 +104,12 @@ func TestBuildExecutionPlanCapsMatrixTargets(t *testing.T) { } result, err := buildExecutionPlan(selections) require.NoError(t, err) - require.Len(t, result.Matrix.Include, maxMatrixEntries) - overflow := result.Matrix.Include[len(result.Matrix.Include)-1] - require.Equal(t, strings.Join([]string{ - "./pkg19", "./pkg20", "./pkg21", "./pkg22", "./pkg23", "./pkg24", "./pkg25", - "./pkg26", "./pkg27", "./pkg28", "./pkg29", "./pkg30", "./pkg31", - }, " "), overflow.Package) - require.Empty(t, overflow.RunRegex) - require.Equal(t, "1", overflow.TestCount) - for _, packagePath := range strings.Fields(overflow.Package) { - require.True(t, isSafePackagePattern(packagePath), packagePath) + require.Len(t, result.Matrix.Include, 33) + for _, entry := range result.Matrix.Include { + require.NotEmpty(t, entry.RunRegex) + require.Equal(t, "10", entry.TestCount) + require.True(t, isSafePackagePattern(entry.Package), entry.Package) } - overflowSummary := result.Summary.Entries[len(result.Summary.Entries)-1] - require.Contains(t, overflowSummary.Notes[0], "Matrix target cap") - require.Contains(t, overflowSummary.Notes[1], "and 3 more") - summary := renderSummary([]string{"pkg00/file_test.go"}, result.Summary) - require.Equal(t, 1, strings.Count(summary, "Matrix target cap")) } func TestBuildExecutionPlanKeepsSameNamePackageAndExternalTestsPrecise(t *testing.T) { @@ -148,6 +133,5 @@ func TestBuildExecutionPlanKeepsSameNamePackageAndExternalTestsPrecise(t *testin require.Equal(t, "./pkg", result.Matrix.Include[0].Package) require.Equal(t, "^(TestShared)(/.*)?$", result.Matrix.Include[0].RunRegex) require.Equal(t, "10", result.Matrix.Include[0].TestCount) - require.False(t, result.Summary.Entries[0].RunAll) require.Empty(t, result.Summary.Entries[0].Notes) } diff --git a/selection.go b/selection.go index 879ad44..70186ae 100644 --- a/selection.go +++ b/selection.go @@ -5,7 +5,6 @@ import ( "fmt" "maps" "path/filepath" - "slices" ) type packageKey struct { @@ -19,11 +18,9 @@ type packageInventory struct { } type packageSelection struct { - Key packageKey - Tests map[string]struct{} - Files map[string]struct{} - Broadened bool - DirectoryWide bool + Key packageKey + Tests map[string]struct{} + Files map[string]struct{} } type parsedFileSnapshot struct { @@ -137,24 +134,11 @@ func parseSnapshotForPath(filePath string, data []byte) (parsedFileSnapshot, err }, nil } -func mergeSelection(ctx context.Context, cache *inventoryCache, selections map[packageKey]*packageSelection, selection *packageSelection) error { - if selection == nil { +func mergeSelection(_ context.Context, _ *inventoryCache, selections map[packageKey]*packageSelection, selection *packageSelection) error { + if selection == nil || len(selection.Tests) == 0 { return nil } - if !selection.DirectoryWide { - if len(selection.Tests) > 0 { - mergePackageSelection(selections, selection) - } - return nil - } - - expanded, err := cache.directoryWideSelections(ctx, cache.cfg.HeadSHA, selection.Key.Dir, selection.Files) - if err != nil { - return fmt.Errorf("load directory-wide inventory for %s: %w", packagePattern(selection.Key.Dir), err) - } - for _, expandedSelection := range expanded { - mergePackageSelection(selections, expandedSelection) - } + mergePackageSelection(selections, selection) return nil } @@ -168,32 +152,13 @@ func mergePackageSelection(selections map[packageKey]*packageSelection, selectio } selections[selection.Key] = merged } - merged.Broadened = merged.Broadened || selection.Broadened maps.Copy(merged.Files, selection.Files) maps.Copy(merged.Tests, selection.Tests) } func selectTestsFromHunks(change testFileChange, oldSnapshot *fileSnapshot, newSnapshot fileSnapshot, newInventory packageInventory, hunks []diffHunk) *packageSelection { - if oldSnapshot == nil && needsOldSnapshot(hunks) { - return allPackageTestsSelection(newInventory, change.displayPath()) - } - selected := map[string]struct{}{} for _, hunk := range hunks { - if oldSnapshot != nil { - switch broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old) { - case broadeningDirectory: - return allDirectoryTestsSelection(newInventory.Key.Dir, change.displayPath()) - case broadeningPackage: - return allPackageTestsSelection(newInventory, change.displayPath()) - } - } - switch broadeningScopeForNewHunk(newSnapshot.shared, oldSnapshot, hunk.New) { - case broadeningDirectory: - return allDirectoryTestsSelection(newInventory.Key.Dir, change.displayPath()) - case broadeningPackage: - return allPackageTestsSelection(newInventory, change.displayPath()) - } addMatchingTests(selected, newSnapshot.tests, hunk.New) if oldSnapshot == nil { continue @@ -220,12 +185,6 @@ func selectTestsFromHunks(change testFileChange, oldSnapshot *fileSnapshot, newS func selectSourceRemoval(change testFileChange, oldSnapshot fileSnapshot, inventory packageInventory, hunks []diffHunk) *packageSelection { selected := map[string]struct{}{} for _, hunk := range hunks { - switch broadeningScopeForOldHunk(oldSnapshot.shared, hunk.Old) { - case broadeningDirectory: - return allDirectoryTestsSelection(inventory.Key.Dir, change.displayPath()) - case broadeningPackage: - return allPackageTestsSelection(inventory, change.displayPath()) - } for name, declRange := range oldSnapshot.tests { if !declRange.overlaps(hunk.Old) { continue @@ -245,38 +204,6 @@ func selectSourceRemoval(change testFileChange, oldSnapshot fileSnapshot, invent } } -func allPackageTestsSelection(inventory packageInventory, filePath string) *packageSelection { - return allPackageTestsSelectionForFiles(inventory, map[string]struct{}{filePath: {}}) -} - -func allPackageTestsSelectionForFiles(inventory packageInventory, files map[string]struct{}) *packageSelection { - selection := &packageSelection{ - Key: inventory.Key, - Tests: map[string]struct{}{}, - Files: files, - Broadened: true, - } - maps.Copy(selection.Tests, inventory.Tests) - if len(selection.Tests) == 0 { - return nil - } - return selection -} - -func allDirectoryTestsSelection(dir, filePath string) *packageSelection { - return &packageSelection{ - Key: packageKey{Dir: dir}, - Files: map[string]struct{}{filePath: {}}, - DirectoryWide: true, - } -} - -func needsOldSnapshot(hunks []diffHunk) bool { - return slices.ContainsFunc(hunks, func(hunk diffHunk) bool { - return hunk.Old.hasLines() - }) -} - func addMatchingTests(selected map[string]struct{}, tests map[string]lineRange, candidate lineRange) { for name, declRange := range tests { if declRange.overlaps(candidate) { diff --git a/selection_test.go b/selection_test.go index 1b8c11a..20ab4df 100644 --- a/selection_test.go +++ b/selection_test.go @@ -259,15 +259,13 @@ func TestAlpha(t *T) { ) tests := []struct { - name string - oldData []byte - newData []byte - inventory packageInventory - hunks []diffHunk - wantTests []string - wantBroadened bool - wantDirectoryWide bool - wantNoSelection bool + name string + oldData []byte + newData []byte + inventory packageInventory + hunks []diffHunk + wantTests []string + wantNoSelection bool }{ { name: "body change selects only changed test", @@ -296,7 +294,7 @@ func TestAlpha(t *T) { wantTests: []string{"TestBeta"}, }, { - name: "existing helper change broadens across package", + name: "existing helper change selects no tests", oldData: []byte(selectionFixture05), newData: []byte(selectionFixture06), inventory: mustPackageInventory(t, map[string]string{ @@ -314,11 +312,10 @@ func TestBeta(t *testing.T) { Old: singleLineRange(t, selectionFixture05, `t.Log("before helper")`), New: singleLineRange(t, selectionFixture06, `t.Log("changed helper")`), }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, + wantNoSelection: true, }, { - name: "package variable change broadens across package", + name: "package variable change selects no tests", oldData: []byte(selectionFixture07), newData: []byte(selectionFixture08), inventory: mustPackageInventory(t, map[string]string{ @@ -336,11 +333,10 @@ func TestBeta(t *testing.T) { Old: singleLineRange(t, selectionFixture07, "var packageValue = 1"), New: singleLineRange(t, selectionFixture08, "var packageValue = 2"), }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, + wantNoSelection: true, }, { - name: "additive import broadens package", + name: "additive import selects no tests", oldData: []byte(selectionFixture09), newData: []byte(selectionFixture10), inventory: mustPackageInventory(t, map[string]string{ @@ -350,8 +346,7 @@ func TestBeta(t *testing.T) { Old: emptyRangeAt(singleLineRange(t, selectionFixture09, `"testing"`).Start), New: singleLineRange(t, selectionFixture10, `"fmt"`), }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, + wantNoSelection: true, }, { name: "additive helper with new test stays narrow", @@ -370,7 +365,7 @@ func TestBeta(t *testing.T) { wantTests: []string{"TestBeta"}, }, { - name: "removed import broadens across package", + name: "removed import selects no tests", oldData: []byte(selectionFixture12), newData: []byte(selectionFixture13), inventory: mustPackageInventory(t, map[string]string{ @@ -381,11 +376,10 @@ func TestBeta(t *testing.T) { Old: singleLineRange(t, selectionFixture12, `"fmt"`), New: emptyRangeAt(singleLineRange(t, selectionFixture13, `"testing"`).Start), }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, + wantNoSelection: true, }, { - name: "TestMain broadens across sibling files in same package", + name: "TestMain change selects no tests", oldData: []byte(selectionFixture15), newData: []byte(selectionFixture16), inventory: mustPackageInventory(t, map[string]string{ @@ -396,10 +390,10 @@ func TestBeta(t *testing.T) { Old: singleLineRange(t, selectionFixture15, `os.Exit(m.Run())`), New: singleLineRange(t, selectionFixture16, `fmt.Println("setup")`), }}, - wantDirectoryWide: true, + wantNoSelection: true, }, { - name: "init broadens across sibling files in same package", + name: "init change selects no tests", oldData: []byte(selectionFixture17), newData: []byte(selectionFixture18), inventory: mustPackageInventory(t, map[string]string{ @@ -410,10 +404,10 @@ func TestBeta(t *testing.T) { Old: singleLineRange(t, selectionFixture17, `register("before")`), New: singleLineRange(t, selectionFixture18, `register("after")`), }}, - wantDirectoryWide: true, + wantNoSelection: true, }, { - name: "deleted helper uses old snapshot to broaden package", + name: "deleted helper selects no tests", oldData: []byte(selectionFixture19), newData: []byte(selectionFixture03), inventory: mustPackageInventory(t, map[string]string{ @@ -427,8 +421,7 @@ func TestBeta(t *testing.T) { ), New: emptyRangeAt(singleLineRange(t, selectionFixture03, `func TestAlpha(t *testing.T) {`).Start), }}, - wantTests: []string{"TestAlpha", "TestBeta"}, - wantBroadened: true, + wantNoSelection: true, }, { name: "brand-new file with additive hunk selects only new tests", @@ -473,19 +466,12 @@ func TestBeta(t *testing.T) { return } require.NotNil(t, selection) - require.Equal(t, tt.wantDirectoryWide, selection.DirectoryWide) - if tt.wantDirectoryWide { - require.Empty(t, selection.Tests) - require.Contains(t, selection.Files, changedPath) - } else { - require.Equal(t, tt.wantTests, selectionNames(selection)) - } - require.Equal(t, tt.wantBroadened, selection.Broadened) + require.Equal(t, tt.wantTests, selectionNames(selection)) }) } } -func TestSelectTestsForSnapshotsTreatsTestMethodsAsSharedHelpers(t *testing.T) { +func TestSelectTestsForSnapshotsIgnoresTestMethods(t *testing.T) { t.Parallel() change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} @@ -534,9 +520,7 @@ func TestBeta(t *testing.T) { Old: singleLineRange(t, string(oldData), `t.Log("before method")`), New: singleLineRange(t, string(newData), `t.Log("changed method")`), }}) - require.NotNil(t, selection) - require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) - require.True(t, selection.Broadened) + require.Nil(t, selection) } func TestSelectTestsForSnapshotsAdditiveSharedDeclsStayNarrow(t *testing.T) { @@ -584,12 +568,11 @@ func TestBeta(t *testing.T) { }}) require.NotNil(t, selection) require.Equal(t, []string{"TestBeta"}, selectionNames(selection)) - require.False(t, selection.Broadened) }) } } -func TestSelectTestsForSnapshotsBroadensAddedImports(t *testing.T) { +func TestSelectTestsForSnapshotsIgnoresAddedImports(t *testing.T) { t.Parallel() change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} @@ -629,9 +612,66 @@ func TestBeta(t *testing.T) { Old: emptyRangeAt(3), New: singleLineRange(t, string(newData), `_ "example.com/sideeffect"`), }}) + require.Nil(t, selection) +} + +func TestSelectTestsForSnapshotsAddedImportWithNewTestSelectsOnlyNewTest(t *testing.T) { + t.Parallel() + + change := testFileChange{Kind: changeModified, OldPath: "pkg/changed_test.go", NewPath: "pkg/changed_test.go"} + oldData := []byte(`package sample + +import "testing" + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} +`) + newData := []byte(`package sample + +import ( + "slices" + "testing" +) + +func TestAlpha(t *testing.T) { + t.Log("alpha") +} + +func TestBeta(t *testing.T) { + if !slices.Contains([]string{"beta"}, "beta") { + t.Fatal("missing beta") + } +} +`) + inventory := mustPackageInventory(t, map[string]string{ + "pkg/changed_test.go": string(newData), + "pkg/sibling_test.go": `package sample + +import "testing" + +func TestGamma(t *testing.T) { + t.Log("gamma") +} +`, + }) + oldSnapshot := mustOptionalFileSnapshot(t, oldData) + newSnapshot := mustFileSnapshot(t, newData) + selection := selectTestsFromHunks(change, oldSnapshot, newSnapshot, inventory, []diffHunk{ + { + Old: emptyRangeAt(3), + New: singleLineRange(t, string(newData), `"slices"`), + }, + { + Old: emptyRangeAt(7), + New: rangeSpan( + singleLineRange(t, string(newData), "func TestBeta(t *testing.T) {"), + singleLineRange(t, string(newData), `t.Fatal("missing beta")`), + ), + }, + }) require.NotNil(t, selection) - require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selection)) - require.True(t, selection.Broadened) + require.Equal(t, []string{"TestBeta"}, selectionNames(selection)) } func TestMergePackageSelectionCombinesSamePackageFiles(t *testing.T) { @@ -645,14 +685,12 @@ func TestMergePackageSelectionCombinesSamePackageFiles(t *testing.T) { Files: map[string]struct{}{"pkg/alpha_test.go": {}}, }) mergePackageSelection(selections, &packageSelection{ - Key: key, - Tests: map[string]struct{}{"TestBeta": {}}, - Files: map[string]struct{}{"pkg/beta_test.go": {}}, - Broadened: true, + Key: key, + Tests: map[string]struct{}{"TestBeta": {}}, + Files: map[string]struct{}{"pkg/beta_test.go": {}}, }) require.Equal(t, []string{"TestAlpha", "TestBeta"}, selectionNames(selections[key])) - require.True(t, selections[key].Broadened) require.Contains(t, selections[key].Files, "pkg/alpha_test.go") require.Contains(t, selections[key].Files, "pkg/beta_test.go") } diff --git a/snapshot.go b/snapshot.go index 19b6e48..86be947 100644 --- a/snapshot.go +++ b/snapshot.go @@ -1,13 +1,9 @@ package main import ( - "bytes" - "fmt" "go/ast" "go/parser" - "go/printer" "go/token" - "slices" "strings" "unicode" "unicode/utf8" @@ -16,26 +12,6 @@ import ( type fileSnapshot struct { packageName string tests map[string]lineRange - shared []sharedDecl - sharedKeys map[string]struct{} -} - -type sharedDeclKind uint8 - -const ( - sharedDeclImport sharedDeclKind = iota + 1 - sharedDeclVar - sharedDeclConst - sharedDeclType - sharedDeclHelper - sharedDeclInit - sharedDeclTestMain -) - -type sharedDecl struct { - Range lineRange - Kind sharedDeclKind - Keys []string } func parseFileSnapshot(data []byte) (fileSnapshot, error) { @@ -49,43 +25,15 @@ func parseFileSnapshot(data []byte) (fileSnapshot, error) { snapshot := fileSnapshot{ packageName: file.Name.Name, tests: map[string]lineRange{}, - sharedKeys: map[string]struct{}{}, } for _, decl := range file.Decls { - rangeForDecl := nodeRange(fset, decl) funcDecl, ok := decl.(*ast.FuncDecl) - if !ok { - genDecl, ok := decl.(*ast.GenDecl) - if !ok { - snapshot.addSharedDecl(sharedDecl{Range: rangeForDecl, Kind: sharedDeclHelper}) - continue - } - snapshot.addSharedDecl(classifyGenDecl(rangeForDecl, genDecl)) - continue - } - if funcDecl.Name == nil { - snapshot.addSharedDecl(sharedDecl{Range: rangeForDecl, Kind: sharedDeclHelper}) + if !ok || funcDecl.Name == nil { continue } - name := funcDecl.Name.Name - switch { - case name == "TestMain": - snapshot.addSharedDecl(sharedDecl{ - Range: rangeForDecl, - Kind: sharedDeclTestMain, - Keys: []string{"func:TestMain"}, - }) - case name == "init": - snapshot.addSharedDecl(sharedDecl{Range: rangeForDecl, Kind: sharedDeclInit}) - case isTopLevelTestFunc(funcDecl, testingDotImport), isTopLevelFuzzFunc(funcDecl, testingDotImport), isTopLevelExampleFunc(funcDecl): - snapshot.tests[name] = rangeForDecl - default: - snapshot.addSharedDecl(sharedDecl{ - Range: rangeForDecl, - Kind: sharedDeclHelper, - Keys: []string{funcIdentity(fset, funcDecl)}, - }) + if isTopLevelTestFunc(funcDecl, testingDotImport) || isTopLevelFuzzFunc(funcDecl, testingDotImport) || isTopLevelExampleFunc(funcDecl) { + snapshot.tests[funcDecl.Name.Name] = nodeRange(fset, decl) } } return snapshot, nil @@ -106,66 +54,6 @@ func hasTestingDotImport(file *ast.File) bool { return false } -func classifyGenDecl(rangeForDecl lineRange, decl *ast.GenDecl) sharedDecl { - shared := sharedDecl{Range: rangeForDecl} - switch decl.Tok { - case token.IMPORT: - shared.Kind = sharedDeclImport - case token.VAR: - shared.Kind = sharedDeclVar - shared.Keys = genDeclKeys("var", decl.Specs) - case token.CONST: - shared.Kind = sharedDeclConst - shared.Keys = genDeclKeys("const", decl.Specs) - case token.TYPE: - shared.Kind = sharedDeclType - shared.Keys = genDeclKeys("type", decl.Specs) - default: - shared.Kind = sharedDeclHelper - } - return shared -} - -func genDeclKeys(prefix string, specs []ast.Spec) []string { - keys := make([]string, 0, len(specs)) - for _, spec := range specs { - switch typed := spec.(type) { - case *ast.TypeSpec: - if typed.Name == nil || typed.Name.Name == "_" { - continue - } - keys = append(keys, prefix+":"+typed.Name.Name) - case *ast.ValueSpec: - for _, name := range typed.Names { - if name == nil || name.Name == "_" { - continue - } - keys = append(keys, prefix+":"+name.Name) - } - } - } - slices.Sort(keys) - return keys -} - -func funcIdentity(fset *token.FileSet, fn *ast.FuncDecl) string { - if fn.Name == nil { - return "" - } - if fn.Recv == nil || len(fn.Recv.List) == 0 { - return "func:" + fn.Name.Name - } - return "method:" + exprString(fset, fn.Recv.List[0].Type) + "." + fn.Name.Name -} - -func exprString(fset *token.FileSet, expr ast.Expr) string { - var buffer bytes.Buffer - if err := printer.Fprint(&buffer, fset, expr); err != nil { - return fmt.Sprintf("%T", expr) - } - return buffer.String() -} - func nodeRange(fset *token.FileSet, node ast.Node) lineRange { start := fset.Position(node.Pos()).Line end := fset.Position(node.End()).Line @@ -261,22 +149,3 @@ func pointerIdentName(expr ast.Expr) (string, bool) { } return ident.Name, true } - -func (snapshot *fileSnapshot) addSharedDecl(decl sharedDecl) { - snapshot.shared = append(snapshot.shared, decl) - for _, key := range decl.Keys { - if key == "" { - continue - } - snapshot.sharedKeys[key] = struct{}{} - } -} - -func (snapshot *fileSnapshot) hasAnySharedKey(keys []string) bool { - for _, key := range keys { - if _, ok := snapshot.sharedKeys[key]; ok { - return true - } - } - return false -} diff --git a/snapshot_test.go b/snapshot_test.go index 38db136..9372339 100644 --- a/snapshot_test.go +++ b/snapshot_test.go @@ -27,7 +27,7 @@ func Examplefoo() {} require.Equal(t, []string{"Example", "ExampleFoo", "FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) } -func TestParseFileSnapshotRecordsStructure(t *testing.T) { +func TestParseFileSnapshotRecordsRunnableTests(t *testing.T) { t.Parallel() snapshot, err := parseFileSnapshot([]byte(`package sample @@ -47,12 +47,4 @@ func FuzzAlpha(f *F) {} require.NoError(t, err) require.Equal(t, "sample", snapshot.packageName) require.Equal(t, []string{"FuzzAlpha", "TestAlpha"}, slices.Sorted(maps.Keys(snapshot.tests))) - require.Contains(t, snapshot.sharedKeys, "const:answer") - require.Contains(t, snapshot.sharedKeys, "var:packageValue") - require.Contains(t, snapshot.sharedKeys, "type:fixture") - require.Contains(t, snapshot.sharedKeys, "func:helper") - require.Contains(t, snapshot.sharedKeys, "func:TestMain") - require.True(t, slices.ContainsFunc(snapshot.shared, func(decl sharedDecl) bool { - return decl.Kind == sharedDeclInit - })) } From ed49d523ecba7d60a16772660332b4e3d60426bd Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 21 May 2026 12:00:31 +0000 Subject: [PATCH 14/14] fix: address remaining review feedback --- githubactions.go | 6 +++++- githubactions_test.go | 2 +- inventory.go | 9 +++++++++ selection.go | 5 +++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/githubactions.go b/githubactions.go index 5adddc0..793e965 100644 --- a/githubactions.go +++ b/githubactions.go @@ -213,9 +213,11 @@ func ensureConcreteRangeAvailable(ctx context.Context, req *runRequest, git gitR } attempts := []error{fmt.Errorf("initial merge-base: %w", mergeErr)} + // This is a best-effort recovery loop. One valid fetch that restores a merge + // base is enough, so invalid specs and failed fetches are recorded and skipped. for _, spec := range req.Fetches { if err := validateFetchSpec(spec); err != nil { - attempts = append(attempts, fmt.Errorf("validate fetch spec %s: %w", spec.Ref, err)) + attempts = append(attempts, fmt.Errorf("fetch spec %s: %w", spec.Ref, err)) continue } if _, err := fetch(ctx, req.RepoRoot, spec); err != nil { @@ -238,6 +240,8 @@ func runFetches(ctx context.Context, req *runRequest, fetch gitFetcher) error { if fetch == nil { return errors.New("history fetch is required but no fetcher was configured") } + // This is an all-or-nothing setup path. Every refspec must be valid and + // fetched before later work can rely on the requested history. for _, spec := range req.Fetches { if err := validateFetchSpec(spec); err != nil { return err diff --git a/githubactions_test.go b/githubactions_test.go index 59a89e0..916332c 100644 --- a/githubactions_test.go +++ b/githubactions_test.go @@ -395,7 +395,7 @@ func TestEnsureRangeAvailableReportsInvalidFetchSpecWithAttempts(t *testing.T) { err := ensureRangeAvailable(t.Context(), &req, git, fetch) require.Error(t, err) require.ErrorContains(t, err, "initial merge-base") - require.ErrorContains(t, err, "validate fetch spec refs/heads/main") + require.ErrorContains(t, err, "fetch spec refs/heads/main") require.ErrorContains(t, err, "invalid fetch spec") } diff --git a/inventory.go b/inventory.go index 97c9cbf..9401160 100644 --- a/inventory.go +++ b/inventory.go @@ -26,6 +26,10 @@ type revisionFileKey struct { Path string } +// cachedFile records one path's facts at one revision. Its zero value means +// file existence is unknown. exists is meaningful only when existenceKnown is +// true. parsed implies existenceKnown and exists, and snapshot is valid only +// when parsed is true. type cachedFile struct { existenceKnown bool exists bool @@ -55,6 +59,9 @@ func (cache *inventoryCache) ensureRevisionExists(ctx context.Context, revision return nil } +// noteFileExists records a positive file-existence fact discovered by a +// caller. The caller must have already validated that the file exists at the +// revision, typically through a successful git ls-tree directory enumeration. func (cache *inventoryCache) noteFileExists(revision, filePath string) { key := revisionFileKey{Revision: revision, Path: cleanGitPath(filePath)} file := cache.files[key] @@ -93,6 +100,8 @@ func (cache *inventoryCache) parseFileAtRevision(ctx context.Context, revision, } parsed, err := parseSnapshotForPath(key.Path, []byte(result.Stdout)) if err != nil { + // Do not cache parse failures. Current callers return immediately, so a + // hypothetical retry may re-read the file from git instead of storing an error. return parsedFileSnapshot{}, true, fmt.Errorf("parse %s at %s: %w", key.Path, revision, err) } file.parsed = true diff --git a/selection.go b/selection.go index 70186ae..e62874d 100644 --- a/selection.go +++ b/selection.go @@ -118,8 +118,9 @@ func (kind changeKind) expectedFileSides() (oldRequired bool, newRequired bool) case changeModified, changeRenamed, changeType: return true, true } - // Unknown change kinds intentionally require both sides so selectChange fails - // loud. parseChangeKind is the choke point for supported git diff statuses. + // parseChangeKind rejects unknown statuses before selection. This default is a + // safety net for direct callers or future drift, but it only triggers + // missing-file errors when the change carries runnable test paths. return true, true }