diff --git a/internal/plugin/autodetect/auto_detect_util.go b/internal/plugin/autodetect/auto_detect_util.go index 11631718..ca2ef7ef 100644 --- a/internal/plugin/autodetect/auto_detect_util.go +++ b/internal/plugin/autodetect/auto_detect_util.go @@ -6,12 +6,19 @@ import ( "io" "os" "path/filepath" + "sort" + "strings" ) type buildToolInfo struct { globToDetect string tool string preparer RepoPreparer + // extsToDetect lets a tool detect multiple file extensions in a single pass + // with a recursive walk rather than Go's non-recursive filepath.Glob. When + // set, globToDetect is used only as a label and the walk in + // findFilesByExtensions takes precedence. + extsToDetect []string } // containsTool checks if a tool is already in the slice @@ -73,16 +80,7 @@ func DetectDirectoriesToCache(skipPrepare bool) ([]string, []string, string, err globToDetect: "*.csproj", tool: "dotnet", preparer: newDotnetPreparer(), - }, - { - globToDetect: "*.vbproj", - tool: "dotnet", - preparer: newDotnetPreparer(), - }, - { - globToDetect: "*.fsproj", - tool: "dotnet", - preparer: newDotnetPreparer(), + extsToDetect: []string{".csproj", ".vbproj", ".fsproj"}, }, } @@ -100,14 +98,17 @@ func DetectDirectoriesToCache(skipPrepare bool) ([]string, []string, string, err continue } - hash, dir, err := hashIfFileExist(supportedTool.globToDetect) - if err != nil { - return nil, nil, "", err - } - if hash == "" { - hash, dir, err = hashIfFileExist(filepath.Join("**", supportedTool.globToDetect)) - if err != nil { - return nil, nil, "", err + var ( + hash string + dir string + err error + ) + if len(supportedTool.extsToDetect) > 0 { + hash, dir, err = hashAllFilesByExtensions(".", supportedTool.extsToDetect) + } else { + hash, dir, err = hashIfFileExist(supportedTool.globToDetect) + if err == nil && hash == "" { + hash, dir, err = hashIfFileExist(filepath.Join("**", supportedTool.globToDetect)) } } if err != nil { @@ -173,6 +174,88 @@ func calculateMd5FromFiles(fileList []string) (string, string, error) { return hex.EncodeToString(hash.Sum(nil)), dir, nil } +// hashAllFilesByExtensions walks root recursively and hashes every file whose +// extension matches one of exts, producing a single stable digest so the cache +// key reflects every project file in a multi-project repo (e.g. a .NET +// solution with several .csproj files). +// +// Returns (hex digest, common parent dir, error). The returned dir is root's +// absolute path so a single nuget.config / .nuget/packages location covers +// all detected projects. +func hashAllFilesByExtensions(root string, exts []string) (string, string, error) { + matches, err := findFilesByExtensions(root, exts) + if err != nil { + return "", "", err + } + if len(matches) == 0 { + return "", "", nil + } + + // Sort so the digest is independent of walk order across filesystems. + sort.Strings(matches) + + hash := md5.New() // #nosec + for _, m := range matches { + f, err := os.Open(m) + if err != nil { + return "", "", err + } + if _, err := io.Copy(hash, f); err != nil { + f.Close() + return "", "", err + } + f.Close() + } + + absRoot, err := filepath.Abs(root) + if err != nil { + return "", "", err + } + return hex.EncodeToString(hash.Sum(nil)), absRoot, nil +} + +// findFilesByExtensions walks root and returns every regular file whose +// extension matches one of exts. Well-known build-output, VCS and dependency +// directories are skipped so vendored/generated project files do not leak into +// the cache key. +func findFilesByExtensions(root string, exts []string) ([]string, error) { + skipDirs := map[string]bool{ + ".git": true, + ".hg": true, + ".svn": true, + "node_modules": true, + "bin": true, + "obj": true, + ".vs": true, + ".nuget": true, + } + extSet := make(map[string]bool, len(exts)) + for _, e := range exts { + extSet[strings.ToLower(e)] = true + } + + var matches []string + err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() { + if path != root && skipDirs[info.Name()] { + return filepath.SkipDir + } + return nil + } + if extSet[strings.ToLower(filepath.Ext(info.Name()))] { + matches = append(matches, path) + } + return nil + }) + if err != nil { + return nil, err + } + return matches, nil +} + func shortestPath(input []string) (shortest string) { size := len(input[0]) for _, v := range input { diff --git a/internal/plugin/autodetect/auto_detect_util_test.go b/internal/plugin/autodetect/auto_detect_util_test.go index e45f934d..91759298 100644 --- a/internal/plugin/autodetect/auto_detect_util_test.go +++ b/internal/plugin/autodetect/auto_detect_util_test.go @@ -165,6 +165,67 @@ func TestDetectDirectoriesToCacheDotnetWithEnvVar(t *testing.T) { test.Equals(t, buildToolsDetected, expectedDetectedTool) } +func TestDetectDirectoriesToCacheDotnetMultiProject(t *testing.T) { + origEnv := os.Getenv("NUGET_PACKAGES") + os.Unsetenv("NUGET_PACKAGES") + defer os.Setenv("NUGET_PACKAGES", origEnv) + + // Root project + rootProj := "App.csproj" + f1, err := os.Create(rootProj) + test.Ok(t, err) + defer f1.Close() + _, err = f1.WriteString(testFileContent) + test.Ok(t, err) + + // Second project in a nested src/Lib directory + nested := filepath.Join("src", "Lib") + test.Ok(t, os.MkdirAll(nested, 0755)) + libProj := filepath.Join(nested, "Lib.csproj") + f2, err := os.Create(libProj) + test.Ok(t, err) + defer f2.Close() + _, err = f2.WriteString(testFileContent2) + test.Ok(t, err) + + // Third project (F#) deeper down to confirm recursive walk + deep := filepath.Join("src", "F", "Sharp") + test.Ok(t, os.MkdirAll(deep, 0755)) + fsProj := filepath.Join(deep, "Fs.fsproj") + f3, err := os.Create(fsProj) + test.Ok(t, err) + defer f3.Close() + _, err = f3.WriteString(testFileContent) + test.Ok(t, err) + + // A .csproj inside bin/ must be ignored + test.Ok(t, os.MkdirAll("bin", 0755)) + ignored := filepath.Join("bin", "Artifact.csproj") + f4, err := os.Create(ignored) + test.Ok(t, err) + defer f4.Close() + _, err = f4.WriteString(testFileContent) + test.Ok(t, err) + + directoriesToCache, buildToolsDetected, hashes, err := DetectDirectoriesToCache(false) + test.Ok(t, err) + + // cleanup + test.Ok(t, os.RemoveAll(rootProj)) + test.Ok(t, os.RemoveAll("src")) + test.Ok(t, os.RemoveAll("bin")) + test.Ok(t, os.RemoveAll("nuget.config")) + + path, _ := filepath.Abs(".nuget/packages") + test.Equals(t, []string{path}, directoriesToCache) + test.Equals(t, []string{"dotnet"}, buildToolsDetected) + + // Hash must differ from the single-file hash of the root project, proving + // all project files contribute to the cache key. + test.Assert(t, hashes != "" && hashes != "baab6c16d9143523b7865d46896e4596", + "hash should aggregate all project files, got %q", hashes) +} + func TestDetectDirectoriesToCacheCombined(t *testing.T) { f, err := os.Create(bazelBuildFile) test.Ok(t, err)