Skip to content

Commit 616433d

Browse files
authored
feat: add use_gitignore option to exclude ignored files from sources/generates (#2773)
1 parent a03aa7b commit 616433d

53 files changed

Lines changed: 1347 additions & 43 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ require (
9292
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
9393
github.com/hashicorp/go-version v1.8.0 // indirect
9494
github.com/klauspost/compress v1.18.5 // indirect
95-
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
95+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
9696
github.com/klauspost/pgzip v1.2.6 // indirect
9797
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
9898
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -121,6 +121,7 @@ require (
121121
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
122122
go.opentelemetry.io/otel/trace v1.43.0 // indirect
123123
golang.org/x/crypto v0.51.0 // indirect
124+
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
124125
golang.org/x/net v0.55.0 // indirect
125126
golang.org/x/oauth2 v0.36.0 // indirect
126127
golang.org/x/sys v0.46.0 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
191191
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
192192
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
193193
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
194-
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
195-
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
194+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
195+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
196196
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
197197
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
198198
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=
284284
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
285285
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
286286
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
287-
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
288-
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
287+
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
288+
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
289289
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
290290
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
291291
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=

internal/fingerprint/gitignore.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package fingerprint
2+
3+
import (
4+
"bufio"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/go-task/task/v3/internal/gitignore"
13+
)
14+
15+
type linesCacheEntry struct {
16+
mtime time.Time
17+
lines []string
18+
}
19+
20+
var (
21+
gitignoreLinesCache sync.Map // dir -> linesCacheEntry, invalidated by mtime
22+
repoRootCache sync.Map // dir -> repo root (or "" when not in a repo)
23+
)
24+
25+
// findRepoRoot returns the first ancestor of dir containing a .git entry, or
26+
// ("", false) when dir is not inside a git repository.
27+
func findRepoRoot(dir string) (string, bool) {
28+
if v, ok := repoRootCache.Load(dir); ok {
29+
root := v.(string)
30+
return root, root != ""
31+
}
32+
33+
current := dir
34+
for {
35+
if _, err := os.Stat(filepath.Join(current, ".git")); err == nil {
36+
repoRootCache.Store(dir, current)
37+
return current, true
38+
}
39+
parent := filepath.Dir(current)
40+
if parent == current {
41+
break
42+
}
43+
current = parent
44+
}
45+
46+
repoRootCache.Store(dir, "")
47+
return "", false
48+
}
49+
50+
// filterGitignored marks entries matching gitignore rules as excluded (false).
51+
// All .gitignore files from the repo root down to each candidate file's
52+
// directory feed a single matcher so that precedence and cross-file negations
53+
// (`!pattern`) resolve correctly.
54+
func filterGitignored(files map[string]bool, dir string) map[string]bool {
55+
if len(files) == 0 {
56+
return files
57+
}
58+
59+
absDir, err := filepath.Abs(dir)
60+
if err != nil {
61+
return files
62+
}
63+
repoRoot, ok := findRepoRoot(absDir)
64+
if !ok {
65+
return files
66+
}
67+
68+
// Every directory from the repo root down to each candidate file's dir, so
69+
// nested .gitignore files reached by deep globs are included too.
70+
dirSet := make(map[string]struct{})
71+
for path, included := range files {
72+
if !included {
73+
continue
74+
}
75+
absPath, err := filepath.Abs(path)
76+
if err != nil {
77+
continue
78+
}
79+
d := filepath.Dir(absPath)
80+
if !withinRepo(repoRoot, d) {
81+
continue
82+
}
83+
for {
84+
dirSet[d] = struct{}{}
85+
if d == repoRoot {
86+
break
87+
}
88+
parent := filepath.Dir(d)
89+
if parent == d {
90+
break
91+
}
92+
d = parent
93+
}
94+
}
95+
96+
// Shallow dirs first (lower priority): the matcher scans patterns last to
97+
// first, so deeper rules win and can negate shallower ones.
98+
dirs := make([]string, 0, len(dirSet))
99+
for d := range dirSet {
100+
dirs = append(dirs, d)
101+
}
102+
sort.Slice(dirs, func(i, j int) bool {
103+
di := strings.Count(dirs[i], string(filepath.Separator))
104+
dj := strings.Count(dirs[j], string(filepath.Separator))
105+
if di != dj {
106+
return di < dj
107+
}
108+
return dirs[i] < dirs[j]
109+
})
110+
111+
var patterns []gitignore.Pattern
112+
for _, d := range dirs {
113+
lines := readGitignoreLines(d)
114+
if len(lines) == 0 {
115+
continue
116+
}
117+
// domain scopes each pattern to its .gitignore subtree (go-git semantics).
118+
var domain []string
119+
if rel, err := filepath.Rel(repoRoot, d); err == nil && rel != "." {
120+
domain = strings.Split(filepath.ToSlash(rel), "/")
121+
}
122+
for _, line := range lines {
123+
patterns = append(patterns, gitignore.ParsePattern(line, domain))
124+
}
125+
}
126+
if len(patterns) == 0 {
127+
return files
128+
}
129+
130+
matcher := gitignore.NewMatcher(patterns)
131+
for path, included := range files {
132+
if !included {
133+
continue
134+
}
135+
absPath, err := filepath.Abs(path)
136+
if err != nil {
137+
continue
138+
}
139+
relPath, err := filepath.Rel(repoRoot, absPath)
140+
if err != nil || relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) {
141+
continue
142+
}
143+
if ignored(matcher, strings.Split(filepath.ToSlash(relPath), "/")) {
144+
files[path] = false
145+
}
146+
}
147+
148+
return files
149+
}
150+
151+
// ignored honors Git's rule that a file under an ignored directory cannot be
152+
// re-included by a deeper negation: if any ancestor directory is ignored, so is
153+
// the file. Otherwise the file's own verdict applies (isDir=false).
154+
func ignored(matcher gitignore.Matcher, segments []string) bool {
155+
for i := 1; i < len(segments); i++ {
156+
if matcher.Match(segments[:i], true) {
157+
return true
158+
}
159+
}
160+
return matcher.Match(segments, false)
161+
}
162+
163+
func withinRepo(repoRoot, p string) bool {
164+
rel, err := filepath.Rel(repoRoot, p)
165+
if err != nil {
166+
return false
167+
}
168+
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
169+
}
170+
171+
// readGitignoreLines returns the .gitignore lines in dir (nil if none), cached
172+
// per directory and invalidated by mtime so watch mode picks up edits.
173+
func readGitignoreLines(dir string) []string {
174+
path := filepath.Join(dir, ".gitignore")
175+
info, err := os.Stat(path)
176+
if err != nil {
177+
return nil
178+
}
179+
mtime := info.ModTime()
180+
181+
if v, ok := gitignoreLinesCache.Load(dir); ok {
182+
entry := v.(linesCacheEntry)
183+
if entry.mtime.Equal(mtime) {
184+
return entry.lines
185+
}
186+
}
187+
188+
lines := parseGitignoreLines(path)
189+
gitignoreLinesCache.Store(dir, linesCacheEntry{mtime: mtime, lines: lines})
190+
return lines
191+
}
192+
193+
func parseGitignoreLines(path string) []string {
194+
f, err := os.Open(path)
195+
if err != nil {
196+
return nil
197+
}
198+
defer f.Close()
199+
200+
var lines []string
201+
scanner := bufio.NewScanner(f)
202+
for scanner.Scan() {
203+
line := strings.TrimRight(scanner.Text(), "\r")
204+
if line != "" && !strings.HasPrefix(line, "#") {
205+
lines = append(lines, line)
206+
}
207+
}
208+
// On a scan error (e.g. an over-long line) keep what was parsed rather than
209+
// dropping the whole file.
210+
return lines
211+
}

0 commit comments

Comments
 (0)