refactor: honor .gitignore in recursive @-search instead of a hardcoded denylist
Replace the static recursiveSkipDirs list (which would inevitably drift as new tools appear) with the project's root .gitignore. Most caches that bloat a recursive walk \u2014 build outputs, dependency dirs, and IaC caches like .terraform/.terragrunt-cache \u2014 are already gitignored in real projects. - Extract the existing .gitignore matcher from agent/extcmd.go into a new leaf package, packages/ignore, so packages/agent/modes can share it without an import cycle. extcmd keeps thin aliases for its tests. - scanRecursive now loads the root .gitignore and prunes ignored entries, plus an unconditional .git skip (rarely self-listed). - Tests: gitignore-driven pruning in the picker, plus unit tests for the extracted matcher. No new dependencies.
- commit
- 3ce9c2861f9584820746dd501dc8bdc6f979f831
- author
- Raymond Gasper · · 2026-06-09 19:54 UTC
- parent
- e7439ba
- signature
- unsigned
6 files · +221 -140
M packages/agent/extcmd.go +9 -94
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "os/exec" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/patriceckhart/zot/packages/ignore" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // runExtCommand dispatches `zot ext ...` subcommands. Returns | |
| @@ -344,7 +346,7 @@ func copyDir(src, dst string) error { | |||
| 344 | 346 | if info.IsDir() && name == ".git" { | |
| 345 | 347 | return filepath.SkipDir | |
| 346 | 348 | } | |
| 347 | - | if ig.match(filepath.ToSlash(rel), info.IsDir()) { | |
| 349 | + | if ig.Match(filepath.ToSlash(rel), info.IsDir()) { | |
| 348 | 350 | if info.IsDir() { | |
| 349 | 351 | return filepath.SkipDir | |
| 350 | 352 | } | |
| @@ -370,98 +372,11 @@ func copyDir(src, dst string) error { | |||
| 370 | 372 | }) | |
| 371 | 373 | } | |
| 372 | 374 | ||
| 373 | - | // gitignore is a minimal .gitignore matcher. It supports the common | |
| 374 | - | // patterns used in real extension repos: blank lines, comments (#), | |
| 375 | - | // negation (!), directory-only patterns (trailing /), anchored | |
| 376 | - | // patterns (leading /), and the * / ? / [..] wildcards via | |
| 377 | - | // filepath.Match. It intentionally does not implement ** globstar or | |
| 378 | - | // nested per-directory .gitignore files; the goal is to drop obvious | |
| 379 | - | // non-portable directories, not to be a faithful git reimplementation. | |
| 380 | - | type gitignore struct { | |
| 381 | - | rules []gitignoreRule | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | type gitignoreRule struct { | |
| 385 | - | pattern string | |
| 386 | - | negate bool | |
| 387 | - | dirOnly bool | |
| 388 | - | anchored bool | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func loadGitignore(root string) *gitignore { | |
| 392 | - | data, err := os.ReadFile(filepath.Join(root, ".gitignore")) | |
| 393 | - | if err != nil { | |
| 394 | - | return &gitignore{} | |
| 395 | - | } | |
| 396 | - | return loadGitignoreFromString(string(data)) | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | func loadGitignoreFromString(data string) *gitignore { | |
| 400 | - | g := &gitignore{} | |
| 401 | - | for _, line := range strings.Split(data, "\n") { | |
| 402 | - | line = strings.TrimRight(line, "\r") | |
| 403 | - | trimmed := strings.TrimSpace(line) | |
| 404 | - | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |
| 405 | - | continue | |
| 406 | - | } | |
| 407 | - | r := gitignoreRule{pattern: trimmed} | |
| 408 | - | if strings.HasPrefix(r.pattern, "!") { | |
| 409 | - | r.negate = true | |
| 410 | - | r.pattern = r.pattern[1:] | |
| 411 | - | } | |
| 412 | - | if strings.HasSuffix(r.pattern, "/") { | |
| 413 | - | r.dirOnly = true | |
| 414 | - | r.pattern = strings.TrimSuffix(r.pattern, "/") | |
| 415 | - | } | |
| 416 | - | if strings.HasPrefix(r.pattern, "/") { | |
| 417 | - | r.anchored = true | |
| 418 | - | r.pattern = strings.TrimPrefix(r.pattern, "/") | |
| 419 | - | } | |
| 420 | - | if r.pattern == "" { | |
| 421 | - | continue | |
| 422 | - | } | |
| 423 | - | g.rules = append(g.rules, r) | |
| 424 | - | } | |
| 425 | - | return g | |
| 426 | - | } | |
| 375 | + | // gitignore matching lives in packages/ignore so the @-file picker in | |
| 376 | + | // packages/agent/modes can share it without an import cycle. These | |
| 377 | + | // thin aliases keep the existing call sites (and tests) terse. | |
| 378 | + | type gitignore = ignore.Gitignore | |
| 427 | 379 | ||
| 428 | - | // match reports whether the slash-separated relative path should be | |
| 429 | - | // ignored. Later rules win, so a trailing negation can re-include a | |
| 430 | - | // previously ignored path. | |
| 431 | - | func (g *gitignore) match(rel string, isDir bool) bool { | |
| 432 | - | ignored := false | |
| 433 | - | for _, r := range g.rules { | |
| 434 | - | if r.dirOnly && !isDir { | |
| 435 | - | continue | |
| 436 | - | } | |
| 437 | - | if r.matchPath(rel) { | |
| 438 | - | ignored = !r.negate | |
| 439 | - | } | |
| 440 | - | } | |
| 441 | - | return ignored | |
| 442 | - | } | |
| 380 | + | func loadGitignore(root string) *gitignore { return ignore.Load(root) } | |
| 443 | 381 | ||
| 444 | - | func (r gitignoreRule) matchPath(rel string) bool { | |
| 445 | - | if r.anchored || strings.Contains(r.pattern, "/") { | |
| 446 | - | if ok, _ := filepath.Match(r.pattern, rel); ok { | |
| 447 | - | return true | |
| 448 | - | } | |
| 449 | - | // Anchored directory pattern also matches everything beneath it. | |
| 450 | - | return strings.HasPrefix(rel, r.pattern+"/") | |
| 451 | - | } | |
| 452 | - | // Unanchored: match the basename of any path component. | |
| 453 | - | base := rel | |
| 454 | - | if i := strings.LastIndex(rel, "/"); i >= 0 { | |
| 455 | - | base = rel[i+1:] | |
| 456 | - | } | |
| 457 | - | if ok, _ := filepath.Match(r.pattern, base); ok { | |
| 458 | - | return true | |
| 459 | - | } | |
| 460 | - | // Match a directory component anywhere in the path. | |
| 461 | - | for _, part := range strings.Split(rel, "/") { | |
| 462 | - | if ok, _ := filepath.Match(r.pattern, part); ok { | |
| 463 | - | return true | |
| 464 | - | } | |
| 465 | - | } | |
| 466 | - | return false | |
| 467 | - | } | |
| 382 | + | func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) } | |
| @@ -8,6 +8,8 @@ import ( | |||||
| 8 | "os/exec" | 8 | "os/exec" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | + | ||||
| 12 | + | "github.com/patriceckhart/zot/packages/ignore" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | // runExtCommand dispatches `zot ext ...` subcommands. Returns | 15 | // runExtCommand dispatches `zot ext ...` subcommands. Returns | ||
| @@ -344,7 +346,7 @@ func copyDir(src, dst string) error { | |||||
| 344 | if info.IsDir() && name == ".git" { | 346 | if info.IsDir() && name == ".git" { | ||
| 345 | return filepath.SkipDir | 347 | return filepath.SkipDir | ||
| 346 | } | 348 | } | ||
| 347 | - | if ig.match(filepath.ToSlash(rel), info.IsDir()) { | 349 | + | if ig.Match(filepath.ToSlash(rel), info.IsDir()) { |
| 348 | if info.IsDir() { | 350 | if info.IsDir() { | ||
| 349 | return filepath.SkipDir | 351 | return filepath.SkipDir | ||
| 350 | } | 352 | } | ||
| @@ -370,98 +372,11 @@ func copyDir(src, dst string) error { | |||||
| 370 | }) | 372 | }) | ||
| 371 | } | 373 | } | ||
| 372 | 374 | ||||
| 373 | - | // gitignore is a minimal .gitignore matcher. It supports the common | 375 | + | // gitignore matching lives in packages/ignore so the @-file picker in |
| 374 | - | // patterns used in real extension repos: blank lines, comments (#), | 376 | + | // packages/agent/modes can share it without an import cycle. These |
| 375 | - | // negation (!), directory-only patterns (trailing /), anchored | 377 | + | // thin aliases keep the existing call sites (and tests) terse. |
| 376 | - | // patterns (leading /), and the * / ? / [..] wildcards via | 378 | + | type gitignore = ignore.Gitignore |
| 377 | - | // filepath.Match. It intentionally does not implement ** globstar or | |||
| 378 | - | // nested per-directory .gitignore files; the goal is to drop obvious | |||
| 379 | - | // non-portable directories, not to be a faithful git reimplementation. | |||
| 380 | - | type gitignore struct { | |||
| 381 | - | rules []gitignoreRule | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | type gitignoreRule struct { | |||
| 385 | - | pattern string | |||
| 386 | - | negate bool | |||
| 387 | - | dirOnly bool | |||
| 388 | - | anchored bool | |||
| 389 | - | } | |||
| 390 | - | ||||
| 391 | - | func loadGitignore(root string) *gitignore { | |||
| 392 | - | data, err := os.ReadFile(filepath.Join(root, ".gitignore")) | |||
| 393 | - | if err != nil { | |||
| 394 | - | return &gitignore{} | |||
| 395 | - | } | |||
| 396 | - | return loadGitignoreFromString(string(data)) | |||
| 397 | - | } | |||
| 398 | - | ||||
| 399 | - | func loadGitignoreFromString(data string) *gitignore { | |||
| 400 | - | g := &gitignore{} | |||
| 401 | - | for _, line := range strings.Split(data, "\n") { | |||
| 402 | - | line = strings.TrimRight(line, "\r") | |||
| 403 | - | trimmed := strings.TrimSpace(line) | |||
| 404 | - | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |||
| 405 | - | continue | |||
| 406 | - | } | |||
| 407 | - | r := gitignoreRule{pattern: trimmed} | |||
| 408 | - | if strings.HasPrefix(r.pattern, "!") { | |||
| 409 | - | r.negate = true | |||
| 410 | - | r.pattern = r.pattern[1:] | |||
| 411 | - | } | |||
| 412 | - | if strings.HasSuffix(r.pattern, "/") { | |||
| 413 | - | r.dirOnly = true | |||
| 414 | - | r.pattern = strings.TrimSuffix(r.pattern, "/") | |||
| 415 | - | } | |||
| 416 | - | if strings.HasPrefix(r.pattern, "/") { | |||
| 417 | - | r.anchored = true | |||
| 418 | - | r.pattern = strings.TrimPrefix(r.pattern, "/") | |||
| 419 | - | } | |||
| 420 | - | if r.pattern == "" { | |||
| 421 | - | continue | |||
| 422 | - | } | |||
| 423 | - | g.rules = append(g.rules, r) | |||
| 424 | - | } | |||
| 425 | - | return g | |||
| 426 | - | } | |||
| 427 | 379 | ||||
| 428 | - | // match reports whether the slash-separated relative path should be | 380 | + | func loadGitignore(root string) *gitignore { return ignore.Load(root) } |
| 429 | - | // ignored. Later rules win, so a trailing negation can re-include a | |||
| 430 | - | // previously ignored path. | |||
| 431 | - | func (g *gitignore) match(rel string, isDir bool) bool { | |||
| 432 | - | ignored := false | |||
| 433 | - | for _, r := range g.rules { | |||
| 434 | - | if r.dirOnly && !isDir { | |||
| 435 | - | continue | |||
| 436 | - | } | |||
| 437 | - | if r.matchPath(rel) { | |||
| 438 | - | ignored = !r.negate | |||
| 439 | - | } | |||
| 440 | - | } | |||
| 441 | - | return ignored | |||
| 442 | - | } | |||
| 443 | 381 | ||||
| 444 | - | func (r gitignoreRule) matchPath(rel string) bool { | 382 | + | func loadGitignoreFromString(data string) *gitignore { return ignore.Parse(data) } |
| 445 | - | if r.anchored || strings.Contains(r.pattern, "/") { | |||
| 446 | - | if ok, _ := filepath.Match(r.pattern, rel); ok { | |||
| 447 | - | return true | |||
| 448 | - | } | |||
| 449 | - | // Anchored directory pattern also matches everything beneath it. | |||
| 450 | - | return strings.HasPrefix(rel, r.pattern+"/") | |||
| 451 | - | } | |||
| 452 | - | // Unanchored: match the basename of any path component. | |||
| 453 | - | base := rel | |||
| 454 | - | if i := strings.LastIndex(rel, "/"); i >= 0 { | |||
| 455 | - | base = rel[i+1:] | |||
| 456 | - | } | |||
| 457 | - | if ok, _ := filepath.Match(r.pattern, base); ok { | |||
| 458 | - | return true | |||
| 459 | - | } | |||
| 460 | - | // Match a directory component anywhere in the path. | |||
| 461 | - | for _, part := range strings.Split(rel, "/") { | |||
| 462 | - | if ok, _ := filepath.Match(r.pattern, part); ok { | |||
| 463 | - | return true | |||
| 464 | - | } | |||
| 465 | - | } | |||
| 466 | - | return false | |||
| 467 | - | } | |||
M packages/agent/extcmd_test.go +2 -2
| @@ -117,10 +117,10 @@ func TestCopyDirRespectsGitignore(t *testing.T) { | |||
| 117 | 117 | ||
| 118 | 118 | func TestGitignoreNegation(t *testing.T) { | |
| 119 | 119 | g := loadGitignoreFromString("build/\n!build/keep.txt\n") | |
| 120 | - | if !g.match("build", true) { | |
| 120 | + | if !g.Match("build", true) { | |
| 121 | 121 | t.Fatal("expected build/ dir to be ignored") | |
| 122 | 122 | } | |
| 123 | - | if g.match("build/keep.txt", false) { | |
| 123 | + | if g.Match("build/keep.txt", false) { | |
| 124 | 124 | t.Fatal("expected build/keep.txt to be re-included by negation") | |
| 125 | 125 | } | |
| 126 | 126 | } | |
| @@ -117,10 +117,10 @@ func TestCopyDirRespectsGitignore(t *testing.T) { | |||||
| 117 | 117 | ||||
| 118 | func TestGitignoreNegation(t *testing.T) { | 118 | func TestGitignoreNegation(t *testing.T) { | ||
| 119 | g := loadGitignoreFromString("build/\n!build/keep.txt\n") | 119 | g := loadGitignoreFromString("build/\n!build/keep.txt\n") | ||
| 120 | - | if !g.match("build", true) { | 120 | + | if !g.Match("build", true) { |
| 121 | t.Fatal("expected build/ dir to be ignored") | 121 | t.Fatal("expected build/ dir to be ignored") | ||
| 122 | } | 122 | } | ||
| 123 | - | if g.match("build/keep.txt", false) { | 123 | + | if g.Match("build/keep.txt", false) { |
| 124 | t.Fatal("expected build/keep.txt to be re-included by negation") | 124 | t.Fatal("expected build/keep.txt to be re-included by negation") | ||
| 125 | } | 125 | } | ||
| 126 | } | 126 | } | ||
M packages/agent/modes/file_suggest.go +23 -33
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/sahilm/fuzzy" | |
| 12 | 12 | ||
| 13 | + | "github.com/patriceckhart/zot/packages/ignore" | |
| 13 | 14 | "github.com/patriceckhart/zot/packages/tui" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -21,31 +22,11 @@ const ( | |||
| 21 | 22 | maxRecursiveDepth = 12 | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | - | // recursiveSkipDirs are directory names never descended into during a | |
| 25 | - | // recursive scan. They dominate the entry budget without being useful | |
| 26 | - | // @-mention targets. | |
| 27 | - | var recursiveSkipDirs = map[string]bool{ | |
| 28 | - | ".git": true, | |
| 29 | - | "node_modules": true, | |
| 30 | - | ".venv": true, | |
| 31 | - | "venv": true, | |
| 32 | - | "vendor": true, | |
| 33 | - | "target": true, | |
| 34 | - | "dist": true, | |
| 35 | - | "build": true, | |
| 36 | - | ".idea": true, | |
| 37 | - | ".vscode": true, | |
| 38 | - | "__pycache__": true, | |
| 39 | - | // Infrastructure-as-code provider/module caches. These hold copies | |
| 40 | - | // of downloaded providers and generated module trees and can grow | |
| 41 | - | // to hundreds of MB or deeply nested duplicates of the source. | |
| 42 | - | ".terraform": true, | |
| 43 | - | ".terragrunt-cache": true, | |
| 44 | - | ".terraform.d": true, | |
| 45 | - | ".pulumi": true, | |
| 46 | - | ".serverless": true, | |
| 47 | - | "cdk.out": true, | |
| 48 | - | } | |
| 25 | + | // alwaysSkipDir is never descended into during a recursive scan, | |
| 26 | + | // regardless of .gitignore. .git is a repo-internal directory that | |
| 27 | + | // real projects rarely list in their own .gitignore yet never want | |
| 28 | + | // surfaced as an @-mention target. | |
| 29 | + | const alwaysSkipDir = ".git" | |
| 49 | 30 | ||
| 50 | 31 | // fileSuggester provides an @-triggered file/directory picker popup. | |
| 51 | 32 | // Type "@" followed by an optional filter to list files in the working | |
| @@ -162,8 +143,10 @@ func (s *fileSuggester) scan() []fileEntry { | |||
| 162 | 143 | ||
| 163 | 144 | // scanRecursive walks the whole project tree below cwd and returns | |
| 164 | 145 | // every file and directory as a fileEntry whose rel is the path | |
| 165 | - | // relative to cwd. Common heavy directories (.git, node_modules, ...) | |
| 166 | - | // are skipped, and the walk stops once it hits the entry/depth caps. | |
| 146 | + | // relative to cwd. The walk honors the project's root .gitignore (so | |
| 147 | + | // build outputs, dependency directories, and tool caches like | |
| 148 | + | // .terraform/.terragrunt-cache stay out of the picker) plus an | |
| 149 | + | // unconditional .git skip, and stops once it hits the entry/depth caps. | |
| 167 | 150 | // | |
| 168 | 151 | // Results are cached by cwd + mtime of cwd. Unlike the flat scan a | |
| 169 | 152 | // single mtime can't catch every nested change, so the cache is best | |
| @@ -180,6 +163,7 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 180 | 163 | return s.cachedAll | |
| 181 | 164 | } | |
| 182 | 165 | ||
| 166 | + | ig := ignore.Load(root) | |
| 183 | 167 | var all []fileEntry | |
| 184 | 168 | rootSep := strings.Count(root, string(os.PathSeparator)) | |
| 185 | 169 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| @@ -192,19 +176,25 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 192 | 176 | if path == root { | |
| 193 | 177 | return nil | |
| 194 | 178 | } | |
| 195 | - | name := d.Name() | |
| 179 | + | rel, relErr := filepath.Rel(root, path) | |
| 180 | + | if relErr != nil { | |
| 181 | + | return nil | |
| 182 | + | } | |
| 183 | + | // .gitignore patterns are matched against slash-separated paths. | |
| 184 | + | if ig.Match(filepath.ToSlash(rel), d.IsDir()) { | |
| 185 | + | if d.IsDir() { | |
| 186 | + | return filepath.SkipDir | |
| 187 | + | } | |
| 188 | + | return nil | |
| 189 | + | } | |
| 196 | 190 | if d.IsDir() { | |
| 197 | - | if recursiveSkipDirs[name] { | |
| 191 | + | if d.Name() == alwaysSkipDir { | |
| 198 | 192 | return filepath.SkipDir | |
| 199 | 193 | } | |
| 200 | 194 | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | |
| 201 | 195 | return filepath.SkipDir | |
| 202 | 196 | } | |
| 203 | 197 | } | |
| 204 | - | rel, relErr := filepath.Rel(root, path) | |
| 205 | - | if relErr != nil { | |
| 206 | - | return nil | |
| 207 | - | } | |
| 208 | 198 | all = append(all, fileEntry{ | |
| 209 | 199 | name: rel, | |
| 210 | 200 | rel: rel, | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | "github.com/sahilm/fuzzy" | 11 | "github.com/sahilm/fuzzy" | ||
| 12 | 12 | ||||
| 13 | + | "github.com/patriceckhart/zot/packages/ignore" | |||
| 13 | "github.com/patriceckhart/zot/packages/tui" | 14 | "github.com/patriceckhart/zot/packages/tui" | ||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| @@ -21,31 +22,11 @@ const ( | |||||
| 21 | maxRecursiveDepth = 12 | 22 | maxRecursiveDepth = 12 | ||
| 22 | ) | 23 | ) | ||
| 23 | 24 | ||||
| 24 | - | // recursiveSkipDirs are directory names never descended into during a | 25 | + | // alwaysSkipDir is never descended into during a recursive scan, |
| 25 | - | // recursive scan. They dominate the entry budget without being useful | 26 | + | // regardless of .gitignore. .git is a repo-internal directory that |
| 26 | - | // @-mention targets. | 27 | + | // real projects rarely list in their own .gitignore yet never want |
| 27 | - | var recursiveSkipDirs = map[string]bool{ | 28 | + | // surfaced as an @-mention target. |
| 28 | - | ".git": true, | 29 | + | const alwaysSkipDir = ".git" |
| 29 | - | "node_modules": true, | |||
| 30 | - | ".venv": true, | |||
| 31 | - | "venv": true, | |||
| 32 | - | "vendor": true, | |||
| 33 | - | "target": true, | |||
| 34 | - | "dist": true, | |||
| 35 | - | "build": true, | |||
| 36 | - | ".idea": true, | |||
| 37 | - | ".vscode": true, | |||
| 38 | - | "__pycache__": true, | |||
| 39 | - | // Infrastructure-as-code provider/module caches. These hold copies | |||
| 40 | - | // of downloaded providers and generated module trees and can grow | |||
| 41 | - | // to hundreds of MB or deeply nested duplicates of the source. | |||
| 42 | - | ".terraform": true, | |||
| 43 | - | ".terragrunt-cache": true, | |||
| 44 | - | ".terraform.d": true, | |||
| 45 | - | ".pulumi": true, | |||
| 46 | - | ".serverless": true, | |||
| 47 | - | "cdk.out": true, | |||
| 48 | - | } | |||
| 49 | 30 | ||||
| 50 | // fileSuggester provides an @-triggered file/directory picker popup. | 31 | // fileSuggester provides an @-triggered file/directory picker popup. | ||
| 51 | // Type "@" followed by an optional filter to list files in the working | 32 | // Type "@" followed by an optional filter to list files in the working | ||
| @@ -162,8 +143,10 @@ func (s *fileSuggester) scan() []fileEntry { | |||||
| 162 | 143 | ||||
| 163 | // scanRecursive walks the whole project tree below cwd and returns | 144 | // scanRecursive walks the whole project tree below cwd and returns | ||
| 164 | // every file and directory as a fileEntry whose rel is the path | 145 | // every file and directory as a fileEntry whose rel is the path | ||
| 165 | - | // relative to cwd. Common heavy directories (.git, node_modules, ...) | 146 | + | // relative to cwd. The walk honors the project's root .gitignore (so |
| 166 | - | // are skipped, and the walk stops once it hits the entry/depth caps. | 147 | + | // build outputs, dependency directories, and tool caches like |
| 148 | + | // .terraform/.terragrunt-cache stay out of the picker) plus an | |||
| 149 | + | // unconditional .git skip, and stops once it hits the entry/depth caps. | |||
| 167 | // | 150 | // | ||
| 168 | // Results are cached by cwd + mtime of cwd. Unlike the flat scan a | 151 | // Results are cached by cwd + mtime of cwd. Unlike the flat scan a | ||
| 169 | // single mtime can't catch every nested change, so the cache is best | 152 | // single mtime can't catch every nested change, so the cache is best | ||
| @@ -180,6 +163,7 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||||
| 180 | return s.cachedAll | 163 | return s.cachedAll | ||
| 181 | } | 164 | } | ||
| 182 | 165 | ||||
| 166 | + | ig := ignore.Load(root) | |||
| 183 | var all []fileEntry | 167 | var all []fileEntry | ||
| 184 | rootSep := strings.Count(root, string(os.PathSeparator)) | 168 | rootSep := strings.Count(root, string(os.PathSeparator)) | ||
| 185 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | 169 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | ||
| @@ -192,19 +176,25 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||||
| 192 | if path == root { | 176 | if path == root { | ||
| 193 | return nil | 177 | return nil | ||
| 194 | } | 178 | } | ||
| 195 | - | name := d.Name() | 179 | + | rel, relErr := filepath.Rel(root, path) |
| 180 | + | if relErr != nil { | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | // .gitignore patterns are matched against slash-separated paths. | |||
| 184 | + | if ig.Match(filepath.ToSlash(rel), d.IsDir()) { | |||
| 185 | + | if d.IsDir() { | |||
| 186 | + | return filepath.SkipDir | |||
| 187 | + | } | |||
| 188 | + | return nil | |||
| 189 | + | } | |||
| 196 | if d.IsDir() { | 190 | if d.IsDir() { | ||
| 197 | - | if recursiveSkipDirs[name] { | 191 | + | if d.Name() == alwaysSkipDir { |
| 198 | return filepath.SkipDir | 192 | return filepath.SkipDir | ||
| 199 | } | 193 | } | ||
| 200 | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | 194 | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | ||
| 201 | return filepath.SkipDir | 195 | return filepath.SkipDir | ||
| 202 | } | 196 | } | ||
| 203 | } | 197 | } | ||
| 204 | - | rel, relErr := filepath.Rel(root, path) | |||
| 205 | - | if relErr != nil { | |||
| 206 | - | return nil | |||
| 207 | - | } | |||
| 208 | all = append(all, fileEntry{ | 198 | all = append(all, fileEntry{ | ||
| 209 | name: rel, | 199 | name: rel, | ||
| 210 | rel: rel, | 200 | rel: rel, | ||
M packages/agent/modes/file_suggest_test.go +24 -11
| @@ -171,20 +171,29 @@ func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) { | |||
| 171 | 171 | } | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | - | // TestFileSuggesterRecursiveSkipsIaCCaches ensures infrastructure-as- | |
| 175 | - | // code provider/module caches are pruned so a recursive walk doesn't | |
| 176 | - | // drown in copies of downloaded providers or generated modules. | |
| 177 | - | func TestFileSuggesterRecursiveSkipsIaCCaches(t *testing.T) { | |
| 174 | + | // TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk | |
| 175 | + | // prunes anything listed in the project's root .gitignore — build | |
| 176 | + | // outputs, dependency dirs, and IaC tool caches like | |
| 177 | + | // .terraform/.terragrunt-cache — while still surfacing tracked files. | |
| 178 | + | func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) { | |
| 178 | 179 | tmp := t.TempDir() | |
| 179 | - | for _, dir := range []string{".terraform", ".terragrunt-cache", "cdk.out"} { | |
| 180 | + | if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), | |
| 181 | + | []byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil { | |
| 182 | + | t.Fatal(err) | |
| 183 | + | } | |
| 184 | + | ignored := []string{".terraform", ".terragrunt-cache", "node_modules"} | |
| 185 | + | for _, dir := range ignored { | |
| 180 | 186 | nested := filepath.Join(tmp, dir, "deep") | |
| 181 | 187 | if err := os.MkdirAll(nested, 0o755); err != nil { | |
| 182 | 188 | t.Fatal(err) | |
| 183 | 189 | } | |
| 184 | - | if err := os.WriteFile(filepath.Join(nested, "junk.tf"), []byte("x"), 0o644); err != nil { | |
| 190 | + | if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil { | |
| 185 | 191 | t.Fatal(err) | |
| 186 | 192 | } | |
| 187 | 193 | } | |
| 194 | + | if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil { | |
| 195 | + | t.Fatal(err) | |
| 196 | + | } | |
| 188 | 197 | if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil { | |
| 189 | 198 | t.Fatal(err) | |
| 190 | 199 | } | |
| @@ -192,15 +201,19 @@ func TestFileSuggesterRecursiveSkipsIaCCaches(t *testing.T) { | |||
| 192 | 201 | s.SetCWD(tmp) | |
| 193 | 202 | s.SetRecursive(true) | |
| 194 | 203 | ||
| 195 | - | for _, e := range s.scan() { | |
| 196 | - | for _, skip := range []string{".terraform", ".terragrunt-cache", "cdk.out"} { | |
| 204 | + | all := s.scan() | |
| 205 | + | for _, e := range all { | |
| 206 | + | for _, skip := range ignored { | |
| 197 | 207 | if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) { | |
| 198 | - | t.Fatalf("recursive scan descended into %s: %#v", skip, e) | |
| 208 | + | t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e) | |
| 199 | 209 | } | |
| 200 | 210 | } | |
| 211 | + | if e.rel == "debug.log" { | |
| 212 | + | t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e) | |
| 213 | + | } | |
| 201 | 214 | } | |
| 202 | - | if !containsEntry(s.scan(), "main.tf", false) { | |
| 203 | - | t.Fatal("recursive scan missing main.tf") | |
| 215 | + | if !containsEntry(all, "main.tf", false) { | |
| 216 | + | t.Fatalf("recursive scan missing tracked main.tf: %#v", all) | |
| 204 | 217 | } | |
| 205 | 218 | } | |
| 206 | 219 | ||
| @@ -171,20 +171,29 @@ func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) { | |||||
| 171 | } | 171 | } | ||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | - | // TestFileSuggesterRecursiveSkipsIaCCaches ensures infrastructure-as- | 174 | + | // TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk |
| 175 | - | // code provider/module caches are pruned so a recursive walk doesn't | 175 | + | // prunes anything listed in the project's root .gitignore — build |
| 176 | - | // drown in copies of downloaded providers or generated modules. | 176 | + | // outputs, dependency dirs, and IaC tool caches like |
| 177 | - | func TestFileSuggesterRecursiveSkipsIaCCaches(t *testing.T) { | 177 | + | // .terraform/.terragrunt-cache — while still surfacing tracked files. |
| 178 | + | func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) { | |||
| 178 | tmp := t.TempDir() | 179 | tmp := t.TempDir() | ||
| 179 | - | for _, dir := range []string{".terraform", ".terragrunt-cache", "cdk.out"} { | 180 | + | if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), |
| 181 | + | []byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil { | |||
| 182 | + | t.Fatal(err) | |||
| 183 | + | } | |||
| 184 | + | ignored := []string{".terraform", ".terragrunt-cache", "node_modules"} | |||
| 185 | + | for _, dir := range ignored { | |||
| 180 | nested := filepath.Join(tmp, dir, "deep") | 186 | nested := filepath.Join(tmp, dir, "deep") | ||
| 181 | if err := os.MkdirAll(nested, 0o755); err != nil { | 187 | if err := os.MkdirAll(nested, 0o755); err != nil { | ||
| 182 | t.Fatal(err) | 188 | t.Fatal(err) | ||
| 183 | } | 189 | } | ||
| 184 | - | if err := os.WriteFile(filepath.Join(nested, "junk.tf"), []byte("x"), 0o644); err != nil { | 190 | + | if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil { |
| 185 | t.Fatal(err) | 191 | t.Fatal(err) | ||
| 186 | } | 192 | } | ||
| 187 | } | 193 | } | ||
| 194 | + | if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil { | |||
| 195 | + | t.Fatal(err) | |||
| 196 | + | } | |||
| 188 | if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil { | 197 | if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil { | ||
| 189 | t.Fatal(err) | 198 | t.Fatal(err) | ||
| 190 | } | 199 | } | ||
| @@ -192,15 +201,19 @@ func TestFileSuggesterRecursiveSkipsIaCCaches(t *testing.T) { | |||||
| 192 | s.SetCWD(tmp) | 201 | s.SetCWD(tmp) | ||
| 193 | s.SetRecursive(true) | 202 | s.SetRecursive(true) | ||
| 194 | 203 | ||||
| 195 | - | for _, e := range s.scan() { | 204 | + | all := s.scan() |
| 196 | - | for _, skip := range []string{".terraform", ".terragrunt-cache", "cdk.out"} { | 205 | + | for _, e := range all { |
| 206 | + | for _, skip := range ignored { | |||
| 197 | if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) { | 207 | if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) { | ||
| 198 | - | t.Fatalf("recursive scan descended into %s: %#v", skip, e) | 208 | + | t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e) |
| 199 | } | 209 | } | ||
| 200 | } | 210 | } | ||
| 211 | + | if e.rel == "debug.log" { | |||
| 212 | + | t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e) | |||
| 213 | + | } | |||
| 201 | } | 214 | } | ||
| 202 | - | if !containsEntry(s.scan(), "main.tf", false) { | 215 | + | if !containsEntry(all, "main.tf", false) { |
| 203 | - | t.Fatal("recursive scan missing main.tf") | 216 | + | t.Fatalf("recursive scan missing tracked main.tf: %#v", all) |
| 204 | } | 217 | } | ||
| 205 | } | 218 | } | ||
| 206 | 219 | ||||
A packages/ignore/gitignore.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | // Package ignore provides a minimal .gitignore matcher shared across | |
| 2 | + | // zot. It is intentionally small: enough to drop obvious non-source | |
| 3 | + | // directories (build outputs, dependency and tool caches) from | |
| 4 | + | // recursive walks, not a faithful git reimplementation. | |
| 5 | + | package ignore | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Gitignore is a minimal .gitignore matcher. It supports the common | |
| 14 | + | // patterns used in real repos: blank lines, comments (#), negation (!), | |
| 15 | + | // directory-only patterns (trailing /), anchored patterns (leading /), | |
| 16 | + | // and the * / ? / [..] wildcards via filepath.Match. It intentionally | |
| 17 | + | // does not implement ** globstar or nested per-directory .gitignore | |
| 18 | + | // files. | |
| 19 | + | type Gitignore struct { | |
| 20 | + | rules []rule | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type rule struct { | |
| 24 | + | pattern string | |
| 25 | + | negate bool | |
| 26 | + | dirOnly bool | |
| 27 | + | anchored bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Load reads the .gitignore at the root directory. A missing or | |
| 31 | + | // unreadable file yields an empty matcher that ignores nothing. | |
| 32 | + | func Load(root string) *Gitignore { | |
| 33 | + | data, err := os.ReadFile(filepath.Join(root, ".gitignore")) | |
| 34 | + | if err != nil { | |
| 35 | + | return &Gitignore{} | |
| 36 | + | } | |
| 37 | + | return Parse(string(data)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Parse builds a matcher from raw .gitignore file contents. | |
| 41 | + | func Parse(data string) *Gitignore { | |
| 42 | + | g := &Gitignore{} | |
| 43 | + | for _, line := range strings.Split(data, "\n") { | |
| 44 | + | line = strings.TrimRight(line, "\r") | |
| 45 | + | trimmed := strings.TrimSpace(line) | |
| 46 | + | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |
| 47 | + | continue | |
| 48 | + | } | |
| 49 | + | r := rule{pattern: trimmed} | |
| 50 | + | if strings.HasPrefix(r.pattern, "!") { | |
| 51 | + | r.negate = true | |
| 52 | + | r.pattern = r.pattern[1:] | |
| 53 | + | } | |
| 54 | + | if strings.HasSuffix(r.pattern, "/") { | |
| 55 | + | r.dirOnly = true | |
| 56 | + | r.pattern = strings.TrimSuffix(r.pattern, "/") | |
| 57 | + | } | |
| 58 | + | if strings.HasPrefix(r.pattern, "/") { | |
| 59 | + | r.anchored = true | |
| 60 | + | r.pattern = strings.TrimPrefix(r.pattern, "/") | |
| 61 | + | } | |
| 62 | + | if r.pattern == "" { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | g.rules = append(g.rules, r) | |
| 66 | + | } | |
| 67 | + | return g | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Match reports whether the slash-separated relative path should be | |
| 71 | + | // ignored. Later rules win, so a trailing negation can re-include a | |
| 72 | + | // previously ignored path. | |
| 73 | + | func (g *Gitignore) Match(rel string, isDir bool) bool { | |
| 74 | + | ignored := false | |
| 75 | + | for _, r := range g.rules { | |
| 76 | + | if r.dirOnly && !isDir { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | if r.matchPath(rel) { | |
| 80 | + | ignored = !r.negate | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return ignored | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (r rule) matchPath(rel string) bool { | |
| 87 | + | if r.anchored || strings.Contains(r.pattern, "/") { | |
| 88 | + | if ok, _ := filepath.Match(r.pattern, rel); ok { | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | // Anchored directory pattern also matches everything beneath it. | |
| 92 | + | return strings.HasPrefix(rel, r.pattern+"/") | |
| 93 | + | } | |
| 94 | + | // Unanchored: match the basename of any path component. | |
| 95 | + | base := rel | |
| 96 | + | if i := strings.LastIndex(rel, "/"); i >= 0 { | |
| 97 | + | base = rel[i+1:] | |
| 98 | + | } | |
| 99 | + | if ok, _ := filepath.Match(r.pattern, base); ok { | |
| 100 | + | return true | |
| 101 | + | } | |
| 102 | + | // Match a directory component anywhere in the path. | |
| 103 | + | for _, part := range strings.Split(rel, "/") { | |
| 104 | + | if ok, _ := filepath.Match(r.pattern, part); ok { | |
| 105 | + | return true | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | return false | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | // Package ignore provides a minimal .gitignore matcher shared across | |||
| 2 | + | // zot. It is intentionally small: enough to drop obvious non-source | |||
| 3 | + | // directories (build outputs, dependency and tool caches) from | |||
| 4 | + | // recursive walks, not a faithful git reimplementation. | |||
| 5 | + | package ignore | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Gitignore is a minimal .gitignore matcher. It supports the common | |||
| 14 | + | // patterns used in real repos: blank lines, comments (#), negation (!), | |||
| 15 | + | // directory-only patterns (trailing /), anchored patterns (leading /), | |||
| 16 | + | // and the * / ? / [..] wildcards via filepath.Match. It intentionally | |||
| 17 | + | // does not implement ** globstar or nested per-directory .gitignore | |||
| 18 | + | // files. | |||
| 19 | + | type Gitignore struct { | |||
| 20 | + | rules []rule | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type rule struct { | |||
| 24 | + | pattern string | |||
| 25 | + | negate bool | |||
| 26 | + | dirOnly bool | |||
| 27 | + | anchored bool | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Load reads the .gitignore at the root directory. A missing or | |||
| 31 | + | // unreadable file yields an empty matcher that ignores nothing. | |||
| 32 | + | func Load(root string) *Gitignore { | |||
| 33 | + | data, err := os.ReadFile(filepath.Join(root, ".gitignore")) | |||
| 34 | + | if err != nil { | |||
| 35 | + | return &Gitignore{} | |||
| 36 | + | } | |||
| 37 | + | return Parse(string(data)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Parse builds a matcher from raw .gitignore file contents. | |||
| 41 | + | func Parse(data string) *Gitignore { | |||
| 42 | + | g := &Gitignore{} | |||
| 43 | + | for _, line := range strings.Split(data, "\n") { | |||
| 44 | + | line = strings.TrimRight(line, "\r") | |||
| 45 | + | trimmed := strings.TrimSpace(line) | |||
| 46 | + | if trimmed == "" || strings.HasPrefix(trimmed, "#") { | |||
| 47 | + | continue | |||
| 48 | + | } | |||
| 49 | + | r := rule{pattern: trimmed} | |||
| 50 | + | if strings.HasPrefix(r.pattern, "!") { | |||
| 51 | + | r.negate = true | |||
| 52 | + | r.pattern = r.pattern[1:] | |||
| 53 | + | } | |||
| 54 | + | if strings.HasSuffix(r.pattern, "/") { | |||
| 55 | + | r.dirOnly = true | |||
| 56 | + | r.pattern = strings.TrimSuffix(r.pattern, "/") | |||
| 57 | + | } | |||
| 58 | + | if strings.HasPrefix(r.pattern, "/") { | |||
| 59 | + | r.anchored = true | |||
| 60 | + | r.pattern = strings.TrimPrefix(r.pattern, "/") | |||
| 61 | + | } | |||
| 62 | + | if r.pattern == "" { | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 65 | + | g.rules = append(g.rules, r) | |||
| 66 | + | } | |||
| 67 | + | return g | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Match reports whether the slash-separated relative path should be | |||
| 71 | + | // ignored. Later rules win, so a trailing negation can re-include a | |||
| 72 | + | // previously ignored path. | |||
| 73 | + | func (g *Gitignore) Match(rel string, isDir bool) bool { | |||
| 74 | + | ignored := false | |||
| 75 | + | for _, r := range g.rules { | |||
| 76 | + | if r.dirOnly && !isDir { | |||
| 77 | + | continue | |||
| 78 | + | } | |||
| 79 | + | if r.matchPath(rel) { | |||
| 80 | + | ignored = !r.negate | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | return ignored | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (r rule) matchPath(rel string) bool { | |||
| 87 | + | if r.anchored || strings.Contains(r.pattern, "/") { | |||
| 88 | + | if ok, _ := filepath.Match(r.pattern, rel); ok { | |||
| 89 | + | return true | |||
| 90 | + | } | |||
| 91 | + | // Anchored directory pattern also matches everything beneath it. | |||
| 92 | + | return strings.HasPrefix(rel, r.pattern+"/") | |||
| 93 | + | } | |||
| 94 | + | // Unanchored: match the basename of any path component. | |||
| 95 | + | base := rel | |||
| 96 | + | if i := strings.LastIndex(rel, "/"); i >= 0 { | |||
| 97 | + | base = rel[i+1:] | |||
| 98 | + | } | |||
| 99 | + | if ok, _ := filepath.Match(r.pattern, base); ok { | |||
| 100 | + | return true | |||
| 101 | + | } | |||
| 102 | + | // Match a directory component anywhere in the path. | |||
| 103 | + | for _, part := range strings.Split(rel, "/") { | |||
| 104 | + | if ok, _ := filepath.Match(r.pattern, part); ok { | |||
| 105 | + | return true | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return false | |||
| 109 | + | } | |||
A packages/ignore/gitignore_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package ignore | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestParseAndMatch(t *testing.T) { | |
| 6 | + | g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log")) | |
| 7 | + | ||
| 8 | + | cases := []struct { | |
| 9 | + | rel string | |
| 10 | + | isDir bool | |
| 11 | + | want bool | |
| 12 | + | }{ | |
| 13 | + | {".terraform", true, true}, | |
| 14 | + | // A dirOnly rule matches the directory itself; the walk prunes | |
| 15 | + | // descent on that match, so children are never tested. A file | |
| 16 | + | // path under it is therefore not matched directly by the rule. | |
| 17 | + | {".terraform/providers/x", false, false}, | |
| 18 | + | {".terragrunt-cache", true, true}, | |
| 19 | + | {"modules/.terragrunt-cache", true, true}, | |
| 20 | + | {"node_modules", true, true}, | |
| 21 | + | {"src/node_modules/pkg", true, true}, | |
| 22 | + | {"debug.log", false, true}, | |
| 23 | + | {"keep.log", false, false}, // re-included by negation | |
| 24 | + | {"build", true, true}, // anchored | |
| 25 | + | {"sub/build", true, false}, // anchored: only at root | |
| 26 | + | {"main.tf", false, false}, | |
| 27 | + | {"src/app.go", false, false}, | |
| 28 | + | } | |
| 29 | + | for _, c := range cases { | |
| 30 | + | if got := g.Match(c.rel, c.isDir); got != c.want { | |
| 31 | + | t.Errorf("Match(%q, dir=%v) = %v, want %v", c.rel, c.isDir, got, c.want) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestEmptyIgnoresNothing(t *testing.T) { | |
| 37 | + | g := Parse("") | |
| 38 | + | if g.Match("anything", false) || g.Match("dir", true) { | |
| 39 | + | t.Fatal("empty matcher should ignore nothing") | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // lines joins fixture lines with newlines for readable .gitignore | |
| 44 | + | // fixtures. | |
| 45 | + | func lines(ls ...string) string { | |
| 46 | + | out := "" | |
| 47 | + | for i, l := range ls { | |
| 48 | + | if i > 0 { | |
| 49 | + | out += "\n" | |
| 50 | + | } | |
| 51 | + | out += l | |
| 52 | + | } | |
| 53 | + | return out | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package ignore | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestParseAndMatch(t *testing.T) { | |||
| 6 | + | g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log")) | |||
| 7 | + | ||||
| 8 | + | cases := []struct { | |||
| 9 | + | rel string | |||
| 10 | + | isDir bool | |||
| 11 | + | want bool | |||
| 12 | + | }{ | |||
| 13 | + | {".terraform", true, true}, | |||
| 14 | + | // A dirOnly rule matches the directory itself; the walk prunes | |||
| 15 | + | // descent on that match, so children are never tested. A file | |||
| 16 | + | // path under it is therefore not matched directly by the rule. | |||
| 17 | + | {".terraform/providers/x", false, false}, | |||
| 18 | + | {".terragrunt-cache", true, true}, | |||
| 19 | + | {"modules/.terragrunt-cache", true, true}, | |||
| 20 | + | {"node_modules", true, true}, | |||
| 21 | + | {"src/node_modules/pkg", true, true}, | |||
| 22 | + | {"debug.log", false, true}, | |||
| 23 | + | {"keep.log", false, false}, // re-included by negation | |||
| 24 | + | {"build", true, true}, // anchored | |||
| 25 | + | {"sub/build", true, false}, // anchored: only at root | |||
| 26 | + | {"main.tf", false, false}, | |||
| 27 | + | {"src/app.go", false, false}, | |||
| 28 | + | } | |||
| 29 | + | for _, c := range cases { | |||
| 30 | + | if got := g.Match(c.rel, c.isDir); got != c.want { | |||
| 31 | + | t.Errorf("Match(%q, dir=%v) = %v, want %v", c.rel, c.isDir, got, c.want) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestEmptyIgnoresNothing(t *testing.T) { | |||
| 37 | + | g := Parse("") | |||
| 38 | + | if g.Match("anything", false) || g.Match("dir", true) { | |||
| 39 | + | t.Fatal("empty matcher should ignore nothing") | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // lines joins fixture lines with newlines for readable .gitignore | |||
| 44 | + | // fixtures. | |||
| 45 | + | func lines(ls ...string) string { | |||
| 46 | + | out := "" | |||
| 47 | + | for i, l := range ls { | |||
| 48 | + | if i > 0 { | |||
| 49 | + | out += "\n" | |||
| 50 | + | } | |||
| 51 | + | out += l | |||
| 52 | + | } | |||
| 53 | + | return out | |||
| 54 | + | } | |||