diff --git a/go.mod b/go.mod index 16627cbb..ab1a4da3 100644 --- a/go.mod +++ b/go.mod @@ -92,7 +92,7 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/klauspost/compress v1.18.5 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -121,6 +121,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.51.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.46.0 // indirect diff --git a/go.sum b/go.sum index c17742d6..455d80af 100644 --- a/go.sum +++ b/go.sum @@ -191,8 +191,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -284,8 +284,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/internal/fingerprint/gitignore.go b/internal/fingerprint/gitignore.go new file mode 100644 index 00000000..1b89349e --- /dev/null +++ b/internal/fingerprint/gitignore.go @@ -0,0 +1,211 @@ +package fingerprint + +import ( + "bufio" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/go-task/task/v3/internal/gitignore" +) + +type linesCacheEntry struct { + mtime time.Time + lines []string +} + +var ( + gitignoreLinesCache sync.Map // dir -> linesCacheEntry, invalidated by mtime + repoRootCache sync.Map // dir -> repo root (or "" when not in a repo) +) + +// findRepoRoot returns the first ancestor of dir containing a .git entry, or +// ("", false) when dir is not inside a git repository. +func findRepoRoot(dir string) (string, bool) { + if v, ok := repoRootCache.Load(dir); ok { + root := v.(string) + return root, root != "" + } + + current := dir + for { + if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { + repoRootCache.Store(dir, current) + return current, true + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + + repoRootCache.Store(dir, "") + return "", false +} + +// filterGitignored marks entries matching gitignore rules as excluded (false). +// All .gitignore files from the repo root down to each candidate file's +// directory feed a single matcher so that precedence and cross-file negations +// (`!pattern`) resolve correctly. +func filterGitignored(files map[string]bool, dir string) map[string]bool { + if len(files) == 0 { + return files + } + + absDir, err := filepath.Abs(dir) + if err != nil { + return files + } + repoRoot, ok := findRepoRoot(absDir) + if !ok { + return files + } + + // Every directory from the repo root down to each candidate file's dir, so + // nested .gitignore files reached by deep globs are included too. + dirSet := make(map[string]struct{}) + for path, included := range files { + if !included { + continue + } + absPath, err := filepath.Abs(path) + if err != nil { + continue + } + d := filepath.Dir(absPath) + if !withinRepo(repoRoot, d) { + continue + } + for { + dirSet[d] = struct{}{} + if d == repoRoot { + break + } + parent := filepath.Dir(d) + if parent == d { + break + } + d = parent + } + } + + // Shallow dirs first (lower priority): the matcher scans patterns last to + // first, so deeper rules win and can negate shallower ones. + dirs := make([]string, 0, len(dirSet)) + for d := range dirSet { + dirs = append(dirs, d) + } + sort.Slice(dirs, func(i, j int) bool { + di := strings.Count(dirs[i], string(filepath.Separator)) + dj := strings.Count(dirs[j], string(filepath.Separator)) + if di != dj { + return di < dj + } + return dirs[i] < dirs[j] + }) + + var patterns []gitignore.Pattern + for _, d := range dirs { + lines := readGitignoreLines(d) + if len(lines) == 0 { + continue + } + // domain scopes each pattern to its .gitignore subtree (go-git semantics). + var domain []string + if rel, err := filepath.Rel(repoRoot, d); err == nil && rel != "." { + domain = strings.Split(filepath.ToSlash(rel), "/") + } + for _, line := range lines { + patterns = append(patterns, gitignore.ParsePattern(line, domain)) + } + } + if len(patterns) == 0 { + return files + } + + matcher := gitignore.NewMatcher(patterns) + for path, included := range files { + if !included { + continue + } + absPath, err := filepath.Abs(path) + if err != nil { + continue + } + relPath, err := filepath.Rel(repoRoot, absPath) + if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) { + continue + } + if ignored(matcher, strings.Split(filepath.ToSlash(relPath), "/")) { + files[path] = false + } + } + + return files +} + +// ignored honors Git's rule that a file under an ignored directory cannot be +// re-included by a deeper negation: if any ancestor directory is ignored, so is +// the file. Otherwise the file's own verdict applies (isDir=false). +func ignored(matcher gitignore.Matcher, segments []string) bool { + for i := 1; i < len(segments); i++ { + if matcher.Match(segments[:i], true) { + return true + } + } + return matcher.Match(segments, false) +} + +func withinRepo(repoRoot, p string) bool { + rel, err := filepath.Rel(repoRoot, p) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// readGitignoreLines returns the .gitignore lines in dir (nil if none), cached +// per directory and invalidated by mtime so watch mode picks up edits. +func readGitignoreLines(dir string) []string { + path := filepath.Join(dir, ".gitignore") + info, err := os.Stat(path) + if err != nil { + return nil + } + mtime := info.ModTime() + + if v, ok := gitignoreLinesCache.Load(dir); ok { + entry := v.(linesCacheEntry) + if entry.mtime.Equal(mtime) { + return entry.lines + } + } + + lines := parseGitignoreLines(path) + gitignoreLinesCache.Store(dir, linesCacheEntry{mtime: mtime, lines: lines}) + return lines +} + +func parseGitignoreLines(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimRight(scanner.Text(), "\r") + if line != "" && !strings.HasPrefix(line, "#") { + lines = append(lines, line) + } + } + // On a scan error (e.g. an over-long line) keep what was parsed rather than + // dropping the whole file. + return lines +} diff --git a/internal/fingerprint/gitignore_test.go b/internal/fingerprint/gitignore_test.go new file mode 100644 index 00000000..507b34e8 --- /dev/null +++ b/internal/fingerprint/gitignore_test.go @@ -0,0 +1,254 @@ +package fingerprint + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) +} + +func TestGlobsWithGitignore(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "included.txt"), []byte("included"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("ignored"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "also-included.txt"), []byte("also included"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./*"}, + } + + filesWithout, err := Globs(dir, globs, false) + require.NoError(t, err) + + filesWith, err := Globs(dir, globs, true) + require.NoError(t, err) + + hasLog := false + for _, f := range filesWithout { + if filepath.Base(f) == "ignored.log" { + hasLog = true + break + } + } + assert.True(t, hasLog, "ignored.log should be present without gitignore filter") + + hasLog = false + for _, f := range filesWith { + if filepath.Base(f) == "ignored.log" { + hasLog = true + break + } + } + assert.False(t, hasLog, "ignored.log should be excluded with gitignore filter") + + txtCount := 0 + for _, f := range filesWith { + if filepath.Ext(f) == ".txt" { + txtCount++ + } + } + assert.Equal(t, 2, txtCount, "both .txt files should remain") +} + +func TestGlobsWithGitignoreParentDirIgnored(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + buildDir := filepath.Join(dir, "build") + require.NoError(t, os.MkdirAll(buildDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(buildDir, "keep.txt"), []byte("keep"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(buildDir, "other.txt"), []byte("other"), 0o644)) + + // Git cannot re-include a file under an ignored directory. + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("build/\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(buildDir, ".gitignore"), []byte("!keep.txt\n"), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./**/*"}, + } + + files, err := Globs(dir, globs, true) + require.NoError(t, err) + + for _, f := range files { + base := filepath.Base(f) + assert.NotEqual(t, "keep.txt", base, "keep.txt must stay excluded under ignored build/") + assert.NotEqual(t, "other.txt", base, "other.txt must stay excluded under ignored build/") + } +} + +func TestGlobsWithGitignoreNested(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + subDir := filepath.Join(dir, "sub") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + + require.NoError(t, os.WriteFile(filepath.Join(subDir, "keep.txt"), []byte("keep"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, "build.out"), []byte("build"), 0o644)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, ".gitignore"), []byte("*.out\n"), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./*"}, + } + + files, err := Globs(subDir, globs, true) + require.NoError(t, err) + + for _, f := range files { + assert.NotEqual(t, "build.out", filepath.Base(f), "build.out should be excluded by nested .gitignore") + } +} + +func TestGlobsWithGitignoreCrossFileNegation(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + subDir := filepath.Join(dir, "sub") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + + require.NoError(t, os.WriteFile(filepath.Join(subDir, "debug.log"), []byte("debug"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, "other.log"), []byte("other"), 0o644)) + + // Root ignores all *.log; a nested .gitignore re-includes debug.log. + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, ".gitignore"), []byte("!debug.log\n"), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./*"}, + } + + files, err := Globs(subDir, globs, true) + require.NoError(t, err) + + hasDebug, hasOther := false, false + for _, f := range files { + switch filepath.Base(f) { + case "debug.log": + hasDebug = true + case "other.log": + hasOther = true + } + } + assert.True(t, hasDebug, "debug.log should be re-included by the nested negation") + assert.False(t, hasOther, "other.log should remain excluded by the root *.log rule") +} + +func TestGlobsWithGitignoreDeepGlob(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + subDir := filepath.Join(dir, "sub") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + + require.NoError(t, os.WriteFile(filepath.Join(subDir, "keep.txt"), []byte("keep"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, "gen.out"), []byte("gen"), 0o644)) + + require.NoError(t, os.WriteFile(filepath.Join(subDir, ".gitignore"), []byte("*.out\n"), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./**/*"}, + } + + files, err := Globs(dir, globs, true) + require.NoError(t, err) + + for _, f := range files { + assert.NotEqual(t, "gen.out", filepath.Base(f), "gen.out should be excluded by the nested .gitignore reached via deep glob") + } +} + +func TestGlobsWithGitignoreDoubleDotFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + // A ".."-prefixed name must not be skipped by the out-of-tree guard. + require.NoError(t, os.WriteFile(filepath.Join(dir, "..keep.log"), []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("y"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./*"}, + } + + files, err := Globs(dir, globs, true) + require.NoError(t, err) + + for _, f := range files { + assert.NotEqual(t, "..keep.log", filepath.Base(f), "..keep.log should be excluded by *.log") + } +} + +func TestGlobsWithGitignoreLongLine(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + initGitRepo(t, dir) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("y"), 0o644)) + + // A line over bufio.Scanner's 64KB limit triggers a scan error; patterns + // parsed before it must survive. + longLine := strings.Repeat("a", 70*1024) + content := "*.log\n" + longLine + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./*"}, + } + + files, err := Globs(dir, globs, true) + require.NoError(t, err) + + for _, f := range files { + assert.NotEqual(t, "ignored.log", filepath.Base(f), "*.log parsed before the long line should still apply") + } +} + +func TestGlobsWithGitignoreNoRepo(t *testing.T) { + t.Parallel() + + // Cannot use t.TempDir() here because it creates a dir inside the + // go-task repo which has a .git parent, defeating the "no repo" test. + dir, err := os.MkdirTemp("", "task-gitignore-norepo-*") //nolint:usetesting + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(dir) }) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644)) + + globs := []*ast.Glob{ + {Glob: "./*"}, + } + + files, err := Globs(dir, globs, true) + require.NoError(t, err) + assert.Len(t, files, 1) +} diff --git a/internal/fingerprint/glob.go b/internal/fingerprint/glob.go index fd3cafce..930a54a3 100644 --- a/internal/fingerprint/glob.go +++ b/internal/fingerprint/glob.go @@ -10,7 +10,7 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) -func Globs(dir string, globs []*ast.Glob) ([]string, error) { +func Globs(dir string, globs []*ast.Glob, useGitignore bool) ([]string, error) { resultMap := make(map[string]bool) for _, g := range globs { matches, err := glob(dir, g.Glob) @@ -21,6 +21,11 @@ func Globs(dir string, globs []*ast.Glob) ([]string, error) { resultMap[match] = !g.Negate } } + + if useGitignore { + resultMap = filterGitignored(resultMap, dir) + } + return collectKeys(resultMap), nil } diff --git a/internal/fingerprint/sources_checksum.go b/internal/fingerprint/sources_checksum.go index 3afd7b0a..3d341369 100644 --- a/internal/fingerprint/sources_checksum.go +++ b/internal/fingerprint/sources_checksum.go @@ -89,7 +89,7 @@ func (*ChecksumChecker) Kind() string { } func (c *ChecksumChecker) checksum(t *ast.Task) (string, error) { - sources, err := Globs(t.Dir, t.Sources) + sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore()) if err != nil { return "", err } diff --git a/internal/fingerprint/sources_timestamp.go b/internal/fingerprint/sources_timestamp.go index 258d9386..929b044b 100644 --- a/internal/fingerprint/sources_timestamp.go +++ b/internal/fingerprint/sources_timestamp.go @@ -28,7 +28,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) { return false, nil } - sources, err := Globs(t.Dir, t.Sources) + sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore()) if err != nil { return false, nil } @@ -54,7 +54,7 @@ func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) { } } - generates, err := Globs(t.Dir, t.Generates) + generates, err := Globs(t.Dir, t.Generates, t.ShouldUseGitignore()) if err != nil { return false, nil } @@ -112,7 +112,7 @@ func (checker *TimestampChecker) Kind() string { // Value implements the Checker Interface func (checker *TimestampChecker) Value(t *ast.Task) (any, error) { - sources, err := Globs(t.Dir, t.Sources) + sources, err := Globs(t.Dir, t.Sources, t.ShouldUseGitignore()) if err != nil { return time.Now(), err } diff --git a/internal/gitignore/LICENSE b/internal/gitignore/LICENSE new file mode 100644 index 00000000..8aa3d854 --- /dev/null +++ b/internal/gitignore/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 Sourced Technologies, S.L. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/internal/gitignore/matcher.go b/internal/gitignore/matcher.go new file mode 100644 index 00000000..4110502b --- /dev/null +++ b/internal/gitignore/matcher.go @@ -0,0 +1,34 @@ +// Vendored from go-git: github.com/go-git/go-git/v5 v5.19.1, +// plumbing/format/gitignore/matcher.go. Licensed under the Apache License 2.0; +// see the LICENSE file in this directory. + +package gitignore + +// Matcher defines a global multi-pattern matcher for gitignore patterns +type Matcher interface { + // Match matches patterns in the order of priorities. As soon as an inclusion or + // exclusion is found, not further matching is performed. + Match(path []string, isDir bool) bool +} + +// NewMatcher constructs a new global matcher. Patterns must be given in the order of +// increasing priority. That is most generic settings files first, then the content of +// the repo .gitignore, then content of .gitignore down the path or the repo and then +// the content command line arguments. +func NewMatcher(ps []Pattern) Matcher { + return &matcher{ps} +} + +type matcher struct { + patterns []Pattern +} + +func (m *matcher) Match(path []string, isDir bool) bool { + n := len(m.patterns) + for i := n - 1; i >= 0; i-- { + if match := m.patterns[i].Match(path, isDir); match > NoMatch { + return match == Exclude + } + } + return false +} diff --git a/internal/gitignore/matcher_test.go b/internal/gitignore/matcher_test.go new file mode 100644 index 00000000..ce0dc75d --- /dev/null +++ b/internal/gitignore/matcher_test.go @@ -0,0 +1,23 @@ +// Test cases ported from go-git: github.com/go-git/go-git/v5 v5.19.1, +// plumbing/format/gitignore/matcher_test.go. Licensed under the Apache +// License 2.0; see LICENSE. + +package gitignore + +import "testing" + +func TestMatcher_Match(t *testing.T) { + t.Parallel() + + m := NewMatcher([]Pattern{ + ParsePattern("**/middle/v[uo]l?ano", nil), + ParsePattern("!volcano", nil), + }) + + if got := m.Match([]string{"head", "middle", "vulkano"}, false); got != true { + t.Errorf("Match(vulkano) = %t, want true", got) + } + if got := m.Match([]string{"head", "middle", "volcano"}, false); got != false { + t.Errorf("Match(volcano) = %t, want false (negated)", got) + } +} diff --git a/internal/gitignore/pattern.go b/internal/gitignore/pattern.go new file mode 100644 index 00000000..a7a51ea0 --- /dev/null +++ b/internal/gitignore/pattern.go @@ -0,0 +1,165 @@ +// Package gitignore implements gitignore pattern matching. +// +// This package is vendored from go-git: +// +// github.com/go-git/go-git/v5 v5.19.1, plumbing/format/gitignore +// +// Only the pattern parsing and matching logic (pattern.go and matcher.go) is +// copied; the file-walking helpers (dir.go) are omitted as they pull in +// go-billy and other go-git internals. The original code is licensed under the +// Apache License 2.0; see the LICENSE file in this directory. +package gitignore + +import ( + "path/filepath" + "strings" +) + +// MatchResult defines outcomes of a match, no match, exclusion or inclusion. +type MatchResult int + +const ( + // NoMatch defines the no match outcome of a match check + NoMatch MatchResult = iota + // Exclude defines an exclusion of a file as a result of a match check + Exclude + // Include defines an explicit inclusion of a file as a result of a match check + Include +) + +const ( + inclusionPrefix = "!" + zeroToManyDirs = "**" + patternDirSep = "/" +) + +// Pattern defines a single gitignore pattern. +type Pattern interface { + // Match matches the given path to the pattern. + Match(path []string, isDir bool) MatchResult +} + +type pattern struct { + domain []string + pattern []string + inclusion bool + dirOnly bool + isGlob bool +} + +// ParsePattern parses a gitignore pattern string into the Pattern structure. +func ParsePattern(p string, domain []string) Pattern { + // storing domain, copy it to ensure it isn't changed externally + domain = append([]string(nil), domain...) + res := pattern{domain: domain} + + if strings.HasPrefix(p, inclusionPrefix) { + res.inclusion = true + p = p[1:] + } + + if !strings.HasSuffix(p, "\\ ") { + p = strings.TrimRight(p, " ") + } + + if strings.HasSuffix(p, patternDirSep) { + res.dirOnly = true + p = p[:len(p)-1] + } + + if strings.Contains(p, patternDirSep) { + res.isGlob = true + } + + res.pattern = strings.Split(p, patternDirSep) + return &res +} + +func (p *pattern) Match(path []string, isDir bool) MatchResult { + if len(path) <= len(p.domain) { + return NoMatch + } + for i, e := range p.domain { + if path[i] != e { + return NoMatch + } + } + + path = path[len(p.domain):] + if p.isGlob && !p.globMatch(path, isDir) { + return NoMatch + } else if !p.isGlob && !p.simpleNameMatch(path, isDir) { + return NoMatch + } + + if p.inclusion { + return Include + } else { + return Exclude + } +} + +func (p *pattern) simpleNameMatch(path []string, isDir bool) bool { + for i, name := range path { + if match, err := filepath.Match(p.pattern[0], name); err != nil { + return false + } else if !match { + continue + } + if p.dirOnly && !isDir && i == len(path)-1 { + return false + } + return true + } + return false +} + +func (p *pattern) globMatch(path []string, isDir bool) bool { + matched := false + canTraverse := false + for i, pattern := range p.pattern { + if pattern == "" { + canTraverse = false + continue + } + if pattern == zeroToManyDirs { + if i == len(p.pattern)-1 { + break + } + canTraverse = true + continue + } + if strings.Contains(pattern, zeroToManyDirs) { + return false + } + if len(path) == 0 { + return false + } + if canTraverse { + canTraverse = false + for len(path) > 0 { + e := path[0] + path = path[1:] + if match, err := filepath.Match(pattern, e); err != nil { + return false + } else if match { + matched = true + break + } else if len(path) == 0 { + // if nothing left then fail + matched = false + } + } + } else { + if match, err := filepath.Match(pattern, path[0]); err != nil || !match { + return false + } + matched = true + path = path[1:] + } + } + if matched && p.dirOnly && !isDir && len(path) == 0 { + matched = false + } + return matched +} diff --git a/internal/gitignore/pattern_test.go b/internal/gitignore/pattern_test.go new file mode 100644 index 00000000..a0168728 --- /dev/null +++ b/internal/gitignore/pattern_test.go @@ -0,0 +1,80 @@ +// Test cases ported from go-git: github.com/go-git/go-git/v5 v5.19.1, +// plumbing/format/gitignore/pattern_test.go (originally written against +// gopkg.in/check.v1; rewritten here as table-driven stdlib tests to avoid an +// extra test dependency). Licensed under the Apache License 2.0; see LICENSE. + +package gitignore + +import "testing" + +func TestParsePattern_Match(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pattern string + domain []string + path []string + isDir bool + want MatchResult + }{ + {"inclusion", "!vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Include}, + {"domainLonger_mismatch", "value", []string{"head", "middle", "tail"}, []string{"head", "middle"}, false, NoMatch}, + {"domainSameLength_mismatch", "value", []string{"head", "middle", "tail"}, []string{"head", "middle", "tail"}, false, NoMatch}, + {"domainMismatch_mismatch", "value", []string{"head", "middle", "tail"}, []string{"head", "middle", "_tail_", "value"}, false, NoMatch}, + {"withDomain", "middle/", []string{"value", "volcano"}, []string{"value", "volcano", "middle", "tail"}, false, Exclude}, + {"onlyMatchInDomain_mismatch", "volcano/", []string{"value", "volcano"}, []string{"value", "volcano", "tail"}, true, NoMatch}, + {"atStart", "value", nil, []string{"value", "tail"}, false, Exclude}, + {"inTheMiddle", "value", nil, []string{"head", "value", "tail"}, false, Exclude}, + {"atEnd", "value", nil, []string{"head", "value"}, false, Exclude}, + {"atStart_dirWanted", "value/", nil, []string{"value", "tail"}, false, Exclude}, + {"inTheMiddle_dirWanted", "value/", nil, []string{"head", "value", "tail"}, false, Exclude}, + {"atEnd_dirWanted", "value/", nil, []string{"head", "value"}, true, Exclude}, + {"atEnd_dirWanted_notADir_mismatch", "value/", nil, []string{"head", "value"}, false, NoMatch}, + {"mismatch", "value", nil, []string{"head", "val", "tail"}, false, NoMatch}, + {"valueLonger_mismatch", "val", nil, []string{"head", "value", "tail"}, false, NoMatch}, + {"withAsterisk", "v*o", nil, []string{"value", "vulkano", "tail"}, false, Exclude}, + {"withQuestionMark", "vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Exclude}, + {"magicChars", "v[ou]l[kc]ano", nil, []string{"value", "volcano"}, false, Exclude}, + {"wrongPattern_mismatch", "v[ou]l[", nil, []string{"value", "vol["}, false, NoMatch}, + {"glob_fromRootWithSlash", "/value/vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Exclude}, + {"glob_withDomain", "middle/tail/", []string{"value", "volcano"}, []string{"value", "volcano", "middle", "tail"}, true, Exclude}, + {"glob_onlyMatchInDomain_mismatch", "volcano/tail", []string{"value", "volcano"}, []string{"value", "volcano", "tail"}, false, NoMatch}, + {"glob_fromRootWithoutSlash", "value/vul?ano", nil, []string{"value", "vulkano", "tail"}, false, Exclude}, + {"glob_fromRoot_mismatch", "value/vulkano", nil, []string{"value", "volcano"}, false, NoMatch}, + {"glob_fromRoot_tooShort_mismatch", "value/vul?ano", nil, []string{"value"}, false, NoMatch}, + {"glob_fromRoot_notAtRoot_mismatch", "/value/volcano", nil, []string{"value", "value", "volcano"}, false, NoMatch}, + {"glob_leadingAsterisks_atStart", "**/*lue/vol?ano", nil, []string{"value", "volcano", "tail"}, false, Exclude}, + {"glob_leadingAsterisks_notAtStart", "**/*lue/vol?ano", nil, []string{"head", "value", "volcano", "tail"}, false, Exclude}, + {"glob_leadingAsterisks_mismatch", "**/*lue/vol?ano", nil, []string{"head", "value", "Volcano", "tail"}, false, NoMatch}, + {"glob_leadingAsterisks_isDir", "**/*lue/vol?ano/", nil, []string{"head", "value", "volcano", "tail"}, false, Exclude}, + {"glob_leadingAsterisks_isDirAtEnd", "**/*lue/vol?ano/", nil, []string{"head", "value", "volcano"}, true, Exclude}, + {"glob_leadingAsterisks_isDir_mismatch", "**/*lue/vol?ano/", nil, []string{"head", "value", "Colcano"}, true, NoMatch}, + {"glob_leadingAsterisks_isDirNoDirAtEnd_mismatch", "**/*lue/vol?ano/", nil, []string{"head", "value", "volcano"}, false, NoMatch}, + {"glob_tailingAsterisks", "/*lue/vol?ano/**", nil, []string{"value", "volcano", "tail", "moretail"}, false, Exclude}, + {"glob_tailingAsterisks_exactMatch", "/*lue/vol?ano/**", nil, []string{"value", "volcano"}, false, Exclude}, + {"glob_middleAsterisks_emptyMatch", "/*lue/**/vol?ano", nil, []string{"value", "volcano"}, false, Exclude}, + {"glob_middleAsterisks_oneMatch", "/*lue/**/vol?ano", nil, []string{"value", "middle", "volcano"}, false, Exclude}, + {"glob_middleAsterisks_multiMatch", "/*lue/**/vol?ano", nil, []string{"value", "middle1", "middle2", "volcano"}, false, Exclude}, + {"glob_middleAsterisks_isDir_trailing", "/*lue/**/vol?ano/", nil, []string{"value", "middle1", "middle2", "volcano"}, true, Exclude}, + {"glob_middleAsterisks_isDir_trailing_mismatch", "/*lue/**/vol?ano/", nil, []string{"value", "middle1", "middle2", "volcano"}, false, NoMatch}, + {"glob_middleAsterisks_isDir", "/*lue/**/vol?ano/", nil, []string{"value", "middle1", "middle2", "volcano", "tail"}, false, Exclude}, + {"glob_wrongDoubleAsterisk_mismatch", "/*lue/**foo/vol?ano", nil, []string{"value", "foo", "volcano", "tail"}, false, NoMatch}, + {"glob_magicChars", "**/head/v[ou]l[kc]ano", nil, []string{"value", "head", "volcano"}, false, Exclude}, + {"glob_wrongPattern_noTraversal_mismatch", "**/head/v[ou]l[", nil, []string{"value", "head", "vol["}, false, NoMatch}, + {"glob_wrongPattern_onTraversal_mismatch", "/value/**/v[ou]l[", nil, []string{"value", "head", "vol["}, false, NoMatch}, + {"glob_issue_923", "**/android/**/GeneratedPluginRegistrant.java", nil, []string{"packages", "flutter_tools", "lib", "src", "android", "gradle.dart"}, false, NoMatch}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + p := ParsePattern(tt.pattern, tt.domain) + if got := p.Match(tt.path, tt.isDir); got != tt.want { + t.Errorf("ParsePattern(%q, %v).Match(%v, %t) = %v, want %v", + tt.pattern, tt.domain, tt.path, tt.isDir, got, tt.want) + } + }) + } +} diff --git a/task_test.go b/task_test.go index 80915c2c..db2417a0 100644 --- a/task_test.go +++ b/task_test.go @@ -653,6 +653,164 @@ func TestStatusChecksumMissingGenerated(t *testing.T) { // nolint:paralleltest / require.NoError(t, err, "generated.txt should be recreated after third run") } +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, name), []byte(content), 0o644)) +} + +// gitignoreStep writes a set of files then runs the task once, capturing its +// output as a golden fixture named run. +type gitignoreStep struct { + write map[string]string + run string +} + +// gitignoreSeq drives a checksum task through a sequence of runs against a +// fixture dir. create seeds runtime files (removed on cleanup); restore resets +// tracked files to their committed content on cleanup; artifacts are +// task-produced files to delete on cleanup. +type gitignoreSeq struct { + dir string + task string + create map[string]string + restore map[string]string + artifacts []string + steps []gitignoreStep +} + +func (s gitignoreSeq) run(t *testing.T) { + t.Helper() + cleanup := func() { + _ = os.RemoveAll(filepathext.SmartJoin(s.dir, ".task")) + for name := range s.create { + _ = os.Remove(filepathext.SmartJoin(s.dir, name)) + } + for _, name := range s.artifacts { + _ = os.Remove(filepathext.SmartJoin(s.dir, name)) + } + for name, content := range s.restore { + writeFile(t, s.dir, name, content) + } + } + cleanup() + t.Cleanup(cleanup) + for name, content := range s.create { + writeFile(t, s.dir, name, content) + } + for _, step := range s.steps { + for name, content := range step.write { + writeFile(t, s.dir, name, content) + } + NewExecutorTest(t, + WithName(step.run), + WithExecutorOptions(task.WithDir(s.dir)), + WithTask(s.task), + ) + } +} + +func TestGitignoreChecksum(t *testing.T) { //nolint:paralleltest // shares testdata/gitignore and mutates fixture files + gitignoreSeq{ + dir: "testdata/gitignore", + task: "build", + create: map[string]string{"ignored.txt": "ignored\n"}, + restore: map[string]string{"source.txt": "source content\n"}, + artifacts: []string{"generated.txt"}, + steps: []gitignoreStep{ + {run: "first run"}, + {run: "up to date"}, + {run: "ignored file modified", write: map[string]string{"ignored.txt": "ignored modified\n"}}, + {run: "source file modified", write: map[string]string{"source.txt": "source modified\n"}}, + }, + }.run(t) +} + +// TestGitignoreNegation checks that a `!pattern` in a nested .gitignore +// re-includes a file excluded by a parent .gitignore. +func TestGitignoreNegation(t *testing.T) { //nolint:paralleltest // mutates fixture files + gitignoreSeq{ + dir: "testdata/gitignore_negation", + task: "build", + create: map[string]string{"sub/debug.log": "debug\n", "sub/other.log": "other\n"}, + steps: []gitignoreStep{ + {run: "first run"}, + {run: "up to date"}, + {run: "ignored file modified", write: map[string]string{"sub/other.log": "other modified\n"}}, + {run: "reincluded file modified", write: map[string]string{"sub/debug.log": "debug modified\n"}}, + }, + }.run(t) +} + +// TestGitignoreNested checks that a .gitignore in a subdirectory below the task +// dir is honored when its files are reached by a deep glob. +func TestGitignoreNested(t *testing.T) { //nolint:paralleltest // mutates fixture files + gitignoreSeq{ + dir: "testdata/gitignore_nested", + task: "build", + create: map[string]string{"sub/secret.dat": "secret\n"}, + restore: map[string]string{"sub/keep.txt": "keep\n"}, + steps: []gitignoreStep{ + {run: "first run"}, + {run: "up to date"}, + {run: "ignored file modified", write: map[string]string{"sub/secret.dat": "secret modified\n"}}, + {run: "source file modified", write: map[string]string{"sub/keep.txt": "keep modified\n"}}, + }, + }.run(t) +} + +// TestGitignoreIncluded checks that a top-level use_gitignore in an included +// Taskfile is propagated onto its tasks during merge. +func TestGitignoreIncluded(t *testing.T) { //nolint:paralleltest // mutates fixture files + gitignoreSeq{ + dir: "testdata/gitignore_included", + task: "included:build", + create: map[string]string{"ignored.txt": "ignored\n"}, + steps: []gitignoreStep{ + {run: "first run"}, + {run: "up to date"}, + {run: "ignored file modified", write: map[string]string{"ignored.txt": "ignored modified\n"}}, + }, + }.run(t) +} + +// TestGitignoreIncludedOverride checks that an explicit use_gitignore: false in +// an included Taskfile is preserved even when the root Taskfile sets it to true. +func TestGitignoreIncludedOverride(t *testing.T) { //nolint:paralleltest // mutates fixture files + gitignoreSeq{ + dir: "testdata/gitignore_included_override", + task: "included:build", + create: map[string]string{"ignored.txt": "ignored\n"}, + steps: []gitignoreStep{ + {run: "first run"}, + {run: "up to date"}, + {run: "ignored file modified", write: map[string]string{"ignored.txt": "ignored modified\n"}}, + }, + }.run(t) +} + +func TestGitignoreTaskListFallback(t *testing.T) { //nolint:paralleltest // shares testdata/gitignore with TestGitignoreChecksum + const dir = "testdata/gitignore" + + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + ) + require.NoError(t, e.Setup()) + + listed, err := e.CompiledTaskForTaskList(&task.Call{Task: "build"}) + require.NoError(t, err) + assert.True(t, listed.ShouldUseGitignore(), + "task list should reflect the global use_gitignore fallback") + + // "build-no-use_gitignore" explicitly disables it. + listedOff, err := e.CompiledTaskForTaskList(&task.Call{Task: "build-no-use_gitignore"}) + require.NoError(t, err) + assert.False(t, listedOff.ShouldUseGitignore(), + "explicit use_gitignore: false must be preserved in the list path") +} + func TestStatusVariables(t *testing.T) { t.Parallel() diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 0e989394..9465c777 100644 --- a/taskfile/ast/task.go +++ b/taskfile/ast/task.go @@ -38,6 +38,7 @@ type Task struct { Method string Prefix string `hash:"ignore"` IgnoreError bool + UseGitignore *bool Run string Platforms []*Platform If string @@ -75,6 +76,12 @@ func (t *Task) IsSilent() bool { return t.Silent != nil && *t.Silent } +// ShouldUseGitignore returns true if gitignore filtering is enabled for the task. +// Returns false if UseGitignore is nil or set to false. +func (t *Task) ShouldUseGitignore() bool { + return t.UseGitignore != nil && *t.UseGitignore +} + // WildcardMatch will check if the given string matches the name of the Task and returns any wildcard values. func (t *Task) WildcardMatch(name string) (bool, []string) { names := append([]string{t.Task}, t.Aliases...) @@ -149,7 +156,8 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { Internal bool Method string Prefix string - IgnoreError bool `yaml:"ignore_error"` + IgnoreError bool `yaml:"ignore_error"` + UseGitignore *bool `yaml:"use_gitignore,omitempty"` Run string Platforms []*Platform If string @@ -190,6 +198,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error { t.Method = task.Method t.Prefix = task.Prefix t.IgnoreError = task.IgnoreError + t.UseGitignore = deepcopy.Scalar(task.UseGitignore) t.Run = task.Run t.Platforms = task.Platforms t.If = task.If @@ -233,6 +242,7 @@ func (t *Task) DeepCopy() *Task { Method: t.Method, Prefix: t.Prefix, IgnoreError: t.IgnoreError, + UseGitignore: deepcopy.Scalar(t.UseGitignore), Run: t.Run, IncludeVars: t.IncludeVars.DeepCopy(), IncludedTaskfileVars: t.IncludedTaskfileVars.DeepCopy(), diff --git a/taskfile/ast/taskfile.go b/taskfile/ast/taskfile.go index 4e3a3e42..20b4476c 100644 --- a/taskfile/ast/taskfile.go +++ b/taskfile/ast/taskfile.go @@ -20,20 +20,21 @@ var ErrIncludedTaskfilesCantHaveDotenvs = errors.New("task: Included Taskfiles c // Taskfile is the abstract syntax tree for a Taskfile type Taskfile struct { - Location string - Version *semver.Version - Output Output - Method string - Includes *Includes - Set []string - Shopt []string - Vars *Vars - Env *Vars - Tasks *Tasks - Silent bool - Dotenv []string - Run string - Interval time.Duration + Location string + Version *semver.Version + Output Output + Method string + Includes *Includes + Set []string + Shopt []string + Vars *Vars + Env *Vars + Tasks *Tasks + Silent bool + Dotenv []string + Run string + Interval time.Duration + UseGitignore *bool } // Merge merges the second Taskfile into the first @@ -67,6 +68,14 @@ func (t1 *Taskfile) Merge(t2 *Taskfile, include *Include) error { } } } + if t2.UseGitignore != nil { + for _, t := range t2.Tasks.All(nil) { + if t.UseGitignore == nil { + v := *t2.UseGitignore + t.UseGitignore = &v + } + } + } t1.Vars.Merge(t2.Vars, include) t1.Env.Merge(t2.Env, include) return t1.Tasks.Merge(t2.Tasks, include, t1.Vars) @@ -76,19 +85,20 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { switch node.Kind { case yaml.MappingNode: var taskfile struct { - Version *semver.Version - Output Output - Method string - Includes *Includes - Set []string - Shopt []string - Vars *Vars - Env *Vars - Tasks *Tasks - Silent bool - Dotenv []string - Run string - Interval time.Duration + Version *semver.Version + Output Output + Method string + Includes *Includes + Set []string + Shopt []string + Vars *Vars + Env *Vars + Tasks *Tasks + Silent bool + Dotenv []string + Run string + Interval time.Duration + UseGitignore *bool `yaml:"use_gitignore"` } if err := node.Decode(&taskfile); err != nil { return errors.NewTaskfileDecodeError(err, node) @@ -106,6 +116,7 @@ func (tf *Taskfile) UnmarshalYAML(node *yaml.Node) error { tf.Dotenv = taskfile.Dotenv tf.Run = taskfile.Run tf.Interval = taskfile.Interval + tf.UseGitignore = taskfile.UseGitignore if tf.Includes == nil { tf.Includes = NewIncludes() } diff --git a/testdata/gitignore/.gitignore b/testdata/gitignore/.gitignore new file mode 100644 index 00000000..f89d64da --- /dev/null +++ b/testdata/gitignore/.gitignore @@ -0,0 +1 @@ +ignored.txt diff --git a/testdata/gitignore/Taskfile.yml b/testdata/gitignore/Taskfile.yml new file mode 100644 index 00000000..93452867 --- /dev/null +++ b/testdata/gitignore/Taskfile.yml @@ -0,0 +1,25 @@ +version: '3' + +use_gitignore: true + +tasks: + build: + cmds: + - cp ./source.txt ./generated.txt + sources: + - ./*.txt + - exclude: ./generated.txt + generates: + - ./generated.txt + method: checksum + + build-no-use_gitignore: + use_gitignore: false + cmds: + - cp ./source.txt ./generated.txt + sources: + - ./*.txt + - exclude: ./generated.txt + generates: + - ./generated.txt + method: checksum diff --git a/testdata/gitignore/source.txt b/testdata/gitignore/source.txt new file mode 100644 index 00000000..48763f03 --- /dev/null +++ b/testdata/gitignore/source.txt @@ -0,0 +1 @@ +source content diff --git a/testdata/gitignore/testdata/TestGitignoreChecksum-first_run.golden b/testdata/gitignore/testdata/TestGitignoreChecksum-first_run.golden new file mode 100644 index 00000000..b1ce7331 --- /dev/null +++ b/testdata/gitignore/testdata/TestGitignoreChecksum-first_run.golden @@ -0,0 +1 @@ +task: [build] cp ./source.txt ./generated.txt diff --git a/testdata/gitignore/testdata/TestGitignoreChecksum-ignored_file_modified.golden b/testdata/gitignore/testdata/TestGitignoreChecksum-ignored_file_modified.golden new file mode 100644 index 00000000..6bcd8557 --- /dev/null +++ b/testdata/gitignore/testdata/TestGitignoreChecksum-ignored_file_modified.golden @@ -0,0 +1 @@ +task: Task "build" is up to date diff --git a/testdata/gitignore/testdata/TestGitignoreChecksum-source_file_modified.golden b/testdata/gitignore/testdata/TestGitignoreChecksum-source_file_modified.golden new file mode 100644 index 00000000..b1ce7331 --- /dev/null +++ b/testdata/gitignore/testdata/TestGitignoreChecksum-source_file_modified.golden @@ -0,0 +1 @@ +task: [build] cp ./source.txt ./generated.txt diff --git a/testdata/gitignore/testdata/TestGitignoreChecksum-up_to_date.golden b/testdata/gitignore/testdata/TestGitignoreChecksum-up_to_date.golden new file mode 100644 index 00000000..6bcd8557 --- /dev/null +++ b/testdata/gitignore/testdata/TestGitignoreChecksum-up_to_date.golden @@ -0,0 +1 @@ +task: Task "build" is up to date diff --git a/testdata/gitignore_included/.gitignore b/testdata/gitignore_included/.gitignore new file mode 100644 index 00000000..f89d64da --- /dev/null +++ b/testdata/gitignore_included/.gitignore @@ -0,0 +1 @@ +ignored.txt diff --git a/testdata/gitignore_included/Taskfile.yml b/testdata/gitignore_included/Taskfile.yml new file mode 100644 index 00000000..8ac3ceac --- /dev/null +++ b/testdata/gitignore_included/Taskfile.yml @@ -0,0 +1,9 @@ +version: '3' + +includes: + included: ./included/Taskfile.yml + +tasks: + default: + cmds: + - echo "root" diff --git a/testdata/gitignore_included/included/Taskfile.yml b/testdata/gitignore_included/included/Taskfile.yml new file mode 100644 index 00000000..9382f42f --- /dev/null +++ b/testdata/gitignore_included/included/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' + +use_gitignore: true + +tasks: + build: + cmds: + - echo "build executed" + sources: + - ./*.txt + method: checksum diff --git a/testdata/gitignore_included/source.txt b/testdata/gitignore_included/source.txt new file mode 100644 index 00000000..5a18cd2f --- /dev/null +++ b/testdata/gitignore_included/source.txt @@ -0,0 +1 @@ +source diff --git a/testdata/gitignore_included/testdata/TestGitignoreIncluded-first_run.golden b/testdata/gitignore_included/testdata/TestGitignoreIncluded-first_run.golden new file mode 100644 index 00000000..561ce3f7 --- /dev/null +++ b/testdata/gitignore_included/testdata/TestGitignoreIncluded-first_run.golden @@ -0,0 +1,2 @@ +task: [included:build] echo "build executed" +build executed diff --git a/testdata/gitignore_included/testdata/TestGitignoreIncluded-ignored_file_modified.golden b/testdata/gitignore_included/testdata/TestGitignoreIncluded-ignored_file_modified.golden new file mode 100644 index 00000000..891728bd --- /dev/null +++ b/testdata/gitignore_included/testdata/TestGitignoreIncluded-ignored_file_modified.golden @@ -0,0 +1 @@ +task: Task "included:build" is up to date diff --git a/testdata/gitignore_included/testdata/TestGitignoreIncluded-up_to_date.golden b/testdata/gitignore_included/testdata/TestGitignoreIncluded-up_to_date.golden new file mode 100644 index 00000000..891728bd --- /dev/null +++ b/testdata/gitignore_included/testdata/TestGitignoreIncluded-up_to_date.golden @@ -0,0 +1 @@ +task: Task "included:build" is up to date diff --git a/testdata/gitignore_included_override/.gitignore b/testdata/gitignore_included_override/.gitignore new file mode 100644 index 00000000..f89d64da --- /dev/null +++ b/testdata/gitignore_included_override/.gitignore @@ -0,0 +1 @@ +ignored.txt diff --git a/testdata/gitignore_included_override/Taskfile.yml b/testdata/gitignore_included_override/Taskfile.yml new file mode 100644 index 00000000..3555e258 --- /dev/null +++ b/testdata/gitignore_included_override/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' + +use_gitignore: true + +includes: + included: ./included/Taskfile.yml + +tasks: + default: + cmds: + - echo "root" diff --git a/testdata/gitignore_included_override/included/Taskfile.yml b/testdata/gitignore_included_override/included/Taskfile.yml new file mode 100644 index 00000000..b14eae06 --- /dev/null +++ b/testdata/gitignore_included_override/included/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' + +use_gitignore: false + +tasks: + build: + cmds: + - echo "build executed" + sources: + - ./*.txt + method: checksum diff --git a/testdata/gitignore_included_override/source.txt b/testdata/gitignore_included_override/source.txt new file mode 100644 index 00000000..5a18cd2f --- /dev/null +++ b/testdata/gitignore_included_override/source.txt @@ -0,0 +1 @@ +source diff --git a/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-first_run.golden b/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-first_run.golden new file mode 100644 index 00000000..561ce3f7 --- /dev/null +++ b/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-first_run.golden @@ -0,0 +1,2 @@ +task: [included:build] echo "build executed" +build executed diff --git a/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-ignored_file_modified.golden b/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-ignored_file_modified.golden new file mode 100644 index 00000000..561ce3f7 --- /dev/null +++ b/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-ignored_file_modified.golden @@ -0,0 +1,2 @@ +task: [included:build] echo "build executed" +build executed diff --git a/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-up_to_date.golden b/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-up_to_date.golden new file mode 100644 index 00000000..891728bd --- /dev/null +++ b/testdata/gitignore_included_override/testdata/TestGitignoreIncludedOverride-up_to_date.golden @@ -0,0 +1 @@ +task: Task "included:build" is up to date diff --git a/testdata/gitignore_negation/.gitignore b/testdata/gitignore_negation/.gitignore new file mode 100644 index 00000000..397b4a76 --- /dev/null +++ b/testdata/gitignore_negation/.gitignore @@ -0,0 +1 @@ +*.log diff --git a/testdata/gitignore_negation/Taskfile.yml b/testdata/gitignore_negation/Taskfile.yml new file mode 100644 index 00000000..0ada7e10 --- /dev/null +++ b/testdata/gitignore_negation/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' + +use_gitignore: true + +tasks: + build: + cmds: + - echo "build executed" + sources: + - ./sub/*.log + method: checksum diff --git a/testdata/gitignore_negation/sub/.gitignore b/testdata/gitignore_negation/sub/.gitignore new file mode 100644 index 00000000..e76c781f --- /dev/null +++ b/testdata/gitignore_negation/sub/.gitignore @@ -0,0 +1 @@ +!debug.log diff --git a/testdata/gitignore_negation/testdata/TestGitignoreNegation-first_run.golden b/testdata/gitignore_negation/testdata/TestGitignoreNegation-first_run.golden new file mode 100644 index 00000000..bfc380dc --- /dev/null +++ b/testdata/gitignore_negation/testdata/TestGitignoreNegation-first_run.golden @@ -0,0 +1,2 @@ +task: [build] echo "build executed" +build executed diff --git a/testdata/gitignore_negation/testdata/TestGitignoreNegation-ignored_file_modified.golden b/testdata/gitignore_negation/testdata/TestGitignoreNegation-ignored_file_modified.golden new file mode 100644 index 00000000..6bcd8557 --- /dev/null +++ b/testdata/gitignore_negation/testdata/TestGitignoreNegation-ignored_file_modified.golden @@ -0,0 +1 @@ +task: Task "build" is up to date diff --git a/testdata/gitignore_negation/testdata/TestGitignoreNegation-reincluded_file_modified.golden b/testdata/gitignore_negation/testdata/TestGitignoreNegation-reincluded_file_modified.golden new file mode 100644 index 00000000..bfc380dc --- /dev/null +++ b/testdata/gitignore_negation/testdata/TestGitignoreNegation-reincluded_file_modified.golden @@ -0,0 +1,2 @@ +task: [build] echo "build executed" +build executed diff --git a/testdata/gitignore_negation/testdata/TestGitignoreNegation-up_to_date.golden b/testdata/gitignore_negation/testdata/TestGitignoreNegation-up_to_date.golden new file mode 100644 index 00000000..6bcd8557 --- /dev/null +++ b/testdata/gitignore_negation/testdata/TestGitignoreNegation-up_to_date.golden @@ -0,0 +1 @@ +task: Task "build" is up to date diff --git a/testdata/gitignore_nested/Taskfile.yml b/testdata/gitignore_nested/Taskfile.yml new file mode 100644 index 00000000..1708c737 --- /dev/null +++ b/testdata/gitignore_nested/Taskfile.yml @@ -0,0 +1,11 @@ +version: '3' + +use_gitignore: true + +tasks: + build: + cmds: + - echo "build executed" + sources: + - ./sub/* + method: checksum diff --git a/testdata/gitignore_nested/sub/.gitignore b/testdata/gitignore_nested/sub/.gitignore new file mode 100644 index 00000000..067a32c5 --- /dev/null +++ b/testdata/gitignore_nested/sub/.gitignore @@ -0,0 +1 @@ +secret.dat diff --git a/testdata/gitignore_nested/sub/keep.txt b/testdata/gitignore_nested/sub/keep.txt new file mode 100644 index 00000000..2fa992c0 --- /dev/null +++ b/testdata/gitignore_nested/sub/keep.txt @@ -0,0 +1 @@ +keep diff --git a/testdata/gitignore_nested/testdata/TestGitignoreNested-first_run.golden b/testdata/gitignore_nested/testdata/TestGitignoreNested-first_run.golden new file mode 100644 index 00000000..bfc380dc --- /dev/null +++ b/testdata/gitignore_nested/testdata/TestGitignoreNested-first_run.golden @@ -0,0 +1,2 @@ +task: [build] echo "build executed" +build executed diff --git a/testdata/gitignore_nested/testdata/TestGitignoreNested-ignored_file_modified.golden b/testdata/gitignore_nested/testdata/TestGitignoreNested-ignored_file_modified.golden new file mode 100644 index 00000000..6bcd8557 --- /dev/null +++ b/testdata/gitignore_nested/testdata/TestGitignoreNested-ignored_file_modified.golden @@ -0,0 +1 @@ +task: Task "build" is up to date diff --git a/testdata/gitignore_nested/testdata/TestGitignoreNested-source_file_modified.golden b/testdata/gitignore_nested/testdata/TestGitignoreNested-source_file_modified.golden new file mode 100644 index 00000000..bfc380dc --- /dev/null +++ b/testdata/gitignore_nested/testdata/TestGitignoreNested-source_file_modified.golden @@ -0,0 +1,2 @@ +task: [build] echo "build executed" +build executed diff --git a/testdata/gitignore_nested/testdata/TestGitignoreNested-up_to_date.golden b/testdata/gitignore_nested/testdata/TestGitignoreNested-up_to_date.golden new file mode 100644 index 00000000..6bcd8557 --- /dev/null +++ b/testdata/gitignore_nested/testdata/TestGitignoreNested-up_to_date.golden @@ -0,0 +1 @@ +task: Task "build" is up to date diff --git a/variables.go b/variables.go index 0f8fcb73..b9f43d85 100644 --- a/variables.go +++ b/variables.go @@ -19,6 +19,16 @@ import ( "github.com/go-task/task/v3/taskfile/ast" ) +// shouldTaskUseGitignore resolves whether gitignore filtering applies to a +// task: the task-level value takes precedence, falling back to the Taskfile's +// global use_gitignore when the task does not set it. +func (e *Executor) shouldTaskUseGitignore(t *ast.Task) bool { + if t.UseGitignore != nil { + return *t.UseGitignore + } + return e.Taskfile.UseGitignore != nil && *e.Taskfile.UseGitignore +} + // CompiledTask returns a copy of a task, but replacing variables in almost all // properties using the Go template package. func (e *Executor) CompiledTask(call *Call) (*ast.Task, error) { @@ -43,6 +53,8 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) { cache := &templater.Cache{Vars: vars} + gitignore := e.shouldTaskUseGitignore(origTask) + return &ast.Task{ Task: origTask.Task, Label: templater.Replace(origTask.Label, cache), @@ -59,6 +71,7 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) { Env: nil, Dotenv: origTask.Dotenv, Silent: deepcopy.Scalar(origTask.Silent), + UseGitignore: &gitignore, Interactive: origTask.Interactive, Internal: origTask.Internal, Method: origTask.Method, @@ -110,6 +123,8 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err } } + gitignore := e.shouldTaskUseGitignore(origTask) + new := ast.Task{ Task: origTask.Task, Label: templater.Replace(origTask.Label, cache), @@ -126,6 +141,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err Env: nil, Dotenv: templater.Replace(origTask.Dotenv, cache), Silent: deepcopy.Scalar(origTask.Silent), + UseGitignore: &gitignore, Interactive: origTask.Interactive, Internal: origTask.Internal, Method: templater.Replace(origTask.Method, cache), @@ -219,7 +235,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err continue } if cmd.For != nil { - list, keys, err := itemsFromFor(cmd.For, new.Dir, new.Sources, new.Generates, vars, origTask.Location, cache) + list, keys, err := itemsFromFor(cmd.For, new.Dir, new.Sources, new.Generates, gitignore, vars, origTask.Location, cache) if err != nil { return nil, err } @@ -272,7 +288,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err continue } if dep.For != nil { - list, keys, err := itemsFromFor(dep.For, new.Dir, new.Sources, new.Generates, vars, origTask.Location, cache) + list, keys, err := itemsFromFor(dep.For, new.Dir, new.Sources, new.Generates, gitignore, vars, origTask.Location, cache) if err != nil { return nil, err } @@ -343,6 +359,7 @@ func itemsFromFor( dir string, sources []*ast.Glob, generates []*ast.Glob, + gitignore bool, vars *ast.Vars, location *ast.Location, cache *templater.Cache, @@ -366,7 +383,7 @@ func itemsFromFor( } // Get the list from the task sources if f.From == "sources" { - glist, err := fingerprint.Globs(dir, sources) + glist, err := fingerprint.Globs(dir, sources, gitignore) if err != nil { return nil, nil, err } @@ -380,7 +397,7 @@ func itemsFromFor( } // Get the list from the task generates if f.From == "generates" { - glist, err := fingerprint.Globs(dir, generates) + glist, err := fingerprint.Globs(dir, generates, gitignore) if err != nil { return nil, nil, err } diff --git a/watch.go b/watch.go index 8e7f7ccf..1a0dbee0 100644 --- a/watch.go +++ b/watch.go @@ -205,7 +205,7 @@ func (e *Executor) collectSources(calls []*Call) ([]string, error) { var sources []string err := e.traverse(calls, func(task *ast.Task) error { - files, err := fingerprint.Globs(task.Dir, task.Sources) + files, err := fingerprint.Globs(task.Dir, task.Sources, task.ShouldUseGitignore()) if err != nil { return err } diff --git a/website/src/public/schema.json b/website/src/public/schema.json index 71e5b3b2..0a814bb4 100644 --- a/website/src/public/schema.json +++ b/website/src/public/schema.json @@ -177,6 +177,11 @@ "enum": ["none", "checksum", "timestamp"], "default": "none" }, + "use_gitignore": { + "description": "When set to true, files matching .gitignore rules will be excluded from sources and generates glob resolution. Overrides the global gitignore setting.", + "type": "boolean", + "default": false + }, "prefix": { "description": "Defines a string to prefix the output of tasks running in parallel. Only used when the output mode is `prefixed`.", "type": "string" @@ -694,6 +699,11 @@ "enum": ["none", "checksum", "timestamp"], "default": "checksum" }, + "use_gitignore": { + "description": "When set to true, files matching .gitignore rules will be excluded from sources and generates glob resolution for all tasks. Can be overridden per task.", + "type": "boolean", + "default": false + }, "includes": { "description": "Imports tasks from the specified taskfiles. The tasks described in the given Taskfiles will be available with the informed namespace.", "type": "object",