Merge pull request #633 from charmbracelet/ignore

Kujtim Hoxha created

feat: improve gitignore and crushignore support

Change summary

internal/config/load.go                       |  22 +
internal/csync/maps.go                        |  12 +
internal/csync/maps_test.go                   |  10 
internal/fsext/ignore_test.go                 |  20 -
internal/fsext/ls.go                          | 238 ++++++++++++--------
internal/tui/components/chat/editor/editor.go |   2 
6 files changed, 184 insertions(+), 120 deletions(-)

Detailed changes

internal/config/load.go 🔗

@@ -1,16 +1,19 @@
 package config
 
 import (
+	"cmp"
 	"encoding/json"
 	"fmt"
 	"io"
 	"log/slog"
 	"maps"
 	"os"
+	"os/user"
 	"path/filepath"
 	"runtime"
 	"slices"
 	"strings"
+	"sync"
 
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/csync"
@@ -538,13 +541,14 @@ func GlobalConfigData() string {
 	return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }
 
-func HomeDir() string {
-	homeDir := os.Getenv("HOME")
-	if homeDir == "" {
-		homeDir = os.Getenv("USERPROFILE") // For Windows compatibility
+var HomeDir = sync.OnceValue(func() string {
+	u, err := user.Current()
+	if err == nil {
+		return u.HomeDir
 	}
-	if homeDir == "" {
-		homeDir = os.Getenv("HOMEPATH") // Fallback for some environments
-	}
-	return homeDir
-}
+	return cmp.Or(
+		os.Getenv("HOME"),
+		os.Getenv("USERPROFILE"),
+		os.Getenv("HOMEPATH"),
+	)
+})

internal/csync/maps.go 🔗

@@ -56,6 +56,18 @@ func (m *Map[K, V]) Len() int {
 	return len(m.inner)
 }
 
+// GetOrSet gets and returns the key if it exists, otherwise, it executes the
+// given function, set its return value for the given key, and returns it.
+func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
+	got, ok := m.Get(key)
+	if ok {
+		return got
+	}
+	value := fn()
+	m.Set(key, value)
+	return value
+}
+
 // Take gets an item and then deletes it.
 func (m *Map[K, V]) Take(key K) (V, bool) {
 	m.mu.Lock()

internal/csync/maps_test.go 🔗

@@ -54,6 +54,16 @@ func TestMap_Set(t *testing.T) {
 	require.Equal(t, 1, m.Len())
 }
 
+func TestMap_GetOrSet(t *testing.T) {
+	t.Parallel()
+
+	m := NewMap[string, int]()
+
+	require.Equal(t, 42, m.GetOrSet("key1", func() int { return 42 }))
+	require.Equal(t, 42, m.GetOrSet("key1", func() int { return 99999 }))
+	require.Equal(t, 1, m.Len())
+}
+
 func TestMap_Get(t *testing.T) {
 	t.Parallel()
 

internal/fsext/ignore_test.go 🔗

@@ -25,20 +25,8 @@ func TestCrushIgnore(t *testing.T) {
 	// Create a .crushignore file that ignores .log files
 	require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644))
 
-	// Test DirectoryLister
-	t.Run("DirectoryLister respects .crushignore", func(t *testing.T) {
-		dl := NewDirectoryLister(tempDir)
-
-		// Test that .log files are ignored
-		require.True(t, dl.gitignore == nil, "gitignore should be nil")
-		require.NotNil(t, dl.crushignore, "crushignore should not be nil")
-	})
-
-	// Test FastGlobWalker
-	t.Run("FastGlobWalker respects .crushignore", func(t *testing.T) {
-		walker := NewFastGlobWalker(tempDir)
-
-		require.True(t, walker.gitignore == nil, "gitignore should be nil")
-		require.NotNil(t, walker.crushignore, "crushignore should not be nil")
-	})
+	dl := NewDirectoryLister(tempDir)
+	require.True(t, dl.shouldIgnore("test2.log", nil), ".log files should be ignored")
+	require.False(t, dl.shouldIgnore("test1.txt", nil), ".txt files should not be ignored")
+	require.True(t, dl.shouldIgnore("test3.tmp", nil), ".tmp files should be ignored by common patterns")
 }

internal/fsext/ls.go 🔗

@@ -1,137 +1,186 @@
 package fsext
 
 import (
+	"log/slog"
 	"os"
 	"path/filepath"
+	"strings"
+	"sync"
 
 	"github.com/charlievieth/fastwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
 	ignore "github.com/sabhiram/go-gitignore"
 )
 
-// CommonIgnorePatterns contains commonly ignored files and directories
-var CommonIgnorePatterns = []string{
-	// Version control
-	".git",
-	".svn",
-	".hg",
-	".bzr",
-
-	// IDE and editor files
-	".vscode",
-	".idea",
-	"*.swp",
-	"*.swo",
-	"*~",
-	".DS_Store",
-	"Thumbs.db",
-
-	// Build artifacts and dependencies
-	"node_modules",
-	"target",
-	"build",
-	"dist",
-	"out",
-	"bin",
-	"obj",
-	"*.o",
-	"*.so",
-	"*.dylib",
-	"*.dll",
-	"*.exe",
-
-	// Logs and temporary files
-	"*.log",
-	"*.tmp",
-	"*.temp",
-	".cache",
-	".tmp",
-
-	// Language-specific
-	"__pycache__",
-	"*.pyc",
-	"*.pyo",
-	".pytest_cache",
-	"vendor",
-	"Cargo.lock",
-	"package-lock.json",
-	"yarn.lock",
-	"pnpm-lock.yaml",
-
-	// OS generated files
-	".Trash",
-	".Spotlight-V100",
-	".fseventsd",
-
-	// Crush
-	".crush",
-}
+// commonIgnorePatterns contains commonly ignored files and directories
+var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
+	return ignore.CompileIgnoreLines(
+		// Version control
+		".git",
+		".svn",
+		".hg",
+		".bzr",
+
+		// IDE and editor files
+		".vscode",
+		".idea",
+		"*.swp",
+		"*.swo",
+		"*~",
+		".DS_Store",
+		"Thumbs.db",
+
+		// Build artifacts and dependencies
+		"node_modules",
+		"target",
+		"build",
+		"dist",
+		"out",
+		"bin",
+		"obj",
+		"*.o",
+		"*.so",
+		"*.dylib",
+		"*.dll",
+		"*.exe",
+
+		// Logs and temporary files
+		"*.log",
+		"*.tmp",
+		"*.temp",
+		".cache",
+		".tmp",
+
+		// Language-specific
+		"__pycache__",
+		"*.pyc",
+		"*.pyo",
+		".pytest_cache",
+		"vendor",
+		"Cargo.lock",
+		"package-lock.json",
+		"yarn.lock",
+		"pnpm-lock.yaml",
+
+		// OS generated files
+		".Trash",
+		".Spotlight-V100",
+		".fseventsd",
+
+		// Crush
+		".crush",
+	)
+})
+
+var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
+	home := config.HomeDir()
+	var lines []string
+	for _, name := range []string{
+		filepath.Join(home, ".gitignore"),
+		filepath.Join(home, ".config", "git", "ignore"),
+		filepath.Join(home, ".config", "crush", "ignore"),
+	} {
+		if bts, err := os.ReadFile(name); err == nil {
+			lines = append(lines, strings.Split(string(bts), "\n")...)
+		}
+	}
+	return ignore.CompileIgnoreLines(lines...)
+})
 
-type DirectoryLister struct {
-	gitignore    *ignore.GitIgnore
-	crushignore  *ignore.GitIgnore
-	commonIgnore *ignore.GitIgnore
-	rootPath     string
+type directoryLister struct {
+	ignores  *csync.Map[string, ignore.IgnoreParser]
+	rootPath string
 }
 
-func NewDirectoryLister(rootPath string) *DirectoryLister {
-	dl := &DirectoryLister{
+func NewDirectoryLister(rootPath string) *directoryLister {
+	dl := &directoryLister{
 		rootPath: rootPath,
+		ignores:  csync.NewMap[string, ignore.IgnoreParser](),
 	}
+	dl.getIgnore(rootPath)
+	return dl
+}
 
-	// Load gitignore if it exists
-	gitignorePath := filepath.Join(rootPath, ".gitignore")
-	if _, err := os.Stat(gitignorePath); err == nil {
-		if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
-			dl.gitignore = gi
-		}
-	}
-
-	// Load crushignore if it exists
-	crushignorePath := filepath.Join(rootPath, ".crushignore")
-	if _, err := os.Stat(crushignorePath); err == nil {
-		if ci, err := ignore.CompileIgnoreFile(crushignorePath); err == nil {
-			dl.crushignore = ci
+// git checks, in order:
+// - ./.gitignore, ../.gitignore, etc, until repo root
+// ~/.config/git/ignore
+// ~/.gitignore
+//
+// This will do the following:
+// - the given ignorePatterns
+// - [commonIgnorePatterns]
+// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
+// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
+// ~/.config/git/ignore
+// ~/.gitignore
+// ~/.config/crush/ignore
+func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
+	if len(ignorePatterns) > 0 {
+		base := filepath.Base(path)
+		for _, pattern := range ignorePatterns {
+			if matched, err := filepath.Match(pattern, base); err == nil && matched {
+				return true
+			}
 		}
 	}
 
-	// Create common ignore patterns
-	dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
-
-	return dl
-}
-
-func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
 	relPath, err := filepath.Rel(dl.rootPath, path)
 	if err != nil {
 		relPath = path
 	}
 
-	// Check common ignore patterns
-	if dl.commonIgnore.MatchesPath(relPath) {
+	if commonIgnorePatterns().MatchesPath(relPath) {
+		slog.Debug("ingoring common pattern", "path", relPath)
 		return true
 	}
 
-	// Check gitignore patterns if available
-	if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
+	if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
+		slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
 		return true
 	}
 
-	// Check crushignore patterns if available
-	if dl.crushignore != nil && dl.crushignore.MatchesPath(relPath) {
+	if dl.checkParentIgnores(relPath) {
 		return true
 	}
 
-	base := filepath.Base(path)
+	if homeIgnore().MatchesPath(relPath) {
+		slog.Debug("ingoring home dir pattern", "path", relPath)
+		return true
+	}
+
+	return false
+}
 
-	for _, pattern := range ignorePatterns {
-		matched, err := filepath.Match(pattern, base)
-		if err == nil && matched {
+func (dl *directoryLister) checkParentIgnores(path string) bool {
+	parent := filepath.Dir(filepath.Dir(path))
+	for parent != dl.rootPath && parent != "." && path != "." {
+		if dl.getIgnore(parent).MatchesPath(path) {
+			slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
 			return true
 		}
+		parent = filepath.Dir(parent)
 	}
 	return false
 }
 
+func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
+	return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
+		var lines []string
+		for _, ign := range []string{".crushignore", ".gitignore"} {
+			name := filepath.Join(path, ign)
+			if content, err := os.ReadFile(name); err == nil {
+				lines = append(lines, strings.Split(string(content), "\n")...)
+			}
+		}
+		if len(lines) == 0 {
+			// Return a no-op parser to avoid nil checks
+			return ignore.CompileIgnoreLines()
+		}
+		return ignore.CompileIgnoreLines(lines...)
+	})
+}
+
 // ListDirectory lists files and directories in the specified path,
 func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
 	var results []string
@@ -144,6 +193,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
 		ToSlash: fastwalk.DefaultToSlash(),
 		Sort:    fastwalk.SortDirsFirst,
 	}
+
 	err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
 		if err != nil {
 			return nil // Skip files we don't have permission to access

internal/tui/components/chat/editor/editor.go 🔗

@@ -473,7 +473,7 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
 }
 
 func (m *editorCmp) startCompletions() tea.Msg {
-	files, _, _ := fsext.ListDirectory(".", []string{}, 0)
+	files, _, _ := fsext.ListDirectory(".", nil, 0)
 	completionItems := make([]completions.Completion, 0, len(files))
 	for _, file := range files {
 		file = strings.TrimPrefix(file, "./")