detached 3ce9c28

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 (
88 "os/exec"
99 "path/filepath"
1010 "strings"
11+
12+ "github.com/patriceckhart/zot/packages/ignore"
1113 )
1214
1315 // runExtCommand dispatches `zot ext ...` subcommands. Returns
@@ -344,7 +346,7 @@ func copyDir(src, dst string) error {
344346 if info.IsDir() && name == ".git" {
345347 return filepath.SkipDir
346348 }
347- if ig.match(filepath.ToSlash(rel), info.IsDir()) {
349+ if ig.Match(filepath.ToSlash(rel), info.IsDir()) {
348350 if info.IsDir() {
349351 return filepath.SkipDir
350352 }
@@ -370,98 +372,11 @@ func copyDir(src, dst string) error {
370372 })
371373 }
372374
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
427379
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) }
443381
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. Returns15 // 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.SkipDir347 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.SkipDir351 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 common375+// 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 /), anchored377+// thin aliases keep the existing call sites (and tests) terse.
376-// patterns (leading /), and the * / ? / [..] wildcards via378+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 be380+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) {
117117
118118 func TestGitignoreNegation(t *testing.T) {
119119 g := loadGitignoreFromString("build/\n!build/keep.txt\n")
120- if !g.match("build", true) {
120+ if !g.Match("build", true) {
121121 t.Fatal("expected build/ dir to be ignored")
122122 }
123- if g.match("build/keep.txt", false) {
123+ if g.Match("build/keep.txt", false) {
124124 t.Fatal("expected build/keep.txt to be re-included by negation")
125125 }
126126 }
@@ -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 (
1010
1111 "github.com/sahilm/fuzzy"
1212
13+ "github.com/patriceckhart/zot/packages/ignore"
1314 "github.com/patriceckhart/zot/packages/tui"
1415 )
1516
@@ -21,31 +22,11 @@ const (
2122 maxRecursiveDepth = 12
2223 )
2324
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"
4930
5031 // fileSuggester provides an @-triggered file/directory picker popup.
5132 // Type "@" followed by an optional filter to list files in the working
@@ -162,8 +143,10 @@ func (s *fileSuggester) scan() []fileEntry {
162143
163144 // scanRecursive walks the whole project tree below cwd and returns
164145 // 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.
167150 //
168151 // Results are cached by cwd + mtime of cwd. Unlike the flat scan a
169152 // single mtime can't catch every nested change, so the cache is best
@@ -180,6 +163,7 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
180163 return s.cachedAll
181164 }
182165
166+ ig := ignore.Load(root)
183167 var all []fileEntry
184168 rootSep := strings.Count(root, string(os.PathSeparator))
185169 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
@@ -192,19 +176,25 @@ func (s *fileSuggester) scanRecursive() []fileEntry {
192176 if path == root {
193177 return nil
194178 }
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+ }
196190 if d.IsDir() {
197- if recursiveSkipDirs[name] {
191+ if d.Name() == alwaysSkipDir {
198192 return filepath.SkipDir
199193 }
200194 if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth {
201195 return filepath.SkipDir
202196 }
203197 }
204- rel, relErr := filepath.Rel(root, path)
205- if relErr != nil {
206- return nil
207- }
208198 all = append(all, fileEntry{
209199 name: rel,
210200 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 = 1222 maxRecursiveDepth = 12
22 )23 )
23 24
24-// recursiveSkipDirs are directory names never descended into during a25+// alwaysSkipDir is never descended into during a recursive scan,
25-// recursive scan. They dominate the entry budget without being useful26+// 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 working32 // 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 returns144 // scanRecursive walks the whole project tree below cwd and returns
164 // every file and directory as a fileEntry whose rel is the path145 // 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 a151 // 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 best152 // 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.cachedAll163 return s.cachedAll
181 }164 }
182 165
166+ ig := ignore.Load(root)
183 var all []fileEntry167 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 nil177 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.SkipDir192 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.SkipDir195 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) {
171171 }
172172 }
173173
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) {
178179 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 {
180186 nested := filepath.Join(tmp, dir, "deep")
181187 if err := os.MkdirAll(nested, 0o755); err != nil {
182188 t.Fatal(err)
183189 }
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 {
185191 t.Fatal(err)
186192 }
187193 }
194+ if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
195+ t.Fatal(err)
196+ }
188197 if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
189198 t.Fatal(err)
190199 }
@@ -192,15 +201,19 @@ func TestFileSuggesterRecursiveSkipsIaCCaches(t *testing.T) {
192201 s.SetCWD(tmp)
193202 s.SetRecursive(true)
194203
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 {
197207 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)
199209 }
200210 }
211+ if e.rel == "debug.log" {
212+ t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
213+ }
201214 }
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)
204217 }
205218 }
206219
@@ -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't175+// 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close