From 5ce094f64b379f185b765abbade8c482778a37c5 Mon Sep 17 00:00:00 2001 From: Maxime Boucher Date: Mon, 13 Jul 2026 20:18:38 -0700 Subject: [PATCH] test: cover optimized and fallback source globs --- internal/fingerprint/glob_test.go | 215 ++++++++++++++++++ internal/fingerprint/sources_checksum_test.go | 137 +++++++++++ internal/fsext/glob_test.go | 31 +++ 3 files changed, 383 insertions(+) create mode 100644 internal/fingerprint/glob_test.go diff --git a/internal/fingerprint/glob_test.go b/internal/fingerprint/glob_test.go new file mode 100644 index 0000000000..b4c287cd2d --- /dev/null +++ b/internal/fingerprint/glob_test.go @@ -0,0 +1,215 @@ +package fingerprint + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/internal/filepathext" + "github.com/go-task/task/v3/internal/fsext" + "github.com/go-task/task/v3/taskfile/ast" +) + +type routedGlob struct { + glob *ast.Glob + fast bool +} + +func TestGlobsRecursiveIncludesAllFilesAndHonorsExclusions(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + files := []string{ + "input tree/folder-a/.hidden-file-a.aaa", + "input tree/folder-a/file-b.bbb", + "input tree/folder-a/folder-b/.hidden-file-c.ccc", + "input tree/folder-a/folder-b/file-d.ddd", + "input tree/folder-a/folder-c/.hidden-folder-a/file-e.eee", + "input tree/folder-a/folder-c/folder-d/.hidden-file-f.fff", + "input tree/folder-a/folder-c/folder-d/file-g.ggg", + "input tree/folder-a/excluded-file-a.xxx", + "input tree/folder-a/excluded-folder-a/.hidden-file-b.yyy", + "input tree/folder-a/excluded-folder-a/.hidden-folder-b/file-c.zzz", + "input tree/folder-a/excluded-folder-a/file-d.www", + "input tree/folder-a/excluded-folder-a/folder-b/file-e.vvv", + } + writeGlobFiles(t, dir, files) + + sourceRoot := filepath.Join("input tree", "folder-a") + tests := []struct { + name string + globs []routedGlob + }{ + { + name: "optimized inclusion and exclusion", + globs: []routedGlob{ + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "**", "*")}, fast: true}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-file-a.xxx"), Negate: true}, fast: false}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-folder-a", "**", "*"), Negate: true}, fast: true}, + }, + }, + { + name: "optimized inclusion and fallback exclusion", + globs: []routedGlob{ + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "**", "*")}, fast: true}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-file-a.xxx"), Negate: true}, fast: false}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "**", "excluded-folder-a", "**", "*"), Negate: true}, fast: false}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := Globs(dir, requireGlobRoutes(t, dir, tt.globs), false) + require.NoError(t, err) + require.Equal(t, globPaths(dir, files[:7]), got) + }) + } +} + +func TestGlobsFallbackPatternsIncludeHiddenEntries(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + files := []string{ + "input tree/folder-a/.hidden-file-a.aaa", + "input tree/folder-a/file-a.aaa", + "input tree/folder-a/file-a.ccc", + "input tree/folder-a/file-a.bbb", + "input tree/folder-a/nested/.hidden-file-b.bbb", + "input tree/folder-a/nested/file-b.aaa", + "input tree/folder-a/nested/deeper-folder-a/file-c.aaa", + "input tree/folder-a/.hidden-folder-a/file-d.aaa", + "input tree/folder-a/.hidden-folder-a/nested/.hidden-file-e.aaa", + "input tree/folder-a/.hidden-folder-a/folder-b/file-f.bbb", + "input tree/folder-a/folder-b/.hidden-file-g.bbb", + "input tree/folder-a/folder-b/file-g.aaa", + "input tree/folder-a/folder-b/deeper-folder-b/file-h.aaa", + "input tree/.hidden-folder-b/.hidden-root-file-a.aaa", + "input tree/.hidden-folder-b/nested/file-i.bbb", + "input tree/.hidden-folder-b/folder-b/file-j.aaa", + "input tree/folder-c/file-k.ccc", + "input tree/folder-c/nested/file-k.aaa", + "input tree/folder-c/folder-b/file-l.aaa", + } + writeGlobFiles(t, dir, files) + + tests := []struct { + name string + pattern string + expected []string + }{ + { + name: "nonrecursive", + pattern: filepath.Join("input tree", "folder-a", "*"), + expected: []string{ + "input tree/folder-a/.hidden-file-a.aaa", + "input tree/folder-a/file-a.aaa", + "input tree/folder-a/file-a.ccc", + "input tree/folder-a/file-a.bbb", + }, + }, + { + name: "wildcard root", + pattern: filepath.Join("input tree", "*", "**", "*"), + expected: files, + }, + { + name: "nested suffix", + pattern: filepath.Join("input tree", "**", "nested", "*"), + expected: []string{ + "input tree/folder-a/nested/.hidden-file-b.bbb", + "input tree/folder-a/nested/file-b.aaa", + "input tree/folder-a/.hidden-folder-a/nested/.hidden-file-e.aaa", + "input tree/.hidden-folder-b/nested/file-i.bbb", + "input tree/folder-c/nested/file-k.aaa", + }, + }, + { + name: "brace suffix", + pattern: filepath.Join("input tree", "**", "*.{aaa,bbb}"), + expected: []string{ + "input tree/folder-a/.hidden-file-a.aaa", + "input tree/folder-a/file-a.aaa", + "input tree/folder-a/file-a.bbb", + "input tree/folder-a/nested/.hidden-file-b.bbb", + "input tree/folder-a/nested/file-b.aaa", + "input tree/folder-a/nested/deeper-folder-a/file-c.aaa", + "input tree/folder-a/.hidden-folder-a/file-d.aaa", + "input tree/folder-a/.hidden-folder-a/nested/.hidden-file-e.aaa", + "input tree/folder-a/.hidden-folder-a/folder-b/file-f.bbb", + "input tree/folder-a/folder-b/.hidden-file-g.bbb", + "input tree/folder-a/folder-b/file-g.aaa", + "input tree/folder-a/folder-b/deeper-folder-b/file-h.aaa", + "input tree/.hidden-folder-b/.hidden-root-file-a.aaa", + "input tree/.hidden-folder-b/nested/file-i.bbb", + "input tree/.hidden-folder-b/folder-b/file-j.aaa", + "input tree/folder-c/nested/file-k.aaa", + "input tree/folder-c/folder-b/file-l.aaa", + }, + }, + { + name: "multiple recursive markers", + pattern: filepath.Join("input tree", "**", "folder-b", "**", "*"), + expected: []string{ + "input tree/folder-a/.hidden-folder-a/folder-b/file-f.bbb", + "input tree/folder-a/folder-b/.hidden-file-g.bbb", + "input tree/folder-a/folder-b/file-g.aaa", + "input tree/folder-a/folder-b/deeper-folder-b/file-h.aaa", + "input tree/.hidden-folder-b/folder-b/file-j.aaa", + "input tree/folder-c/folder-b/file-l.aaa", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + globs := requireGlobRoutes(t, dir, []routedGlob{{ + glob: &ast.Glob{Glob: tt.pattern}, + fast: false, + }}) + got, err := Globs(dir, globs, false) + require.NoError(t, err) + require.Equal(t, globPaths(dir, tt.expected), got) + }) + } +} + +func requireGlobRoutes(t *testing.T, dir string, globs []routedGlob) []*ast.Glob { + t.Helper() + + patterns := make([]*ast.Glob, 0, len(globs)) + for _, routed := range globs { + _, fast, err := fsext.FastRecursiveGlob(filepathext.SmartJoin(dir, routed.glob.Glob)) + require.NoError(t, err) + require.Equal(t, routed.fast, fast, "glob route for %q", routed.glob.Glob) + patterns = append(patterns, routed.glob) + } + return patterns +} + +func writeGlobFiles(t *testing.T, dir string, files []string) { + t.Helper() + + for _, file := range files { + path := filepath.Join(dir, filepath.FromSlash(file)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(file), 0o600)) + } +} + +func globPaths(dir string, files []string) []string { + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, filepath.ToSlash(filepath.Join(dir, filepath.FromSlash(file)))) + } + sort.Strings(paths) + return paths +} diff --git a/internal/fingerprint/sources_checksum_test.go b/internal/fingerprint/sources_checksum_test.go index a8e4b253ae..86e06c2ee8 100644 --- a/internal/fingerprint/sources_checksum_test.go +++ b/internal/fingerprint/sources_checksum_test.go @@ -1,11 +1,148 @@ package fingerprint import ( + "fmt" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/taskfile/ast" ) +func TestChecksumCheckerRecursiveSourcesAndRequiredGenerates(t *testing.T) { + t.Parallel() + + sourceRoot := filepath.Join("input tree", "folder-a") + outputRoot := filepath.Join("output tree", "folder-a") + includedSources := []string{ + filepath.Join(sourceRoot, ".hidden-file-a.aaa"), + filepath.Join(sourceRoot, ".hidden-file-b.bbb"), + filepath.Join(sourceRoot, "file-c.ccc"), + filepath.Join(sourceRoot, "file-d.ddd"), + filepath.Join(sourceRoot, ".hidden-folder-a", "file-e.eee"), + filepath.Join(sourceRoot, "folder-b", "folder-c", "file-f.fff"), + filepath.Join(sourceRoot, "folder-b", "folder-c", "file-g.ggg"), + filepath.Join(sourceRoot, "folder-b", "folder-c", "file-h.hhh"), + filepath.Join(sourceRoot, "folder-b", "folder-c", "file-i.iii"), + filepath.Join(sourceRoot, "folder-b", "folder-c", "folder-d", ".hidden-file-j.jjj"), + filepath.Join(sourceRoot, "folder-b", "folder-e", "file-k.kkk"), + } + excludedSources := []string{ + filepath.Join(sourceRoot, "excluded-file-a.xxx"), + filepath.Join(sourceRoot, "excluded-folder-a", "file-b.yyy"), + filepath.Join(sourceRoot, "excluded-folder-a", "folder-b", ".hidden-file-c.zzz"), + filepath.Join(sourceRoot, "excluded-folder-a", ".hidden-folder-b", "file-d.www"), + } + generatedOutputs := []string{ + filepath.Join(outputRoot, ".hidden-file-a.aaa"), + filepath.Join(outputRoot, "folder-b", "file-b.bbb"), + filepath.Join(outputRoot, "folder-b", "file-c.ccc"), + filepath.Join(outputRoot, "folder-b", "file-d.ddd"), + filepath.Join(outputRoot, "folder-b", "file-e.eee"), + filepath.Join(outputRoot, "folder-b", "file-f.fff"), + filepath.Join(outputRoot, "folder-b", "file-g.ggg"), + filepath.Join(outputRoot, "folder-b", "file-h.hhh"), + filepath.Join(outputRoot, "folder-b", "folder-c", "folder-d", "file-i.iii"), + } + + tests := []struct { + name string + sources []routedGlob + }{ + { + name: "optimized inclusion and exclusion", + sources: []routedGlob{ + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "**", "*")}, fast: true}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-file-a.xxx"), Negate: true}, fast: false}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-folder-a", "**", "*"), Negate: true}, fast: true}, + }, + }, + { + name: "fallback inclusion", + sources: []routedGlob{ + {glob: &ast.Glob{Glob: filepath.Join("input tree", "*", "**", "*")}, fast: false}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-file-a.xxx"), Negate: true}, fast: false}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-folder-a", "**", "*"), Negate: true}, fast: true}, + }, + }, + { + name: "fallback exclusion", + sources: []routedGlob{ + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "**", "*")}, fast: true}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "excluded-file-a.xxx"), Negate: true}, fast: false}, + {glob: &ast.Glob{Glob: filepath.Join(sourceRoot, "**", "excluded-folder-a", "**", "*"), Negate: true}, fast: false}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rootDir := t.TempDir() + writeFile := func(relativePath, contents string) { + t.Helper() + path := filepath.Join(rootDir, relativePath) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + } + for i, source := range includedSources { + writeFile(source, fmt.Sprintf("included source %d\n", i)) + } + for i, source := range excludedSources { + writeFile(source, fmt.Sprintf("excluded source %d\n", i)) + } + for i, output := range generatedOutputs { + writeFile(output, fmt.Sprintf("generated output %d\n", i)) + } + + generates := make([]*ast.Glob, 0, len(generatedOutputs)) + for _, output := range generatedOutputs { + generates = append(generates, &ast.Glob{Glob: output}) + } + task := &ast.Task{ + Task: "cache-" + tt.name, + Dir: rootDir, + Sources: requireGlobRoutes(t, rootDir, tt.sources), + Generates: generates, + } + checker := NewChecksumChecker(filepath.Join(rootDir, ".task-cache"), false) + isUpToDate := func() bool { + t.Helper() + upToDate, err := checker.IsUpToDate(task) + require.NoError(t, err) + return upToDate + } + + require.False(t, isUpToDate(), "the first check should prime the source checksum") + require.True(t, isUpToDate(), "an unchanged task should be cached") + + for i, source := range includedSources { + writeFile(source, fmt.Sprintf("included source %d mutation\n", i)) + assert.False(t, isUpToDate(), "mutating included source %q should invalidate the cache", source) + require.True(t, isUpToDate(), "the updated checksum for included source %q should be cached", source) + } + + for i, source := range excludedSources { + writeFile(source, fmt.Sprintf("excluded source %d mutation\n", i)) + assert.True(t, isUpToDate(), "mutating excluded source %q should preserve the cache", source) + require.True(t, isUpToDate(), "the current checksum for excluded source %q should be cached", source) + } + + for i, output := range generatedOutputs { + path := filepath.Join(rootDir, output) + require.NoError(t, os.Remove(path)) + require.False(t, isUpToDate(), "missing required output %q should invalidate the cache", output) + writeFile(output, fmt.Sprintf("generated output %d\n", i)) + require.True(t, isUpToDate(), "restoring required output %q should restore the cache", output) + } + }) + } +} + func TestNormalizeFilename(t *testing.T) { t.Parallel() diff --git a/internal/fsext/glob_test.go b/internal/fsext/glob_test.go index 17ed58e0d3..73122255ba 100644 --- a/internal/fsext/glob_test.go +++ b/internal/fsext/glob_test.go @@ -3,11 +3,42 @@ package fsext import ( "os" "path/filepath" + "sort" "testing" "github.com/stretchr/testify/require" ) +func TestFastRecursiveGlobAllFilesIncludesHiddenEntries(t *testing.T) { + t.Parallel() + + root := filepath.Join(t.TempDir(), "root with spaces") + files := []string{ + ".hidden-file-a.aaa", + "file-a.bbb", + "folder-a/.hidden-file-b.ccc", + "folder-a/file-b.ddd", + ".hidden-folder-a/file-c.eee", + ".hidden-folder-a/deep-folder-a/.hidden-file-c.fff", + } + for _, file := range files { + path := filepath.Join(root, filepath.FromSlash(file)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, nil, 0o600)) + } + + got, ok, err := FastRecursiveGlob(filepath.Join(root, "**", "*")) + require.NoError(t, err) + require.True(t, ok) + + expected := make([]string, 0, len(files)) + for _, file := range files { + expected = append(expected, filepath.ToSlash(filepath.Join(root, filepath.FromSlash(file)))) + } + sort.Strings(expected) + require.Equal(t, expected, got) +} + func TestFastRecursiveGlob(t *testing.T) { t.Parallel()