Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 101 additions & 18 deletions internal/plugin/autodetect/auto_detect_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"},
},
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions internal/plugin/autodetect/auto_detect_util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down