Detailed changes
@@ -20,7 +20,8 @@
- **Structs**: Use struct embedding for composition, group related fields
- **Constants**: Use typed constants with iota for enums, group in const blocks
- **Testing**: Use testify's `require` package, parallel tests with `t.Parallel()`,
- `t.SetEnv()` to set environment variables.
+ `t.SetEnv()` to set environment variables. Always use `t.Tempdir()` when in
+ need of a temporary directory. This directory does not need to be removed.
- **JSON tags**: Use snake_case for JSON field names
- **File permissions**: Use octal notation (0o755, 0o644) for file permissions
- **Comments**: End comments in periods unless comments are at the end of the line.
@@ -1,11 +1,8 @@
package fsext
import (
- "context"
"fmt"
- "log/slog"
"os"
- "os/exec"
"path/filepath"
"sort"
"strings"
@@ -13,55 +10,10 @@ import (
"github.com/bmatcuk/doublestar/v4"
"github.com/charlievieth/fastwalk"
- "github.com/charmbracelet/crush/internal/log"
ignore "github.com/sabhiram/go-gitignore"
)
-var rgPath string
-
-func init() {
- var err error
- rgPath, err = exec.LookPath("rg")
- if err != nil {
- if log.Initialized() {
- slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
- }
- }
-}
-
-func GetRgCmd(ctx context.Context, globPattern string) *exec.Cmd {
- if rgPath == "" {
- return nil
- }
- rgArgs := []string{
- "--files",
- "-L",
- "--null",
- }
- if globPattern != "" {
- if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
- globPattern = "/" + globPattern
- }
- rgArgs = append(rgArgs, "--glob", globPattern)
- }
- return exec.CommandContext(ctx, rgPath, rgArgs...)
-}
-
-func GetRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cmd {
- if rgPath == "" {
- return nil
- }
- // Use -n to show line numbers and include the matched line
- args := []string{"-H", "-n", pattern}
- if include != "" {
- args = append(args, "--glob", include)
- }
- args = append(args, path)
-
- return exec.CommandContext(ctx, rgPath, args...)
-}
-
type FileInfo struct {
Path string
ModTime time.Time
@@ -89,8 +41,6 @@ func SkipHidden(path string) bool {
"obj": true,
"out": true,
"coverage": true,
- "tmp": true,
- "temp": true,
"logs": true,
"generated": true,
"bower_components": true,
@@ -137,7 +87,8 @@ func NewFastGlobWalker(searchPath string) *FastGlobWalker {
return walker
}
-func (w *FastGlobWalker) shouldSkip(path string) bool {
+// ShouldSkip checks if a path should be skipped based on gitignore, crushignore, and hidden file rules
+func (w *FastGlobWalker) ShouldSkip(path string) bool {
if SkipHidden(path) {
return true
}
@@ -177,13 +128,13 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
}
if d.IsDir() {
- if walker.shouldSkip(path) {
+ if walker.ShouldSkip(path) {
return filepath.SkipDir
}
return nil
}
- if walker.shouldSkip(path) {
+ if walker.ShouldSkip(path) {
return nil
}
@@ -139,7 +139,7 @@ func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
}
func globFiles(ctx context.Context, pattern, searchPath string, limit int) ([]string, bool, error) {
- cmdRg := fsext.GetRgCmd(ctx, pattern)
+ cmdRg := getRgCmd(ctx, pattern)
if cmdRg != nil {
cmdRg.Dir = searchPath
matches, err := runRipgrep(cmdRg, searchPath, limit)
@@ -125,6 +125,11 @@ LIMITATIONS:
- Very large binary files may be skipped
- Hidden files (starting with '.') are skipped
+IGNORE FILE SUPPORT:
+- Respects .gitignore patterns to skip ignored files and directories
+- Respects .crushignore patterns for additional ignore rules
+- Both ignore files are automatically detected in the search root directory
+
CROSS-PLATFORM NOTES:
- Uses ripgrep (rg) command if available for better performance
- 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
}
func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]grepMatch, error) {
- cmd := fsext.GetRgSearchCmd(ctx, pattern, path, include)
+ cmd := getRgSearchCmd(ctx, pattern, path, include)
if cmd == nil {
return nil, fmt.Errorf("ripgrep not found in $PATH")
}
+ cmd.Args = append(
+ cmd.Args,
+ "--ignore-file", filepath.Join(path, ".gitignore"),
+ "--ignore-file", filepath.Join(path, ".crushignore"),
+ )
+
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
@@ -337,6 +348,9 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
}
}
+ // Create walker with gitignore and crushignore support
+ walker := fsext.NewFastGlobWalker(rootPath)
+
err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors
@@ -346,7 +360,8 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
return nil // Skip directories
}
- if fsext.SkipHidden(path) {
+ // Use walker's shouldSkip method instead of just SkipHidden
+ if walker.ShouldSkip(path) {
return nil
}
@@ -1,8 +1,14 @@
package tools
import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
"regexp"
"testing"
+
+ "github.com/stretchr/testify/require"
)
func TestRegexCache(t *testing.T) {
@@ -52,6 +58,114 @@ func TestGlobToRegexCaching(t *testing.T) {
}
}
+func TestGrepWithIgnoreFiles(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Create test files
+ testFiles := map[string]string{
+ "file1.txt": "hello world",
+ "file2.txt": "hello world",
+ "ignored/file3.txt": "hello world",
+ "node_modules/lib.js": "hello world",
+ "secret.key": "hello world",
+ }
+
+ for path, content := range testFiles {
+ fullPath := filepath.Join(tempDir, path)
+ require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
+ require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
+ }
+
+ // Create .gitignore file
+ gitignoreContent := "ignored/\n*.key\n"
+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte(gitignoreContent), 0o644))
+
+ // Create .crushignore file
+ crushignoreContent := "node_modules/\n"
+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte(crushignoreContent), 0o644))
+
+ // Create grep tool
+ grepTool := NewGrepTool(tempDir)
+
+ // Create grep parameters
+ params := GrepParams{
+ Pattern: "hello world",
+ Path: tempDir,
+ }
+ paramsJSON, err := json.Marshal(params)
+ require.NoError(t, err)
+
+ // Run grep
+ call := ToolCall{Input: string(paramsJSON)}
+ response, err := grepTool.Run(context.Background(), call)
+ require.NoError(t, err)
+
+ // Check results - should only find file1.txt and file2.txt
+ // ignored/file3.txt should be ignored by .gitignore
+ // node_modules/lib.js should be ignored by .crushignore
+ // secret.key should be ignored by .gitignore
+ result := response.Content
+ require.Contains(t, result, "file1.txt")
+ require.Contains(t, result, "file2.txt")
+ require.NotContains(t, result, "file3.txt")
+ require.NotContains(t, result, "lib.js")
+ require.NotContains(t, result, "secret.key")
+}
+
+func TestSearchImplementations(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+
+ for path, content := range map[string]string{
+ "file1.go": "package main\nfunc main() {\n\tfmt.Println(\"hello world\")\n}",
+ "file2.js": "console.log('hello world');",
+ "file3.txt": "hello world from text file",
+ "binary.exe": "\x00\x01\x02\x03",
+ "empty.txt": "",
+ "subdir/nested.go": "package nested\n// hello world comment",
+ ".hidden.txt": "hello world in hidden file",
+ "file4.txt": "hello world from a banana",
+ "file5.txt": "hello world from a grape",
+ } {
+ fullPath := filepath.Join(tempDir, path)
+ require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755))
+ require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644))
+ }
+
+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".gitignore"), []byte("file4.txt\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".crushignore"), []byte("file5.txt\n"), 0o644))
+
+ for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
+ "regex": searchFilesWithRegex,
+ "rg": func(pattern, path, include string) ([]grepMatch, error) {
+ return searchWithRipgrep(t.Context(), pattern, path, include)
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ if name == "rg" && getRg() == "" {
+ t.Skip("rg is not in $PATH")
+ }
+
+ matches, err := fn("hello world", tempDir, "")
+ require.NoError(t, err)
+
+ require.Equal(t, len(matches), 4)
+ for _, match := range matches {
+ require.NotEmpty(t, match.path)
+ require.NotZero(t, match.lineNum)
+ require.NotEmpty(t, match.lineText)
+ require.NotZero(t, match.modTime)
+ require.NotContains(t, match.path, ".hidden.txt")
+ require.NotContains(t, match.path, "file4.txt")
+ require.NotContains(t, match.path, "file5.txt")
+ require.NotContains(t, match.path, "binary.exe")
+ }
+ })
+ }
+}
+
// Benchmark to show performance improvement
func BenchmarkRegexCacheVsCompile(b *testing.B) {
cache := newRegexCache()
@@ -0,0 +1,53 @@
+package tools
+
+import (
+ "context"
+ "log/slog"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/charmbracelet/crush/internal/log"
+)
+
+var getRg = sync.OnceValue(func() string {
+ path, err := exec.LookPath("rg")
+ if err != nil {
+ if log.Initialized() {
+ slog.Warn("Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower.")
+ }
+ return ""
+ }
+ return path
+})
+
+func getRgCmd(ctx context.Context, globPattern string) *exec.Cmd {
+ name := getRg()
+ if name == "" {
+ return nil
+ }
+ args := []string{"--files", "-L", "--null"}
+ if globPattern != "" {
+ if !filepath.IsAbs(globPattern) && !strings.HasPrefix(globPattern, "/") {
+ globPattern = "/" + globPattern
+ }
+ args = append(args, "--glob", globPattern)
+ }
+ return exec.CommandContext(ctx, name, args...)
+}
+
+func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cmd {
+ name := getRg()
+ if name == "" {
+ return nil
+ }
+ // Use -n to show line numbers and include the matched line
+ args := []string{"-H", "-n", pattern}
+ if include != "" {
+ args = append(args, "--glob", include)
+ }
+ args = append(args, path)
+
+ return exec.CommandContext(ctx, name, args...)
+}
@@ -2,7 +2,6 @@ package shell
import (
"context"
- "os"
"strings"
"testing"
)
@@ -92,18 +91,14 @@ func TestCommandBlocking(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a temporary directory for each test
- tmpDir, err := os.MkdirTemp("", "shell-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp dir: %v", err)
- }
- defer os.RemoveAll(tmpDir)
+ tmpDir := t.TempDir()
shell := NewShell(&Options{
WorkingDir: tmpDir,
BlockFuncs: tt.blockFuncs,
})
- _, _, err = shell.Exec(context.Background(), tt.command)
+ _, _, err := shell.Exec(context.Background(), tt.command)
if tt.shouldBlock {
if err == nil {