diff --git a/go.mod b/go.mod index aa896c4ec2..64fa6a246e 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.45.0 // indirect diff --git a/go.sum b/go.sum index 03370e83c4..7ab23a070f 100644 --- a/go.sum +++ b/go.sum @@ -181,8 +181,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= @@ -274,8 +274,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 0000000000..e74bc716ae --- /dev/null +++ b/internal/fingerprint/gitignore.go @@ -0,0 +1,102 @@ +package fingerprint + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "github.com/go-task/task/v3/internal/gitignore" +) + +type gitignoreRule struct { + dir string + matcher gitignore.Matcher +} + +// loadGitignoreRules walks up from dir collecting .gitignore files. +// Stops at the first .git (file or directory) found. +// Returns nil if no .git is found (not in a git repo). +func loadGitignoreRules(dir string) []gitignoreRule { + dir, _ = filepath.Abs(dir) + + var rules []gitignoreRule + foundGit := false + current := dir + + for { + lines := readGitignoreLines(filepath.Join(current, ".gitignore")) + if len(lines) > 0 { + patterns := make([]gitignore.Pattern, 0, len(lines)) + for _, line := range lines { + patterns = append(patterns, gitignore.ParsePattern(line, nil)) + } + rules = append(rules, gitignoreRule{ + dir: current, + matcher: gitignore.NewMatcher(patterns), + }) + } + if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { + foundGit = true + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + + if !foundGit { + return nil + } + + return rules +} + +func readGitignoreLines(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) + } + } + if err := scanner.Err(); err != nil { + return nil + } + return lines +} + +// filterGitignored removes entries from the file map that match gitignore rules. +func filterGitignored(files map[string]bool, dir string) map[string]bool { + rules := loadGitignoreRules(dir) + if len(rules) == 0 { + return files + } + + for path := range files { + for _, rule := range rules { + relPath, err := filepath.Rel(rule.dir, path) + if err != nil || strings.HasPrefix(relPath, "..") { + continue + } + // Sources are files, not directories; pass isDir=false. Per the + // gitignore spec this still matches files under an ignored dir + // (e.g. "build/" matches build/out.txt). + if rule.matcher.Match(strings.Split(filepath.ToSlash(relPath), "/"), false) { + files[path] = false + break + } + } + } + + return files +} diff --git a/internal/fingerprint/gitignore_test.go b/internal/fingerprint/gitignore_test.go new file mode 100644 index 0000000000..edef18b1af --- /dev/null +++ b/internal/fingerprint/gitignore_test.go @@ -0,0 +1,112 @@ +package fingerprint + +import ( + "os" + "path/filepath" + "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 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 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 fd3cafceaa..930a54a3b1 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 f1108e111c..9ffdbdb6f8 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 258d9386d9..929b044bca 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 0000000000..8aa3d854cf --- /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 0000000000..4110502be7 --- /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 0000000000..ce0dc75d69 --- /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 0000000000..a7a51ea0fa --- /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 0000000000..a0168728ba --- /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 80915c2c47..784c03257c 100644 --- a/task_test.go +++ b/task_test.go @@ -653,6 +653,55 @@ func TestStatusChecksumMissingGenerated(t *testing.T) { // nolint:paralleltest / require.NoError(t, err, "generated.txt should be recreated after third run") } +func TestGitignoreChecksum(t *testing.T) { //nolint:paralleltest // cannot run in parallel + const dir = "testdata/gitignore" + + // Clean up + _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) + _ = os.Remove(filepathext.SmartJoin(dir, "generated.txt")) + + var buff bytes.Buffer + tempDir := task.TempDir{ + Remote: filepathext.SmartJoin(dir, ".task"), + Fingerprint: filepathext.SmartJoin(dir, ".task"), + } + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(tempDir), + ) + require.NoError(t, e.Setup()) + + // First run - should execute + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + + // Second run - should be up to date + buff.Reset() + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.Equal(t, "task: Task \"build\" is up to date\n", buff.String()) + + // Modify the ignored file - should still be up to date + require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "ignored.txt"), []byte("modified\n"), 0o644)) + buff.Reset() + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.Equal(t, "task: Task \"build\" is up to date\n", buff.String()) + + // Modify the source file - should re-execute + require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "source.txt"), []byte("modified source\n"), 0o644)) + buff.Reset() + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.NotEqual(t, "task: Task \"build\" is up to date\n", buff.String()) + + // Restore source file + require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "source.txt"), []byte("source content\n"), 0o644)) + require.NoError(t, os.WriteFile(filepathext.SmartJoin(dir, "ignored.txt"), []byte("ignored content\n"), 0o644)) + + // Clean up + _ = os.RemoveAll(filepathext.SmartJoin(dir, ".task")) + _ = os.Remove(filepathext.SmartJoin(dir, "generated.txt")) +} + func TestStatusVariables(t *testing.T) { t.Parallel() diff --git a/taskfile/ast/task.go b/taskfile/ast/task.go index 0e9893943e..9465c77770 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 4e3a3e4255..9de0f90255 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 `yaml:"use_gitignore"` } // Merge merges the second Taskfile into the first @@ -76,19 +77,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 +108,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 0000000000..f89d64dab6 --- /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 0000000000..93452867c6 --- /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 0000000000..48763f0349 --- /dev/null +++ b/testdata/gitignore/source.txt @@ -0,0 +1 @@ +source content diff --git a/variables.go b/variables.go index c7c6cc8493..dad3ca9219 100644 --- a/variables.go +++ b/variables.go @@ -59,6 +59,7 @@ func (e *Executor) CompiledTaskForTaskList(call *Call) (*ast.Task, error) { Env: nil, Dotenv: origTask.Dotenv, Silent: deepcopy.Scalar(origTask.Silent), + UseGitignore: deepcopy.Scalar(origTask.UseGitignore), Interactive: origTask.Interactive, Internal: origTask.Internal, Method: origTask.Method, @@ -110,6 +111,11 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err } } + gitignore := origTask.ShouldUseGitignore() + if origTask.UseGitignore == nil { + gitignore = e.Taskfile.UseGitignore + } + new := ast.Task{ Task: origTask.Task, Label: templater.Replace(origTask.Label, cache), @@ -126,6 +132,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 +226,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 } @@ -268,7 +275,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 } @@ -339,6 +346,7 @@ func itemsFromFor( dir string, sources []*ast.Glob, generates []*ast.Glob, + gitignore bool, vars *ast.Vars, location *ast.Location, cache *templater.Cache, @@ -361,7 +369,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 } @@ -375,7 +383,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 8e7f7ccf7d..1a0dbee0c7 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 2210952d62..61f7a843b7 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" @@ -687,6 +692,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",