Merge pull request #28 from rgasper/file-picker-improvements
File picker improvements
5 files · +348 -13
M packages/agent/modes/file_suggest.go +52 -5
| @@ -17,9 +17,21 @@ import ( | |||
| 17 | 17 | // recursiveScanLimits bound the recursive walk so the picker stays | |
| 18 | 18 | // responsive in very large repos. Hitting either cap stops the walk | |
| 19 | 19 | // early; the entries gathered so far are still searchable. | |
| 20 | + | // | |
| 21 | + | // The entry cap is set against the real bottleneck, which is not memory | |
| 22 | + | // but the per-keystroke fuzzy.Find ranking that scores every entry. A | |
| 23 | + | // fileEntry is ~120 bytes all-in (40-byte struct header plus the path | |
| 24 | + | // string), so even 50k entries is only ~6 MB. fuzzy.Find scales roughly | |
| 25 | + | // linearly: ~2 ms at 5k, ~13 ms at 50k, ~21 ms at 100k on a typical | |
| 26 | + | // laptop. 50k keeps ranking under one 60 Hz frame (~16 ms) while | |
| 27 | + | // comfortably holding a large monorepo once nested .gitignore pruning | |
| 28 | + | // (node_modules, build outputs, tool caches) has done its job. The | |
| 29 | + | // depth cap likewise guards against pathologically deep trees; 24 | |
| 30 | + | // levels is far below anything a human navigates yet still reaches the | |
| 31 | + | // deeply nested source files real monorepos bury. | |
| 20 | 32 | const ( | |
| 21 | - | maxRecursiveEntries = 5000 | |
| 22 | - | maxRecursiveDepth = 12 | |
| 33 | + | maxRecursiveEntries = 50000 | |
| 34 | + | maxRecursiveDepth = 24 | |
| 23 | 35 | ) | |
| 24 | 36 | ||
| 25 | 37 | // alwaysSkipDir is never descended into during a recursive scan, | |
| @@ -195,12 +207,22 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 195 | 207 | return s.cachedAll | |
| 196 | 208 | } | |
| 197 | 209 | ||
| 198 | - | var ig *ignore.Gitignore | |
| 210 | + | var stack *ignore.Stack | |
| 199 | 211 | if s.respectGitignore { | |
| 200 | - | ig = ignore.Load(root) | |
| 212 | + | stack = ignore.NewStack(root) | |
| 201 | 213 | } | |
| 202 | 214 | var all []fileEntry | |
| 203 | 215 | rootSep := strings.Count(root, string(os.PathSeparator)) | |
| 216 | + | // pushed mirrors the directories whose nested .gitignore we've | |
| 217 | + | // brought into scope on stack but not yet dropped. WalkDir is | |
| 218 | + | // depth-first in lexical order, so before each entry we pop frames | |
| 219 | + | // for directories we've finished walking (those that are no longer | |
| 220 | + | // an ancestor of the current path), then push the current | |
| 221 | + | // directory. Honoring nested .gitignore files is what stops a | |
| 222 | + | // vendored node_modules whose ignore rule lives in a subdirectory | |
| 223 | + | // (e.g. .opencode/.gitignore) from swamping the entry budget before | |
| 224 | + | // the walk ever reaches the user's deeply nested source file. | |
| 225 | + | var pushed []string | |
| 204 | 226 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| 205 | 227 | if err != nil { | |
| 206 | 228 | if d != nil && d.IsDir() { | |
| @@ -215,9 +237,28 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 215 | 237 | if relErr != nil { | |
| 216 | 238 | return nil | |
| 217 | 239 | } | |
| 240 | + | relSlash := filepath.ToSlash(rel) | |
| 218 | 241 | if s.respectGitignore { | |
| 242 | + | // Drop stack frames for directories we've finished walking: | |
| 243 | + | // any pushed dir that is not an ancestor of this entry's dir. | |
| 244 | + | dirSlash := relSlash | |
| 245 | + | if !d.IsDir() { | |
| 246 | + | if i := strings.LastIndex(relSlash, "/"); i >= 0 { | |
| 247 | + | dirSlash = relSlash[:i] | |
| 248 | + | } else { | |
| 249 | + | dirSlash = "" | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | for len(pushed) > 0 { | |
| 253 | + | top := pushed[len(pushed)-1] | |
| 254 | + | if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") { | |
| 255 | + | break | |
| 256 | + | } | |
| 257 | + | pushed = pushed[:len(pushed)-1] | |
| 258 | + | stack.Pop() | |
| 259 | + | } | |
| 219 | 260 | // .gitignore patterns are matched against slash-separated paths. | |
| 220 | - | if ig.Match(filepath.ToSlash(rel), d.IsDir()) { | |
| 261 | + | if stack.Match(relSlash, d.IsDir()) { | |
| 221 | 262 | if d.IsDir() { | |
| 222 | 263 | return filepath.SkipDir | |
| 223 | 264 | } | |
| @@ -234,6 +275,12 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||
| 234 | 275 | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | |
| 235 | 276 | return filepath.SkipDir | |
| 236 | 277 | } | |
| 278 | + | // This directory is kept; bring its nested .gitignore (if | |
| 279 | + | // any) into scope for the descendants we're about to visit. | |
| 280 | + | if s.respectGitignore { | |
| 281 | + | stack.Push(path, rel) | |
| 282 | + | pushed = append(pushed, relSlash) | |
| 283 | + | } | |
| 237 | 284 | } | |
| 238 | 285 | all = append(all, fileEntry{ | |
| 239 | 286 | name: rel, | |
| @@ -17,9 +17,21 @@ import ( | |||||
| 17 | // recursiveScanLimits bound the recursive walk so the picker stays | 17 | // recursiveScanLimits bound the recursive walk so the picker stays | ||
| 18 | // responsive in very large repos. Hitting either cap stops the walk | 18 | // responsive in very large repos. Hitting either cap stops the walk | ||
| 19 | // early; the entries gathered so far are still searchable. | 19 | // early; the entries gathered so far are still searchable. | ||
| 20 | + | // | |||
| 21 | + | // The entry cap is set against the real bottleneck, which is not memory | |||
| 22 | + | // but the per-keystroke fuzzy.Find ranking that scores every entry. A | |||
| 23 | + | // fileEntry is ~120 bytes all-in (40-byte struct header plus the path | |||
| 24 | + | // string), so even 50k entries is only ~6 MB. fuzzy.Find scales roughly | |||
| 25 | + | // linearly: ~2 ms at 5k, ~13 ms at 50k, ~21 ms at 100k on a typical | |||
| 26 | + | // laptop. 50k keeps ranking under one 60 Hz frame (~16 ms) while | |||
| 27 | + | // comfortably holding a large monorepo once nested .gitignore pruning | |||
| 28 | + | // (node_modules, build outputs, tool caches) has done its job. The | |||
| 29 | + | // depth cap likewise guards against pathologically deep trees; 24 | |||
| 30 | + | // levels is far below anything a human navigates yet still reaches the | |||
| 31 | + | // deeply nested source files real monorepos bury. | |||
| 20 | const ( | 32 | const ( | ||
| 21 | - | maxRecursiveEntries = 5000 | 33 | + | maxRecursiveEntries = 50000 |
| 22 | - | maxRecursiveDepth = 12 | 34 | + | maxRecursiveDepth = 24 |
| 23 | ) | 35 | ) | ||
| 24 | 36 | ||||
| 25 | // alwaysSkipDir is never descended into during a recursive scan, | 37 | // alwaysSkipDir is never descended into during a recursive scan, | ||
| @@ -195,12 +207,22 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||||
| 195 | return s.cachedAll | 207 | return s.cachedAll | ||
| 196 | } | 208 | } | ||
| 197 | 209 | ||||
| 198 | - | var ig *ignore.Gitignore | 210 | + | var stack *ignore.Stack |
| 199 | if s.respectGitignore { | 211 | if s.respectGitignore { | ||
| 200 | - | ig = ignore.Load(root) | 212 | + | stack = ignore.NewStack(root) |
| 201 | } | 213 | } | ||
| 202 | var all []fileEntry | 214 | var all []fileEntry | ||
| 203 | rootSep := strings.Count(root, string(os.PathSeparator)) | 215 | rootSep := strings.Count(root, string(os.PathSeparator)) | ||
| 216 | + | // pushed mirrors the directories whose nested .gitignore we've | |||
| 217 | + | // brought into scope on stack but not yet dropped. WalkDir is | |||
| 218 | + | // depth-first in lexical order, so before each entry we pop frames | |||
| 219 | + | // for directories we've finished walking (those that are no longer | |||
| 220 | + | // an ancestor of the current path), then push the current | |||
| 221 | + | // directory. Honoring nested .gitignore files is what stops a | |||
| 222 | + | // vendored node_modules whose ignore rule lives in a subdirectory | |||
| 223 | + | // (e.g. .opencode/.gitignore) from swamping the entry budget before | |||
| 224 | + | // the walk ever reaches the user's deeply nested source file. | |||
| 225 | + | var pushed []string | |||
| 204 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | 226 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | ||
| 205 | if err != nil { | 227 | if err != nil { | ||
| 206 | if d != nil && d.IsDir() { | 228 | if d != nil && d.IsDir() { | ||
| @@ -215,9 +237,28 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||||
| 215 | if relErr != nil { | 237 | if relErr != nil { | ||
| 216 | return nil | 238 | return nil | ||
| 217 | } | 239 | } | ||
| 240 | + | relSlash := filepath.ToSlash(rel) | |||
| 218 | if s.respectGitignore { | 241 | if s.respectGitignore { | ||
| 242 | + | // Drop stack frames for directories we've finished walking: | |||
| 243 | + | // any pushed dir that is not an ancestor of this entry's dir. | |||
| 244 | + | dirSlash := relSlash | |||
| 245 | + | if !d.IsDir() { | |||
| 246 | + | if i := strings.LastIndex(relSlash, "/"); i >= 0 { | |||
| 247 | + | dirSlash = relSlash[:i] | |||
| 248 | + | } else { | |||
| 249 | + | dirSlash = "" | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | for len(pushed) > 0 { | |||
| 253 | + | top := pushed[len(pushed)-1] | |||
| 254 | + | if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") { | |||
| 255 | + | break | |||
| 256 | + | } | |||
| 257 | + | pushed = pushed[:len(pushed)-1] | |||
| 258 | + | stack.Pop() | |||
| 259 | + | } | |||
| 219 | // .gitignore patterns are matched against slash-separated paths. | 260 | // .gitignore patterns are matched against slash-separated paths. | ||
| 220 | - | if ig.Match(filepath.ToSlash(rel), d.IsDir()) { | 261 | + | if stack.Match(relSlash, d.IsDir()) { |
| 221 | if d.IsDir() { | 262 | if d.IsDir() { | ||
| 222 | return filepath.SkipDir | 263 | return filepath.SkipDir | ||
| 223 | } | 264 | } | ||
| @@ -234,6 +275,12 @@ func (s *fileSuggester) scanRecursive() []fileEntry { | |||||
| 234 | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | 275 | if strings.Count(path, string(os.PathSeparator))-rootSep >= maxRecursiveDepth { | ||
| 235 | return filepath.SkipDir | 276 | return filepath.SkipDir | ||
| 236 | } | 277 | } | ||
| 278 | + | // This directory is kept; bring its nested .gitignore (if | |||
| 279 | + | // any) into scope for the descendants we're about to visit. | |||
| 280 | + | if s.respectGitignore { | |||
| 281 | + | stack.Push(path, rel) | |||
| 282 | + | pushed = append(pushed, relSlash) | |||
| 283 | + | } | |||
| 237 | } | 284 | } | ||
| 238 | all = append(all, fileEntry{ | 285 | all = append(all, fileEntry{ | ||
| 239 | name: rel, | 286 | name: rel, | ||
M packages/agent/modes/file_suggest_test.go +103 -0
| @@ -79,6 +79,61 @@ func TestFileSuggesterPicksUpNewEntries(t *testing.T) { | |||
| 79 | 79 | } | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | + | // TestFileSuggesterRecursiveHonorsNestedGitignore reproduces the | |
| 83 | + | // reported bug: a vendored tool directory (.opencode) carries its own | |
| 84 | + | // .gitignore that excludes node_modules, but the repo root .gitignore | |
| 85 | + | // says nothing about it. Before nested .gitignore support the | |
| 86 | + | // recursive walk surfaced thousands of node_modules files and a deeply | |
| 87 | + | // nested real source file (eda/rjg/enk-1150/pipeline.py) could not be | |
| 88 | + | // found. The walk must prune the nested-ignored tree and surface the | |
| 89 | + | // deep file. | |
| 90 | + | func TestFileSuggesterRecursiveHonorsNestedGitignore(t *testing.T) { | |
| 91 | + | tmp := t.TempDir() | |
| 92 | + | // Root .gitignore knows nothing about node_modules. | |
| 93 | + | if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | // .opencode/.gitignore ignores its own node_modules. | |
| 97 | + | opencodeNM := filepath.Join(tmp, ".opencode", "node_modules", "zod", "src") | |
| 98 | + | if err := os.MkdirAll(opencodeNM, 0o755); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | if err := os.WriteFile(filepath.Join(tmp, ".opencode", ".gitignore"), []byte("node_modules\n"), 0o644); err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | if err := os.WriteFile(filepath.Join(opencodeNM, "pipeline.test.ts"), []byte("x"), 0o644); err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | // The deeply nested real file the user is hunting for. | |
| 108 | + | deep := filepath.Join(tmp, "eda", "rjg", "enk-1150") | |
| 109 | + | if err := os.MkdirAll(deep, 0o755); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | if err := os.WriteFile(filepath.Join(deep, "pipeline.py"), []byte("x"), 0o644); err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | s := newFileSuggester() | |
| 117 | + | s.SetCWD(tmp) | |
| 118 | + | s.SetRecursive(true) | |
| 119 | + | ||
| 120 | + | all := s.scan() | |
| 121 | + | for _, e := range all { | |
| 122 | + | if strings.Contains(filepath.ToSlash(e.rel), "node_modules") { | |
| 123 | + | t.Fatalf("recursive scan surfaced nested-gitignored node_modules: %#v", e) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | rel := filepath.Join("eda", "rjg", "enk-1150", "pipeline.py") | |
| 127 | + | if !containsEntry(all, rel, false) { | |
| 128 | + | t.Fatalf("recursive scan missing deep pipeline.py: %#v", all) | |
| 129 | + | } | |
| 130 | + | // The fuzzy query should now find the real file. | |
| 131 | + | got := s.matches("@pipeline.py") | |
| 132 | + | if !containsEntry(got, rel, false) { | |
| 133 | + | t.Fatalf("@pipeline.py did not match the deep file: %#v", got) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 82 | 137 | func containsEntry(entries []fileEntry, name string, isDir bool) bool { | |
| 83 | 138 | for _, e := range entries { | |
| 84 | 139 | if e.name == name && e.isDir == isDir { | |
| @@ -121,6 +176,54 @@ func TestFileSuggesterFuzzyMatch(t *testing.T) { | |||
| 121 | 176 | } | |
| 122 | 177 | } | |
| 123 | 178 | ||
| 179 | + | // TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter reproduces the | |
| 180 | + | // reported bug: in flat (non-recursive) mode, typing "@eda" to find a | |
| 181 | + | // directory then opening it with Right must show that directory's | |
| 182 | + | // contents, not re-apply the "eda" filter inside it (which matches | |
| 183 | + | // nothing). The interactive layer clears the @-query when descending, | |
| 184 | + | // so here we model that by browsing with Right and then matching an | |
| 185 | + | // empty query against the new level. | |
| 186 | + | func TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter(t *testing.T) { | |
| 187 | + | tmp := t.TempDir() | |
| 188 | + | // eda/rjg/enk-1150 with a file inside, plus a sibling so the filter | |
| 189 | + | // is meaningful at the top level. | |
| 190 | + | if err := os.MkdirAll(filepath.Join(tmp, "eda", "rjg", "enk-1150"), 0o755); err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | if err := os.WriteFile(filepath.Join(tmp, "eda", "rjg", "enk-1150", "pipeline.py"), []byte("x"), 0o644); err != nil { | |
| 194 | + | t.Fatal(err) | |
| 195 | + | } | |
| 196 | + | if err := os.MkdirAll(filepath.Join(tmp, "unrelated"), 0o755); err != nil { | |
| 197 | + | t.Fatal(err) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | s := newFileSuggester() | |
| 201 | + | s.SetCWD(tmp) // flat mode | |
| 202 | + | ||
| 203 | + | // Top level: "@eda" highlights the eda/ directory. Render populates | |
| 204 | + | // lastMatches, which Right/Left act on (it runs every frame before | |
| 205 | + | // key handling in the live UI). | |
| 206 | + | s.lastMatches = s.matches("@eda") | |
| 207 | + | if !containsEntry(s.lastMatches, "eda", true) { | |
| 208 | + | t.Fatalf("@eda did not match eda/: %#v", s.lastMatches) | |
| 209 | + | } | |
| 210 | + | s.cursor = 0 // eda/ is the (only) match, selected. | |
| 211 | + | ||
| 212 | + | // Open it. After the interactive layer clears the query, the picker | |
| 213 | + | // is browsing eda/ with an empty filter and must show rjg/. | |
| 214 | + | if !s.Right() { | |
| 215 | + | t.Fatal("Right() did not open eda/") | |
| 216 | + | } | |
| 217 | + | if got := s.matches("@"); !containsEntry(got, "rjg", true) { | |
| 218 | + | t.Fatalf("after opening eda/, empty filter did not show rjg/: %#v", got) | |
| 219 | + | } | |
| 220 | + | // The stale filter would have shown nothing: confirm that's the | |
| 221 | + | // behavior the fix avoids. | |
| 222 | + | if got := s.matches("@eda"); len(got) != 0 { | |
| 223 | + | t.Fatalf("stale @eda filter inside eda/ unexpectedly matched: %#v", got) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 124 | 227 | // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the | |
| 125 | 228 | // tree and matches against the cwd-relative path, so a pattern can | |
| 126 | 229 | // span directory boundaries. | |
| @@ -79,6 +79,61 @@ func TestFileSuggesterPicksUpNewEntries(t *testing.T) { | |||||
| 79 | } | 79 | } | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | // TestFileSuggesterRecursiveHonorsNestedGitignore reproduces the | |||
| 83 | + | // reported bug: a vendored tool directory (.opencode) carries its own | |||
| 84 | + | // .gitignore that excludes node_modules, but the repo root .gitignore | |||
| 85 | + | // says nothing about it. Before nested .gitignore support the | |||
| 86 | + | // recursive walk surfaced thousands of node_modules files and a deeply | |||
| 87 | + | // nested real source file (eda/rjg/enk-1150/pipeline.py) could not be | |||
| 88 | + | // found. The walk must prune the nested-ignored tree and surface the | |||
| 89 | + | // deep file. | |||
| 90 | + | func TestFileSuggesterRecursiveHonorsNestedGitignore(t *testing.T) { | |||
| 91 | + | tmp := t.TempDir() | |||
| 92 | + | // Root .gitignore knows nothing about node_modules. | |||
| 93 | + | if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | // .opencode/.gitignore ignores its own node_modules. | |||
| 97 | + | opencodeNM := filepath.Join(tmp, ".opencode", "node_modules", "zod", "src") | |||
| 98 | + | if err := os.MkdirAll(opencodeNM, 0o755); err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | if err := os.WriteFile(filepath.Join(tmp, ".opencode", ".gitignore"), []byte("node_modules\n"), 0o644); err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | if err := os.WriteFile(filepath.Join(opencodeNM, "pipeline.test.ts"), []byte("x"), 0o644); err != nil { | |||
| 105 | + | t.Fatal(err) | |||
| 106 | + | } | |||
| 107 | + | // The deeply nested real file the user is hunting for. | |||
| 108 | + | deep := filepath.Join(tmp, "eda", "rjg", "enk-1150") | |||
| 109 | + | if err := os.MkdirAll(deep, 0o755); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | if err := os.WriteFile(filepath.Join(deep, "pipeline.py"), []byte("x"), 0o644); err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | s := newFileSuggester() | |||
| 117 | + | s.SetCWD(tmp) | |||
| 118 | + | s.SetRecursive(true) | |||
| 119 | + | ||||
| 120 | + | all := s.scan() | |||
| 121 | + | for _, e := range all { | |||
| 122 | + | if strings.Contains(filepath.ToSlash(e.rel), "node_modules") { | |||
| 123 | + | t.Fatalf("recursive scan surfaced nested-gitignored node_modules: %#v", e) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | rel := filepath.Join("eda", "rjg", "enk-1150", "pipeline.py") | |||
| 127 | + | if !containsEntry(all, rel, false) { | |||
| 128 | + | t.Fatalf("recursive scan missing deep pipeline.py: %#v", all) | |||
| 129 | + | } | |||
| 130 | + | // The fuzzy query should now find the real file. | |||
| 131 | + | got := s.matches("@pipeline.py") | |||
| 132 | + | if !containsEntry(got, rel, false) { | |||
| 133 | + | t.Fatalf("@pipeline.py did not match the deep file: %#v", got) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 82 | func containsEntry(entries []fileEntry, name string, isDir bool) bool { | 137 | func containsEntry(entries []fileEntry, name string, isDir bool) bool { | ||
| 83 | for _, e := range entries { | 138 | for _, e := range entries { | ||
| 84 | if e.name == name && e.isDir == isDir { | 139 | if e.name == name && e.isDir == isDir { | ||
| @@ -121,6 +176,54 @@ func TestFileSuggesterFuzzyMatch(t *testing.T) { | |||||
| 121 | } | 176 | } | ||
| 122 | } | 177 | } | ||
| 123 | 178 | ||||
| 179 | + | // TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter reproduces the | |||
| 180 | + | // reported bug: in flat (non-recursive) mode, typing "@eda" to find a | |||
| 181 | + | // directory then opening it with Right must show that directory's | |||
| 182 | + | // contents, not re-apply the "eda" filter inside it (which matches | |||
| 183 | + | // nothing). The interactive layer clears the @-query when descending, | |||
| 184 | + | // so here we model that by browsing with Right and then matching an | |||
| 185 | + | // empty query against the new level. | |||
| 186 | + | func TestFileSuggesterFlatBrowseIntoDirIgnoresStaleFilter(t *testing.T) { | |||
| 187 | + | tmp := t.TempDir() | |||
| 188 | + | // eda/rjg/enk-1150 with a file inside, plus a sibling so the filter | |||
| 189 | + | // is meaningful at the top level. | |||
| 190 | + | if err := os.MkdirAll(filepath.Join(tmp, "eda", "rjg", "enk-1150"), 0o755); err != nil { | |||
| 191 | + | t.Fatal(err) | |||
| 192 | + | } | |||
| 193 | + | if err := os.WriteFile(filepath.Join(tmp, "eda", "rjg", "enk-1150", "pipeline.py"), []byte("x"), 0o644); err != nil { | |||
| 194 | + | t.Fatal(err) | |||
| 195 | + | } | |||
| 196 | + | if err := os.MkdirAll(filepath.Join(tmp, "unrelated"), 0o755); err != nil { | |||
| 197 | + | t.Fatal(err) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | s := newFileSuggester() | |||
| 201 | + | s.SetCWD(tmp) // flat mode | |||
| 202 | + | ||||
| 203 | + | // Top level: "@eda" highlights the eda/ directory. Render populates | |||
| 204 | + | // lastMatches, which Right/Left act on (it runs every frame before | |||
| 205 | + | // key handling in the live UI). | |||
| 206 | + | s.lastMatches = s.matches("@eda") | |||
| 207 | + | if !containsEntry(s.lastMatches, "eda", true) { | |||
| 208 | + | t.Fatalf("@eda did not match eda/: %#v", s.lastMatches) | |||
| 209 | + | } | |||
| 210 | + | s.cursor = 0 // eda/ is the (only) match, selected. | |||
| 211 | + | ||||
| 212 | + | // Open it. After the interactive layer clears the query, the picker | |||
| 213 | + | // is browsing eda/ with an empty filter and must show rjg/. | |||
| 214 | + | if !s.Right() { | |||
| 215 | + | t.Fatal("Right() did not open eda/") | |||
| 216 | + | } | |||
| 217 | + | if got := s.matches("@"); !containsEntry(got, "rjg", true) { | |||
| 218 | + | t.Fatalf("after opening eda/, empty filter did not show rjg/: %#v", got) | |||
| 219 | + | } | |||
| 220 | + | // The stale filter would have shown nothing: confirm that's the | |||
| 221 | + | // behavior the fix avoids. | |||
| 222 | + | if got := s.matches("@eda"); len(got) != 0 { | |||
| 223 | + | t.Fatalf("stale @eda filter inside eda/ unexpectedly matched: %#v", got) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 124 | // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the | 227 | // TestFileSuggesterRecursiveMatch verifies recursive mode flattens the | ||
| 125 | // tree and matches against the cwd-relative path, so a pattern can | 228 | // tree and matches against the cwd-relative path, so a pattern can | ||
| 126 | // span directory boundaries. | 229 | // span directory boundaries. | ||
M packages/agent/modes/interactive.go +25 -4
| @@ -1605,6 +1605,18 @@ func (i *Interactive) ctrlCExitArmed() bool { | |||
| 1605 | 1605 | return !t.IsZero() && time.Since(t) <= ctrlCExitWindow | |
| 1606 | 1606 | } | |
| 1607 | 1607 | ||
| 1608 | + | // clearFileSuggestQuery strips the filter the user typed after the | |
| 1609 | + | // last "@", leaving the bare "@" so the picker stays open. Called when | |
| 1610 | + | // navigating between directory levels (Right/Left): the filter applied | |
| 1611 | + | // to the level the user was on, not the one being entered, so carrying | |
| 1612 | + | // it forward would wrongly hide the new directory's contents. | |
| 1613 | + | func (i *Interactive) clearFileSuggestQuery() { | |
| 1614 | + | val := i.ed.Value() | |
| 1615 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |
| 1616 | + | i.ed.SetValue(val[:idx+1]) | |
| 1617 | + | } | |
| 1618 | + | } | |
| 1619 | + | ||
| 1608 | 1620 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 1609 | 1621 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 1610 | 1622 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | |
| @@ -2083,12 +2095,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2083 | 2095 | i.fileSuggest.Down() | |
| 2084 | 2096 | return false | |
| 2085 | 2097 | case tui.KeyRight: | |
| 2086 | - | // Open selected directory. | |
| 2087 | - | i.fileSuggest.Right() | |
| 2098 | + | // Open selected directory. The filter the user typed picked | |
| 2099 | + | // that directory at the current level; once we descend it no | |
| 2100 | + | // longer applies to the directory's contents, so clear it. | |
| 2101 | + | // Otherwise typing "@eda" then right would re-filter inside | |
| 2102 | + | // eda/ by "eda" and show nothing. | |
| 2103 | + | if i.fileSuggest.Right() { | |
| 2104 | + | i.clearFileSuggestQuery() | |
| 2105 | + | } | |
| 2088 | 2106 | return false | |
| 2089 | 2107 | case tui.KeyLeft: | |
| 2090 | - | // Go back to parent directory. | |
| 2091 | - | i.fileSuggest.Left() | |
| 2108 | + | // Go back to parent directory. Clear the filter for the same | |
| 2109 | + | // reason as Right: it was scoped to the level we just left. | |
| 2110 | + | if i.fileSuggest.Left() { | |
| 2111 | + | i.clearFileSuggestQuery() | |
| 2112 | + | } | |
| 2092 | 2113 | return false | |
| 2093 | 2114 | case tui.KeyEnter: | |
| 2094 | 2115 | if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok { | |
| @@ -1605,6 +1605,18 @@ func (i *Interactive) ctrlCExitArmed() bool { | |||||
| 1605 | return !t.IsZero() && time.Since(t) <= ctrlCExitWindow | 1605 | return !t.IsZero() && time.Since(t) <= ctrlCExitWindow | ||
| 1606 | } | 1606 | } | ||
| 1607 | 1607 | ||||
| 1608 | + | // clearFileSuggestQuery strips the filter the user typed after the | |||
| 1609 | + | // last "@", leaving the bare "@" so the picker stays open. Called when | |||
| 1610 | + | // navigating between directory levels (Right/Left): the filter applied | |||
| 1611 | + | // to the level the user was on, not the one being entered, so carrying | |||
| 1612 | + | // it forward would wrongly hide the new directory's contents. | |||
| 1613 | + | func (i *Interactive) clearFileSuggestQuery() { | |||
| 1614 | + | val := i.ed.Value() | |||
| 1615 | + | if idx := strings.LastIndex(val, "@"); idx >= 0 { | |||
| 1616 | + | i.ed.SetValue(val[:idx+1]) | |||
| 1617 | + | } | |||
| 1618 | + | } | |||
| 1619 | + | ||||
| 1608 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | 1620 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | ||
| 1609 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | 1621 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | ||
| 1610 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | 1622 | // pressing ctrl+c then typing then ctrl+c much later doesn't quit | ||
| @@ -2083,12 +2095,21 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2083 | i.fileSuggest.Down() | 2095 | i.fileSuggest.Down() | ||
| 2084 | return false | 2096 | return false | ||
| 2085 | case tui.KeyRight: | 2097 | case tui.KeyRight: | ||
| 2086 | - | // Open selected directory. | 2098 | + | // Open selected directory. The filter the user typed picked |
| 2087 | - | i.fileSuggest.Right() | 2099 | + | // that directory at the current level; once we descend it no |
| 2100 | + | // longer applies to the directory's contents, so clear it. | |||
| 2101 | + | // Otherwise typing "@eda" then right would re-filter inside | |||
| 2102 | + | // eda/ by "eda" and show nothing. | |||
| 2103 | + | if i.fileSuggest.Right() { | |||
| 2104 | + | i.clearFileSuggestQuery() | |||
| 2105 | + | } | |||
| 2088 | return false | 2106 | return false | ||
| 2089 | case tui.KeyLeft: | 2107 | case tui.KeyLeft: | ||
| 2090 | - | // Go back to parent directory. | 2108 | + | // Go back to parent directory. Clear the filter for the same |
| 2091 | - | i.fileSuggest.Left() | 2109 | + | // reason as Right: it was scoped to the level we just left. |
| 2110 | + | if i.fileSuggest.Left() { | |||
| 2111 | + | i.clearFileSuggestQuery() | |||
| 2112 | + | } | |||
| 2092 | return false | 2113 | return false | ||
| 2093 | case tui.KeyEnter: | 2114 | case tui.KeyEnter: | ||
| 2094 | if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok { | 2115 | if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok { | ||
M packages/ignore/gitignore.go +91 -3
| @@ -37,6 +37,84 @@ func Load(root string) *Gitignore { | |||
| 37 | 37 | return Parse(string(data)) | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | + | // Stack tracks the chain of .gitignore files from a walk's root down to | |
| 41 | + | // the directory currently being visited. Real repositories routinely | |
| 42 | + | // place a .gitignore inside a subdirectory (for example a vendored tool | |
| 43 | + | // directory that ignores its own node_modules), and those nested rules | |
| 44 | + | // are invisible to a single root-only matcher. During a recursive walk | |
| 45 | + | // such an unhonored node_modules can swamp the entry budget before the | |
| 46 | + | // walk ever reaches the files the user is actually looking for, so the | |
| 47 | + | // nested files must be applied. | |
| 48 | + | // | |
| 49 | + | // Each frame holds the matcher parsed from a directory's .gitignore | |
| 50 | + | // plus that directory's path relative to the walk root (slash | |
| 51 | + | // separated, "" for the root). A nested matcher's patterns are | |
| 52 | + | // evaluated against the path relative to the directory that owns them, | |
| 53 | + | // matching git's own semantics. | |
| 54 | + | type Stack struct { | |
| 55 | + | root string | |
| 56 | + | frames []stackFrame | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type stackFrame struct { | |
| 60 | + | relDir string // dir owning the .gitignore, relative to root, slash-sep | |
| 61 | + | ig *Gitignore | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // NewStack returns a Stack seeded with the root .gitignore (if any). | |
| 65 | + | func NewStack(root string) *Stack { | |
| 66 | + | s := &Stack{root: root} | |
| 67 | + | s.frames = append(s.frames, stackFrame{relDir: "", ig: Load(root)}) | |
| 68 | + | return s | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Push loads the .gitignore in dir (an absolute path under root, with | |
| 72 | + | // relDir its slash-separated path relative to root) and adds it to the | |
| 73 | + | // stack. Call when the walk descends into a directory that is being | |
| 74 | + | // kept; pair with Pop when leaving it. A directory with no .gitignore | |
| 75 | + | // still gets a frame so the push/pop bookkeeping stays balanced. | |
| 76 | + | func (s *Stack) Push(absDir, relDir string) { | |
| 77 | + | data, err := os.ReadFile(filepath.Join(absDir, ".gitignore")) | |
| 78 | + | var ig *Gitignore | |
| 79 | + | if err != nil { | |
| 80 | + | ig = &Gitignore{} | |
| 81 | + | } else { | |
| 82 | + | ig = Parse(string(data)) | |
| 83 | + | } | |
| 84 | + | s.frames = append(s.frames, stackFrame{relDir: filepath.ToSlash(relDir), ig: ig}) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Pop removes the most recently pushed frame. The seeded root frame is | |
| 88 | + | // never popped. | |
| 89 | + | func (s *Stack) Pop() { | |
| 90 | + | if len(s.frames) > 1 { | |
| 91 | + | s.frames = s.frames[:len(s.frames)-1] | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Match reports whether rel (slash-separated, relative to the walk | |
| 96 | + | // root) should be ignored, consulting every .gitignore from the root | |
| 97 | + | // down to the current directory. Each frame matches against rel made | |
| 98 | + | // relative to the directory that owns it; a deeper frame's later rules | |
| 99 | + | // win, mirroring git's nearest-file-wins ordering. | |
| 100 | + | func (s *Stack) Match(rel string, isDir bool) bool { | |
| 101 | + | ignored := false | |
| 102 | + | for _, f := range s.frames { | |
| 103 | + | sub := rel | |
| 104 | + | if f.relDir != "" { | |
| 105 | + | prefix := f.relDir + "/" | |
| 106 | + | if !strings.HasPrefix(rel, prefix) { | |
| 107 | + | continue | |
| 108 | + | } | |
| 109 | + | sub = rel[len(prefix):] | |
| 110 | + | } | |
| 111 | + | if matched, neg := f.ig.matchResult(sub, isDir); matched { | |
| 112 | + | ignored = !neg | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | return ignored | |
| 116 | + | } | |
| 117 | + | ||
| 40 | 118 | // Parse builds a matcher from raw .gitignore file contents. | |
| 41 | 119 | func Parse(data string) *Gitignore { | |
| 42 | 120 | g := &Gitignore{} | |
| @@ -71,16 +149,26 @@ func Parse(data string) *Gitignore { | |||
| 71 | 149 | // ignored. Later rules win, so a trailing negation can re-include a | |
| 72 | 150 | // previously ignored path. | |
| 73 | 151 | func (g *Gitignore) Match(rel string, isDir bool) bool { | |
| 74 | - | ignored := false | |
| 152 | + | matched, negate := g.matchResult(rel, isDir) | |
| 153 | + | return matched && !negate | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // matchResult reports whether any rule matched rel and, if so, whether | |
| 157 | + | // the winning (last) matching rule was a negation. This lets a Stack | |
| 158 | + | // combine matchers across nested .gitignore files while still honoring | |
| 159 | + | // negations correctly: a nested "!keep.me" must be able to re-include a | |
| 160 | + | // path a parent .gitignore excluded. | |
| 161 | + | func (g *Gitignore) matchResult(rel string, isDir bool) (matched, negate bool) { | |
| 75 | 162 | for _, r := range g.rules { | |
| 76 | 163 | if r.dirOnly && !isDir { | |
| 77 | 164 | continue | |
| 78 | 165 | } | |
| 79 | 166 | if r.matchPath(rel) { | |
| 80 | - | ignored = !r.negate | |
| 167 | + | matched = true | |
| 168 | + | negate = r.negate | |
| 81 | 169 | } | |
| 82 | 170 | } | |
| 83 | - | return ignored | |
| 171 | + | return matched, negate | |
| 84 | 172 | } | |
| 85 | 173 | ||
| 86 | 174 | func (r rule) matchPath(rel string) bool { | |
| @@ -37,6 +37,84 @@ func Load(root string) *Gitignore { | |||||
| 37 | return Parse(string(data)) | 37 | return Parse(string(data)) | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | + | // Stack tracks the chain of .gitignore files from a walk's root down to | |||
| 41 | + | // the directory currently being visited. Real repositories routinely | |||
| 42 | + | // place a .gitignore inside a subdirectory (for example a vendored tool | |||
| 43 | + | // directory that ignores its own node_modules), and those nested rules | |||
| 44 | + | // are invisible to a single root-only matcher. During a recursive walk | |||
| 45 | + | // such an unhonored node_modules can swamp the entry budget before the | |||
| 46 | + | // walk ever reaches the files the user is actually looking for, so the | |||
| 47 | + | // nested files must be applied. | |||
| 48 | + | // | |||
| 49 | + | // Each frame holds the matcher parsed from a directory's .gitignore | |||
| 50 | + | // plus that directory's path relative to the walk root (slash | |||
| 51 | + | // separated, "" for the root). A nested matcher's patterns are | |||
| 52 | + | // evaluated against the path relative to the directory that owns them, | |||
| 53 | + | // matching git's own semantics. | |||
| 54 | + | type Stack struct { | |||
| 55 | + | root string | |||
| 56 | + | frames []stackFrame | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | type stackFrame struct { | |||
| 60 | + | relDir string // dir owning the .gitignore, relative to root, slash-sep | |||
| 61 | + | ig *Gitignore | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // NewStack returns a Stack seeded with the root .gitignore (if any). | |||
| 65 | + | func NewStack(root string) *Stack { | |||
| 66 | + | s := &Stack{root: root} | |||
| 67 | + | s.frames = append(s.frames, stackFrame{relDir: "", ig: Load(root)}) | |||
| 68 | + | return s | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Push loads the .gitignore in dir (an absolute path under root, with | |||
| 72 | + | // relDir its slash-separated path relative to root) and adds it to the | |||
| 73 | + | // stack. Call when the walk descends into a directory that is being | |||
| 74 | + | // kept; pair with Pop when leaving it. A directory with no .gitignore | |||
| 75 | + | // still gets a frame so the push/pop bookkeeping stays balanced. | |||
| 76 | + | func (s *Stack) Push(absDir, relDir string) { | |||
| 77 | + | data, err := os.ReadFile(filepath.Join(absDir, ".gitignore")) | |||
| 78 | + | var ig *Gitignore | |||
| 79 | + | if err != nil { | |||
| 80 | + | ig = &Gitignore{} | |||
| 81 | + | } else { | |||
| 82 | + | ig = Parse(string(data)) | |||
| 83 | + | } | |||
| 84 | + | s.frames = append(s.frames, stackFrame{relDir: filepath.ToSlash(relDir), ig: ig}) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Pop removes the most recently pushed frame. The seeded root frame is | |||
| 88 | + | // never popped. | |||
| 89 | + | func (s *Stack) Pop() { | |||
| 90 | + | if len(s.frames) > 1 { | |||
| 91 | + | s.frames = s.frames[:len(s.frames)-1] | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Match reports whether rel (slash-separated, relative to the walk | |||
| 96 | + | // root) should be ignored, consulting every .gitignore from the root | |||
| 97 | + | // down to the current directory. Each frame matches against rel made | |||
| 98 | + | // relative to the directory that owns it; a deeper frame's later rules | |||
| 99 | + | // win, mirroring git's nearest-file-wins ordering. | |||
| 100 | + | func (s *Stack) Match(rel string, isDir bool) bool { | |||
| 101 | + | ignored := false | |||
| 102 | + | for _, f := range s.frames { | |||
| 103 | + | sub := rel | |||
| 104 | + | if f.relDir != "" { | |||
| 105 | + | prefix := f.relDir + "/" | |||
| 106 | + | if !strings.HasPrefix(rel, prefix) { | |||
| 107 | + | continue | |||
| 108 | + | } | |||
| 109 | + | sub = rel[len(prefix):] | |||
| 110 | + | } | |||
| 111 | + | if matched, neg := f.ig.matchResult(sub, isDir); matched { | |||
| 112 | + | ignored = !neg | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | return ignored | |||
| 116 | + | } | |||
| 117 | + | ||||
| 40 | // Parse builds a matcher from raw .gitignore file contents. | 118 | // Parse builds a matcher from raw .gitignore file contents. | ||
| 41 | func Parse(data string) *Gitignore { | 119 | func Parse(data string) *Gitignore { | ||
| 42 | g := &Gitignore{} | 120 | g := &Gitignore{} | ||
| @@ -71,16 +149,26 @@ func Parse(data string) *Gitignore { | |||||
| 71 | // ignored. Later rules win, so a trailing negation can re-include a | 149 | // ignored. Later rules win, so a trailing negation can re-include a | ||
| 72 | // previously ignored path. | 150 | // previously ignored path. | ||
| 73 | func (g *Gitignore) Match(rel string, isDir bool) bool { | 151 | func (g *Gitignore) Match(rel string, isDir bool) bool { | ||
| 74 | - | ignored := false | 152 | + | matched, negate := g.matchResult(rel, isDir) |
| 153 | + | return matched && !negate | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // matchResult reports whether any rule matched rel and, if so, whether | |||
| 157 | + | // the winning (last) matching rule was a negation. This lets a Stack | |||
| 158 | + | // combine matchers across nested .gitignore files while still honoring | |||
| 159 | + | // negations correctly: a nested "!keep.me" must be able to re-include a | |||
| 160 | + | // path a parent .gitignore excluded. | |||
| 161 | + | func (g *Gitignore) matchResult(rel string, isDir bool) (matched, negate bool) { | |||
| 75 | for _, r := range g.rules { | 162 | for _, r := range g.rules { | ||
| 76 | if r.dirOnly && !isDir { | 163 | if r.dirOnly && !isDir { | ||
| 77 | continue | 164 | continue | ||
| 78 | } | 165 | } | ||
| 79 | if r.matchPath(rel) { | 166 | if r.matchPath(rel) { | ||
| 80 | - | ignored = !r.negate | 167 | + | matched = true |
| 168 | + | negate = r.negate | |||
| 81 | } | 169 | } | ||
| 82 | } | 170 | } | ||
| 83 | - | return ignored | 171 | + | return matched, negate |
| 84 | } | 172 | } | ||
| 85 | 173 | ||||
| 86 | func (r rule) matchPath(rel string) bool { | 174 | func (r rule) matchPath(rel string) bool { | ||
M packages/ignore/gitignore_test.go +77 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package ignore | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func TestParseAndMatch(t *testing.T) { | |
| 6 | 10 | g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log")) | |
| @@ -40,6 +44,78 @@ func TestEmptyIgnoresNothing(t *testing.T) { | |||
| 40 | 44 | } | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 47 | + | // TestStackHonorsNestedGitignore pins the recursive-picker bug: a | |
| 48 | + | // .gitignore living inside a subdirectory (here .opencode/.gitignore | |
| 49 | + | // ignoring node_modules, exactly the layout that flooded the @-picker) | |
| 50 | + | // must prune that subdirectory's node_modules even though the root | |
| 51 | + | // .gitignore says nothing about it. | |
| 52 | + | func TestStackHonorsNestedGitignore(t *testing.T) { | |
| 53 | + | root := t.TempDir() | |
| 54 | + | // Root .gitignore: only build/ at root, nothing about node_modules. | |
| 55 | + | if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("build/\n"), 0o644); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | opencode := filepath.Join(root, ".opencode") | |
| 59 | + | if err := os.MkdirAll(opencode, 0o755); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | if err := os.WriteFile(filepath.Join(opencode, ".gitignore"), []byte("node_modules\n"), 0o644); err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | s := NewStack(root) | |
| 67 | + | // Before descending into .opencode the nested rule is not in scope, | |
| 68 | + | // so a same-named path elsewhere stays visible. | |
| 69 | + | if s.Match("node_modules", true) { | |
| 70 | + | t.Fatal("root-level node_modules should not be ignored by an unloaded nested rule") | |
| 71 | + | } | |
| 72 | + | // Descend into .opencode: push its .gitignore. | |
| 73 | + | s.Push(opencode, ".opencode") | |
| 74 | + | if !s.Match(".opencode/node_modules", true) { | |
| 75 | + | t.Fatal("nested .opencode/.gitignore should ignore .opencode/node_modules") | |
| 76 | + | } | |
| 77 | + | if !s.Match(".opencode/node_modules/zod/src/v3/tests/pipeline.test.ts", false) { | |
| 78 | + | t.Fatal("files under nested-ignored node_modules should be ignored") | |
| 79 | + | } | |
| 80 | + | // A sibling source file inside .opencode is still visible. | |
| 81 | + | if s.Match(".opencode/config.json", false) { | |
| 82 | + | t.Fatal(".opencode/config.json should not be ignored") | |
| 83 | + | } | |
| 84 | + | // Root build/ rule still applies through the stack. | |
| 85 | + | if !s.Match("build", true) { | |
| 86 | + | t.Fatal("root build/ rule should still apply while nested frame is pushed") | |
| 87 | + | } | |
| 88 | + | // Pop the nested frame: its rule no longer applies. | |
| 89 | + | s.Pop() | |
| 90 | + | if s.Match(".opencode/node_modules", true) { | |
| 91 | + | t.Fatal("after popping, the nested rule should no longer be in scope") | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // TestStackNestedNegationReincludes verifies a nested !pattern can | |
| 96 | + | // re-include a path a parent .gitignore excluded. | |
| 97 | + | func TestStackNestedNegationReincludes(t *testing.T) { | |
| 98 | + | root := t.TempDir() | |
| 99 | + | if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0o644); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | sub := filepath.Join(root, "sub") | |
| 103 | + | if err := os.MkdirAll(sub, 0o755); err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | if err := os.WriteFile(filepath.Join(sub, ".gitignore"), []byte("!keep.log\n"), 0o644); err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | s := NewStack(root) | |
| 110 | + | s.Push(sub, "sub") | |
| 111 | + | if s.Match("sub/keep.log", false) { | |
| 112 | + | t.Fatal("nested !keep.log should re-include a *.log excluded by root") | |
| 113 | + | } | |
| 114 | + | if !s.Match("sub/other.log", false) { | |
| 115 | + | t.Fatal("sub/other.log should still be excluded by root *.log") | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 43 | 119 | // lines joins fixture lines with newlines for readable .gitignore | |
| 44 | 120 | // fixtures. | |
| 45 | 121 | func lines(ls ...string) string { | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package ignore | 1 | package ignore | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | func TestParseAndMatch(t *testing.T) { | 9 | func TestParseAndMatch(t *testing.T) { | ||
| 6 | g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log")) | 10 | g := Parse(lines("# comment", "", ".terraform/", ".terragrunt-cache/", "node_modules/", "*.log", "/build", "!keep.log")) | ||
| @@ -40,6 +44,78 @@ func TestEmptyIgnoresNothing(t *testing.T) { | |||||
| 40 | } | 44 | } | ||
| 41 | } | 45 | } | ||
| 42 | 46 | ||||
| 47 | + | // TestStackHonorsNestedGitignore pins the recursive-picker bug: a | |||
| 48 | + | // .gitignore living inside a subdirectory (here .opencode/.gitignore | |||
| 49 | + | // ignoring node_modules, exactly the layout that flooded the @-picker) | |||
| 50 | + | // must prune that subdirectory's node_modules even though the root | |||
| 51 | + | // .gitignore says nothing about it. | |||
| 52 | + | func TestStackHonorsNestedGitignore(t *testing.T) { | |||
| 53 | + | root := t.TempDir() | |||
| 54 | + | // Root .gitignore: only build/ at root, nothing about node_modules. | |||
| 55 | + | if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("build/\n"), 0o644); err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | opencode := filepath.Join(root, ".opencode") | |||
| 59 | + | if err := os.MkdirAll(opencode, 0o755); err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | if err := os.WriteFile(filepath.Join(opencode, ".gitignore"), []byte("node_modules\n"), 0o644); err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | s := NewStack(root) | |||
| 67 | + | // Before descending into .opencode the nested rule is not in scope, | |||
| 68 | + | // so a same-named path elsewhere stays visible. | |||
| 69 | + | if s.Match("node_modules", true) { | |||
| 70 | + | t.Fatal("root-level node_modules should not be ignored by an unloaded nested rule") | |||
| 71 | + | } | |||
| 72 | + | // Descend into .opencode: push its .gitignore. | |||
| 73 | + | s.Push(opencode, ".opencode") | |||
| 74 | + | if !s.Match(".opencode/node_modules", true) { | |||
| 75 | + | t.Fatal("nested .opencode/.gitignore should ignore .opencode/node_modules") | |||
| 76 | + | } | |||
| 77 | + | if !s.Match(".opencode/node_modules/zod/src/v3/tests/pipeline.test.ts", false) { | |||
| 78 | + | t.Fatal("files under nested-ignored node_modules should be ignored") | |||
| 79 | + | } | |||
| 80 | + | // A sibling source file inside .opencode is still visible. | |||
| 81 | + | if s.Match(".opencode/config.json", false) { | |||
| 82 | + | t.Fatal(".opencode/config.json should not be ignored") | |||
| 83 | + | } | |||
| 84 | + | // Root build/ rule still applies through the stack. | |||
| 85 | + | if !s.Match("build", true) { | |||
| 86 | + | t.Fatal("root build/ rule should still apply while nested frame is pushed") | |||
| 87 | + | } | |||
| 88 | + | // Pop the nested frame: its rule no longer applies. | |||
| 89 | + | s.Pop() | |||
| 90 | + | if s.Match(".opencode/node_modules", true) { | |||
| 91 | + | t.Fatal("after popping, the nested rule should no longer be in scope") | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // TestStackNestedNegationReincludes verifies a nested !pattern can | |||
| 96 | + | // re-include a path a parent .gitignore excluded. | |||
| 97 | + | func TestStackNestedNegationReincludes(t *testing.T) { | |||
| 98 | + | root := t.TempDir() | |||
| 99 | + | if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0o644); err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | sub := filepath.Join(root, "sub") | |||
| 103 | + | if err := os.MkdirAll(sub, 0o755); err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | if err := os.WriteFile(filepath.Join(sub, ".gitignore"), []byte("!keep.log\n"), 0o644); err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | s := NewStack(root) | |||
| 110 | + | s.Push(sub, "sub") | |||
| 111 | + | if s.Match("sub/keep.log", false) { | |||
| 112 | + | t.Fatal("nested !keep.log should re-include a *.log excluded by root") | |||
| 113 | + | } | |||
| 114 | + | if !s.Match("sub/other.log", false) { | |||
| 115 | + | t.Fatal("sub/other.log should still be excluded by root *.log") | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 43 | // lines joins fixture lines with newlines for readable .gitignore | 119 | // lines joins fixture lines with newlines for readable .gitignore | ||
| 44 | // fixtures. | 120 | // fixtures. | ||
| 45 | func lines(ls ...string) string { | 121 | func lines(ls ...string) string { | ||