on main help sign in
feat: grep should support gitignore/crushignore (#428)

* feat: support .crushignore as well as .gitignore * docs: update * refactor: simplify * chore: fmt * feat: grep should support gitignore/crushignore * fix: small fixes * fix: small fixes * fix: ripgrep * fix: rg * fix: tst * test: fixes * refactor: organized code a bit * fix: try * fix: temp * chore: lint --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commit2a431845803adcded25746d24d6d093c4f4076cb authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 1 Aug 2025, 22:39 (1 year ago) parentab7a093
7 files changed+193−64
CRUSH.md+2−1 internal/fsext/fileutil.go+4−53 internal/llm/tools/glob.go+1−1 internal/llm/tools/grep.go+17−2 internal/llm/tools/grep_test.go+114 internal/llm/tools/rg.go+53 internal/shell/command_block_test.go+2−7
CRUSH.md+2−1
@@ -20,7 +20,8 @@
2020- **Structs**: Use struct embedding for composition, group related fields
2121- **Constants**: Use typed constants with iota for enums, group in const blocks
2222- **Testing**: Use testify's `require` package, parallel tests with `t.Parallel()`,
23− `t.SetEnv()` to set environment variables.
23+ `t.SetEnv()` to set environment variables. Always use `t.Tempdir()` when in
24+ need of a temporary directory. This directory does not need to be removed.
2425- **JSON tags**: Use snake_case for JSON field names
2526- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
2627- **Comments**: End comments in periods unless comments are at the end of the line.
internal/fsext/fileutil.go+4−53
@@ -1,11 +1,8 @@
11package fsext
22
33import (
4− "context"
54 "fmt"
6− "log/slog"
75 "os"
8− "os/exec"
96 "path/filepath"
107 "sort"
118 "strings"
@@ -13,55 +10,10 @@ import (
1310
1411 "github.com/bmatcuk/doublestar/v4"
1512 "github.com/charlievieth/fastwalk"
16− "github.com/charmbracelet/crush/internal/log"
1713
1814 ignore "github.com/sabhiram/go-gitignore"
1915)
2016
21−var rgPath string
22−
23−func init() {
24− var err error
25− rgPath, err = exec.LookPath("rg")
26− if err != nil {
27− if log.Initialized() {
28− slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
29− }
30− }
31−}
32−
33−func GetRgCmd(ctx context.Context, globPattern string) *exec.Cmd {
34− if rgPath == "" {
35− return nil
36− }
37− rgArgs := []string{
38− "--files",
39− "-L",
40− "--null",
41− }
42− if globPattern != "" {
43− if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
44− globPattern = "/" + globPattern
45− }
46− rgArgs = append(rgArgs, "--glob", globPattern)
47− }
48− return exec.CommandContext(ctx, rgPath, rgArgs...)
49−}
50−
51−func GetRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cmd {
52− if rgPath == "" {
53− return nil
54− }
55− // Use -n to show line numbers and include the matched line
56− args := []string{"-H", "-n", pattern}
57− if include != "" {
58− args = append(args, "--glob", include)
59− }
60− args = append(args, path)
61−
62− return exec.CommandContext(ctx, rgPath, args...)
63−}
64−
6517type FileInfo struct {
6618 Path string
6719 ModTime time.Time
@@ -89,8 +41,6 @@ func SkipHidden(path string) bool {
8941 "obj": true,
9042 "out": true,
9143 "coverage": true,
92− "tmp": true,
93− "temp": true,
9444 "logs": true,
9545 "generated": true,
9646 "bower_components": true,
@@ -137,7 +87,8 @@ func NewFastGlobWalker(searchPath string) *FastGlobWalker {
13787 return walker
13888}
13989
140−func (w *FastGlobWalker) shouldSkip(path string) bool {
90+// ShouldSkip checks if a path should be skipped based on gitignore, crushignore, and hidden file rules
91+func (w *FastGlobWalker) ShouldSkip(path string) bool {
14192 if SkipHidden(path) {
14293 return true
14394 }
@@ -177,13 +128,13 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
177128 }
178129
179130 if d.IsDir() {
180− if walker.shouldSkip(path) {
131+ if walker.ShouldSkip(path) {
181132 return filepath.SkipDir
182133 }
183134 return nil
184135 }
185136
186− if walker.shouldSkip(path) {
137+ if walker.ShouldSkip(path) {
187138 return nil
188139 }
189140
internal/llm/tools/glob.go+1−1
@@ -139,7 +139,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
139139}
140140
141141func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
142− cmdRg := fsext.GetRgCmd(ctx, pattern)
142+ cmdRg := getRgCmd(ctx, pattern)
143143 if cmdRg != nil {
144144 cmdRg.Dir = searchPath
145145 matches, err := runRipgrep(cmdRg, searchPath, limit)
internal/llm/tools/grep.go+17−2
@@ -125,6 +125,11 @@ LIMITATIONS:
125125- Very large binary files may be skipped
126126- Hidden files (starting with '.') are skipped
127127
128+IGNORE FILE SUPPORT:
129+- Respects .gitignore patterns to skip ignored files and directories
130+- Respects .crushignore patterns for additional ignore rules
131+- Both ignore files are automatically detected in the search root directory
132+
128133CROSS-PLATFORM NOTES:
129134- Uses ripgrep (rg) command if available for better performance
130135- Falls back to built-in Go implementation if ripgrep is not available
@@ -269,11 +274,17 @@ func searchFiles(ctx context.Context, pattern, rootPath, include string, limit i
269274}
270275
271276func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]grepMatch, error) {
272− cmd := fsext.GetRgSearchCmd(ctx, pattern, path, include)
277+ cmd := getRgSearchCmd(ctx, pattern, path, include)
273278 if cmd == nil {
274279 return nil, fmt.Errorf("ripgrep not found in $PATH")
275280 }
276281
282+ cmd.Args = append(
283+ cmd.Args,
284+ "--ignore-file", filepath.Join(path, ".gitignore"),
285+ "--ignore-file", filepath.Join(path, ".crushignore"),
286+ )
287+
277288 output, err := cmd.Output()
278289 if err != nil {
279290 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
@@ -337,6 +348,9 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
337348 }
338349 }
339350
351+ // Create walker with gitignore and crushignore support
352+ walker := fsext.NewFastGlobWalker(rootPath)
353+
340354 err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
341355 if err != nil {
342356 return nil // Skip errors
@@ -346,7 +360,8 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
346360 return nil // Skip directories
347361 }
348362
349− if fsext.SkipHidden(path) {
363+ // Use walker's shouldSkip method instead of just SkipHidden
364+ if walker.ShouldSkip(path) {
350365 return nil
351366 }
352367
internal/llm/tools/grep_test.go+114
@@ -1,8 +1,14 @@
11package tools
22
33import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
48 "regexp"
59 "testing"
10+
11+ "github.com/stretchr/testify/require"
612)
713
814func TestRegexCache(t *testing.T) {
@@ -52,6 +58,114 @@ func TestGlobToRegexCaching(t *testing.T) {
5258 }
5359}
5460
61+func TestGrepWithIgnoreFiles(t *testing.T) {
62+ tempDir := t.TempDir()
63+
64+ // Create test files
65+ testFiles := map[string]string{
66+ "file1.txt": "hello world",
67+ "file2.txt": "hello world",
68+ "ignored/file3.txt": "hello world",
69+ "node_modules/lib.js": "hello world",
70+ "secret.key": "hello world",
71+ }
72+
73+ for path, content := range testFiles {
74+ fullPath := filepath.Join(tempDir, path)
75+ require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
76+ require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
77+ }
78+
79+ // Create .gitignore file
80+ gitignoreContent := "ignored/\n*.key\n"
81+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644))
82+
83+ // Create .crushignore file
84+ crushignoreContent := "node_modules/\n"
85+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644))
86+
87+ // Create grep tool
88+ grepTool := NewGrepTool(tempDir)
89+
90+ // Create grep parameters
91+ params := GrepParams{
92+ Pattern: "hello world",
93+ Path: tempDir,
94+ }
95+ paramsJSON, err := json.Marshal(params)
96+ require.NoError(t, err)
97+
98+ // Run grep
99+ call := ToolCall{Input: string(paramsJSON)}
100+ response, err := grepTool.Run(context.Background(), call)
101+ require.NoError(t, err)
102+
103+ // Check results - should only find file1.txt and file2.txt
104+ // ignored/file3.txt should be ignored by .gitignore
105+ // node_modules/lib.js should be ignored by .crushignore
106+ // secret.key should be ignored by .gitignore
107+ result := response.Content
108+ require.Contains(t, result, "file1.txt")
109+ require.Contains(t, result, "file2.txt")
110+ require.NotContains(t, result, "file3.txt")
111+ require.NotContains(t, result, "lib.js")
112+ require.NotContains(t, result, "secret.key")
113+}
114+
115+func TestSearchImplementations(t *testing.T) {
116+ t.Parallel()
117+ tempDir := t.TempDir()
118+
119+ for path, content := range map[string]string{
120+ "file1.go": "package main\nfunc main() {\n\tfmt.Println(\"hello world\")\n}",
121+ "file2.js": "console.log('hello world');",
122+ "file3.txt": "hello world from text file",
123+ "binary.exe": "\x00\x01\x02\x03",
124+ "empty.txt": "",
125+ "subdir/nested.go": "package nested\n// hello world comment",
126+ ".hidden.txt": "hello world in hidden file",
127+ "file4.txt": "hello world from a banana",
128+ "file5.txt": "hello world from a grape",
129+ } {
130+ fullPath := filepath.Join(tempDir, path)
131+ require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
132+ require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
133+ }
134+
135+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("file4.txt\n"), 0o644))
136+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte("file5.txt\n"), 0o644))
137+
138+ for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
139+ "regex": searchFilesWithRegex,
140+ "rg": func(pattern, path, include string) ([]grepMatch, error) {
141+ return searchWithRipgrep(t.Context(), pattern, path, include)
142+ },
143+ } {
144+ t.Run(name, func(t *testing.T) {
145+ t.Parallel()
146+
147+ if name == "rg" && getRg() == "" {
148+ t.Skip("rg is not in $PATH")
149+ }
150+
151+ matches, err := fn("hello world", tempDir, "")
152+ require.NoError(t, err)
153+
154+ require.Equal(t, len(matches), 4)
155+ for _, match := range matches {
156+ require.NotEmpty(t, match.path)
157+ require.NotZero(t, match.lineNum)
158+ require.NotEmpty(t, match.lineText)
159+ require.NotZero(t, match.modTime)
160+ require.NotContains(t, match.path, ".hidden.txt")
161+ require.NotContains(t, match.path, "file4.txt")
162+ require.NotContains(t, match.path, "file5.txt")
163+ require.NotContains(t, match.path, "binary.exe")
164+ }
165+ })
166+ }
167+}
168+
55169// Benchmark to show performance improvement
56170func BenchmarkRegexCacheVsCompile(b *testing.B) {
57171 cache := newRegexCache()
internal/llm/tools/rg.gonew file+53
@@ -0,0 +1,53 @@
1+package tools
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "sync"
10+
11+ "github.com/charmbracelet/crush/internal/log"
12+)
13+
14+var getRg = sync.OnceValue(func() string {
15+ path, err := exec.LookPath("rg")
16+ if err != nil {
17+ if log.Initialized() {
18+ slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
19+ }
20+ return ""
21+ }
22+ return path
23+})
24+
25+func getRgCmd(ctx context.Context, globPattern string) *exec.Cmd {
26+ name := getRg()
27+ if name == "" {
28+ return nil
29+ }
30+ args := []string{"--files", "-L", "--null"}
31+ if globPattern != "" {
32+ if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
33+ globPattern = "/" + globPattern
34+ }
35+ args = append(args, "--glob", globPattern)
36+ }
37+ return exec.CommandContext(ctx, name, args...)
38+}
39+
40+func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cmd {
41+ name := getRg()
42+ if name == "" {
43+ return nil
44+ }
45+ // Use -n to show line numbers and include the matched line
46+ args := []string{"-H", "-n", pattern}
47+ if include != "" {
48+ args = append(args, "--glob", include)
49+ }
50+ args = append(args, path)
51+
52+ return exec.CommandContext(ctx, name, args...)
53+}
internal/shell/command_block_test.go+2−7
@@ -2,7 +2,6 @@ package shell
22
33import (
44 "context"
5− "os"
65 "strings"
76 "testing"
87)
@@ -92,18 +91,14 @@ func TestCommandBlocking(t *testing.T) {
9291 for _, tt := range tests {
9392 t.Run(tt.name, func(t *testing.T) {
9493 // Create a temporary directory for each test
95− tmpDir, err := os.MkdirTemp("", "shell-test-*")
96− if err != nil {
97− t.Fatalf("Failed to create temp dir: %v", err)
98− }
99− defer os.RemoveAll(tmpDir)
94+ tmpDir := t.TempDir()
10095
10196 shell := NewShell(&Options{
10297 WorkingDir: tmpDir,
10398 BlockFuncs: tt.blockFuncs,
10499 })
105100
106− _, _, err = shell.Exec(context.Background(), tt.command)
101+ _, _, err := shell.Exec(context.Background(), tt.command)
107102
108103 if tt.shouldBlock {
109104 if err == nil {