diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index ead782d..e4801d3 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -78,6 +78,10 @@ func findJavaFilesGit(root string) ([]string, error) { if line == "" { continue } + if isExcludedInfraPath(line) { + continue + } + // ls-files output is relative to git root. absPath := filepath.Join(gitRoot, line) // Keep only files under target root (avoids picking up @@ -91,11 +95,27 @@ func findJavaFilesGit(root string) ([]string, error) { return files, nil } +func isExcludedInfraPath(path string) bool { + parts := strings.FieldsFunc(filepath.Clean(path), func(r rune) bool { + return r == '/' || r == filepath.Separator + }) + for i, part := range parts { + if part == "vendor" || part == "node_modules" || part == ".git" || part == ".mvn" || part == ".gradle" { + return true + } + if part == "wrapper" && i > 0 && parts[i-1] == "gradle" { + return true + } + } + return false +} + // findJavaFilesWalk is the recursive filesystem walk fallback. // It skips vendor/, node_modules/, .git/, .mvn/, .gradle/, // and gradle/wrapper/ (build-wrapper/infrastructure directories). func findJavaFilesWalk(root string) ([]string, error) { var files []string + rootParent := filepath.Dir(filepath.Clean(root)) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -103,13 +123,11 @@ func findJavaFilesWalk(root string) ([]string, error) { } if info.IsDir() { - name := info.Name() - // Skip common non-source and build-wrapper/infrastructure directories - if name == "vendor" || name == "node_modules" || name == ".git" || name == ".mvn" || name == ".gradle" { - return filepath.SkipDir + relPath, err := filepath.Rel(rootParent, path) + if err != nil { + return err } - // Skip gradle/wrapper build wrapper infrastructure - if name == "wrapper" && filepath.Base(filepath.Dir(path)) == "gradle" { + if isExcludedInfraPath(relPath) { return filepath.SkipDir } return nil diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..18424fa --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -0,0 +1,125 @@ +package discovery + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestIsExcludedInfraPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + want bool + }{ + {name: "maven wrapper nested", path: "complete/.mvn/wrapper/MavenWrapperDownloader.java", want: true}, + {name: "maven wrapper root", path: ".mvn/wrapper/X.java", want: true}, + {name: "vendor", path: "vendor/foo/Bar.java", want: true}, + {name: "node modules", path: "node_modules/pkg/Thing.java", want: true}, + {name: "git dir", path: ".git/hooks/whatever.java", want: true}, + {name: "gradle cache", path: ".gradle/cache/Y.java", want: true}, + {name: "gradle wrapper", path: "proj/gradle/wrapper/GradleWrapperMain.java", want: true}, + {name: "normal source true positive", path: "src/main/java/com/example/HotelServiceEvent.java", want: false}, + {name: "normal app", path: "src/main/java/com/example/App.java", want: false}, + {name: "mvn substring only", path: "mvnutil/src/Main.java", want: false}, + {name: "gradle substring only", path: "mygradlestuff/src/X.java", want: false}, + {name: "wrapper without gradle parent", path: "wrapper/Foo.java", want: false}, + {name: "wrapper under src", path: "src/wrapper/Helper.java", want: false}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := isExcludedInfraPath(tc.path); got != tc.want { + t.Fatalf("isExcludedInfraPath(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +func TestFindJavaFilesWalkExcludesInfraPaths(t *testing.T) { + root := t.TempDir() + + mustWriteFile(t, root, "src/main/java/Live.java") + mustWriteFile(t, root, ".mvn/wrapper/MavenWrapperDownloader.java") + mustWriteFile(t, root, "gradle/wrapper/GradleWrapperMain.java") + mustWriteFile(t, root, "vendor/x/V.java") + + files, err := FindJavaFiles(root) + if err != nil { + t.Fatalf("FindJavaFiles() error = %v", err) + } + + got := relPaths(t, root, files) + want := []string{"src/main/java/Live.java"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("FindJavaFiles() = %v, want %v", got, want) + } +} + +func TestFindJavaFilesWithGitExcludesInfraPaths(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + root := t.TempDir() + mustWriteFile(t, root, "src/main/java/Live.java") + mustWriteFile(t, root, ".mvn/wrapper/MavenWrapperDownloader.java") + mustWriteFile(t, root, "gradle/wrapper/GradleWrapperMain.java") + mustWriteFile(t, root, "vendor/x/V.java") + + git(t, root, "init") + git(t, root, "add", ".") + + files, err := FindJavaFilesWithGit(root, true) + if err != nil { + t.Fatalf("FindJavaFilesWithGit() error = %v", err) + } + + got := relPaths(t, root, files) + want := []string{"src/main/java/Live.java"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("FindJavaFilesWithGit() = %v, want %v", got, want) + } +} + +func mustWriteFile(t *testing.T, root, rel string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte("class X {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) error = %v", path, err) + } +} + +func relPaths(t *testing.T, root string, files []string) []string { + t.Helper() + rel := make([]string, 0, len(files)) + for _, file := range files { + p, err := filepath.Rel(root, file) + if err != nil { + t.Fatalf("filepath.Rel(%q, %q) error = %v", root, file, err) + } + rel = append(rel, filepath.ToSlash(p)) + } + sort.Strings(rel) + return rel +} + +func git(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } +}