on main help sign in
Merge pull request #633 from charmbracelet/ignore

feat: improve gitignore and crushignore support

commit318cc22353345b2e236db3a66cda74a509f7ba40 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 8 Aug 2025, 00:06 (1 year ago) parenta9e5221a05b434
6 files changed+184−120
internal/config/load.go+13−9 internal/csync/maps.go+12 internal/csync/maps_test.go+10 internal/fsext/ignore_test.go+4−16 internal/fsext/ls.go+144−94 internal/tui/components/chat/editor/editor.go+1−1
internal/config/load.go+13−9
@@ -1,16 +1,19 @@
11package config
22
33import (
4+ "cmp"
45 "encoding/json"
56 "fmt"
67 "io"
78 "log/slog"
89 "maps"
910 "os"
11+ "os/user"
1012 "path/filepath"
1113 "runtime"
1214 "slices"
1315 "strings"
16+ "sync"
1417
1518 "github.com/charmbracelet/catwalk/pkg/catwalk"
1619 "github.com/charmbracelet/crush/internal/csync"
@@ -538,13 +541,14 @@ func GlobalConfigData() string {
538541 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
539542}
540543
541−func HomeDir() string {
542− homeDir := os.Getenv("HOME")
543− if homeDir == "" {
544− homeDir = os.Getenv("USERPROFILE") // For Windows compatibility
544+var HomeDir = sync.OnceValue(func() string {
545+ u, err := user.Current()
546+ if err == nil {
547+ return u.HomeDir
545548 }
546− if homeDir == "" {
547− homeDir = os.Getenv("HOMEPATH") // Fallback for some environments
548− }
549− return homeDir
550−}
549+ return cmp.Or(
550+ os.Getenv("HOME"),
551+ os.Getenv("USERPROFILE"),
552+ os.Getenv("HOMEPATH"),
553+ )
554+})
internal/csync/maps.go+12
@@ -56,6 +56,18 @@ func (m *Map[K, V]) Len() int {
5656 return len(m.inner)
5757}
5858
59+// GetOrSet gets and returns the key if it exists, otherwise, it executes the
60+// given function, set its return value for the given key, and returns it.
61+func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
62+ got, ok := m.Get(key)
63+ if ok {
64+ return got
65+ }
66+ value := fn()
67+ m.Set(key, value)
68+ return value
69+}
70+
5971// Take gets an item and then deletes it.
6072func (m *Map[K, V]) Take(key K) (V, bool) {
6173 m.mu.Lock()
internal/csync/maps_test.go+10
@@ -54,6 +54,16 @@ func TestMap_Set(t *testing.T) {
5454 require.Equal(t, 1, m.Len())
5555}
5656
57+func TestMap_GetOrSet(t *testing.T) {
58+ t.Parallel()
59+
60+ m := NewMap[string, int]()
61+
62+ require.Equal(t, 42, m.GetOrSet("key1", func() int { return 42 }))
63+ require.Equal(t, 42, m.GetOrSet("key1", func() int { return 99999 }))
64+ require.Equal(t, 1, m.Len())
65+}
66+
5767func TestMap_Get(t *testing.T) {
5868 t.Parallel()
5969
internal/fsext/ignore_test.go+4−16
@@ -25,20 +25,8 @@ func TestCrushIgnore(t *testing.T) {
2525 // Create a .crushignore file that ignores .log files
2626 require.NoError(t, os.WriteFile(".crushignore", []byte("*.log\n"), 0o644))
2727
28− // Test DirectoryLister
29− t.Run("DirectoryLister respects .crushignore", func(t *testing.T) {
30− dl := NewDirectoryLister(tempDir)
31−
32− // Test that .log files are ignored
33− require.True(t, dl.gitignore == nil, "gitignore should be nil")
34− require.NotNil(t, dl.crushignore, "crushignore should not be nil")
35− })
36−
37− // Test FastGlobWalker
38− t.Run("FastGlobWalker respects .crushignore", func(t *testing.T) {
39− walker := NewFastGlobWalker(tempDir)
40−
41− require.True(t, walker.gitignore == nil, "gitignore should be nil")
42− require.NotNil(t, walker.crushignore, "crushignore should not be nil")
43− })
28+ dl := NewDirectoryLister(tempDir)
29+ require.True(t, dl.shouldIgnore("test2.log", nil), ".log files should be ignored")
30+ require.False(t, dl.shouldIgnore("test1.txt", nil), ".txt files should not be ignored")
31+ require.True(t, dl.shouldIgnore("test3.tmp", nil), ".tmp files should be ignored by common patterns")
4432}
internal/fsext/ls.go+144−94
@@ -1,137 +1,186 @@
11package fsext
22
33import (
4+ "log/slog"
45 "os"
56 "path/filepath"
7+ "strings"
8+ "sync"
69
710 "github.com/charlievieth/fastwalk"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/csync"
813 ignore "github.com/sabhiram/go-gitignore"
914)
1015
11−// CommonIgnorePatterns contains commonly ignored files and directories
12−var CommonIgnorePatterns = []string{
13− // Version control
14− ".git",
15− ".svn",
16− ".hg",
17− ".bzr",
18−
19− // IDE and editor files
20− ".vscode",
21− ".idea",
22− "*.swp",
23− "*.swo",
24− "*~",
25− ".DS_Store",
26− "Thumbs.db",
27−
28− // Build artifacts and dependencies
29− "node_modules",
30− "target",
31− "build",
32− "dist",
33− "out",
34− "bin",
35− "obj",
36− "*.o",
37− "*.so",
38− "*.dylib",
39− "*.dll",
40− "*.exe",
41−
42− // Logs and temporary files
43− "*.log",
44− "*.tmp",
45− "*.temp",
46− ".cache",
47− ".tmp",
48−
49− // Language-specific
50− "__pycache__",
51− "*.pyc",
52− "*.pyo",
53− ".pytest_cache",
54− "vendor",
55− "Cargo.lock",
56− "package-lock.json",
57− "yarn.lock",
58− "pnpm-lock.yaml",
59−
60− // OS generated files
61− ".Trash",
62− ".Spotlight-V100",
63− ".fseventsd",
64−
65− // Crush
66− ".crush",
67−}
16+// commonIgnorePatterns contains commonly ignored files and directories
17+var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
18+ return ignore.CompileIgnoreLines(
19+ // Version control
20+ ".git",
21+ ".svn",
22+ ".hg",
23+ ".bzr",
24+
25+ // IDE and editor files
26+ ".vscode",
27+ ".idea",
28+ "*.swp",
29+ "*.swo",
30+ "*~",
31+ ".DS_Store",
32+ "Thumbs.db",
33+
34+ // Build artifacts and dependencies
35+ "node_modules",
36+ "target",
37+ "build",
38+ "dist",
39+ "out",
40+ "bin",
41+ "obj",
42+ "*.o",
43+ "*.so",
44+ "*.dylib",
45+ "*.dll",
46+ "*.exe",
47+
48+ // Logs and temporary files
49+ "*.log",
50+ "*.tmp",
51+ "*.temp",
52+ ".cache",
53+ ".tmp",
54+
55+ // Language-specific
56+ "__pycache__",
57+ "*.pyc",
58+ "*.pyo",
59+ ".pytest_cache",
60+ "vendor",
61+ "Cargo.lock",
62+ "package-lock.json",
63+ "yarn.lock",
64+ "pnpm-lock.yaml",
65+
66+ // OS generated files
67+ ".Trash",
68+ ".Spotlight-V100",
69+ ".fseventsd",
70+
71+ // Crush
72+ ".crush",
73+ )
74+})
75+
76+var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
77+ home := config.HomeDir()
78+ var lines []string
79+ for _, name := range []string{
80+ filepath.Join(home, ".gitignore"),
81+ filepath.Join(home, ".config", "git", "ignore"),
82+ filepath.Join(home, ".config", "crush", "ignore"),
83+ } {
84+ if bts, err := os.ReadFile(name); err == nil {
85+ lines = append(lines, strings.Split(string(bts), "\n")...)
86+ }
87+ }
88+ return ignore.CompileIgnoreLines(lines...)
89+})
6890
69−type DirectoryLister struct {
70− gitignore *ignore.GitIgnore
71− crushignore *ignore.GitIgnore
72− commonIgnore *ignore.GitIgnore
73− rootPath string
91+type directoryLister struct {
92+ ignores *csync.Map[string, ignore.IgnoreParser]
93+ rootPath string
7494}
7595
76−func NewDirectoryLister(rootPath string) *DirectoryLister {
77− dl := &DirectoryLister{
96+func NewDirectoryLister(rootPath string) *directoryLister {
97+ dl := &directoryLister{
7898 rootPath: rootPath,
99+ ignores: csync.NewMap[string, ignore.IgnoreParser](),
79100 }
101+ dl.getIgnore(rootPath)
102+ return dl
103+}
80104
81− // Load gitignore if it exists
82− gitignorePath := filepath.Join(rootPath, ".gitignore")
83− if _, err := os.Stat(gitignorePath); err == nil {
84− if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
85− dl.gitignore = gi
86− }
87− }
88−
89− // Load crushignore if it exists
90− crushignorePath := filepath.Join(rootPath, ".crushignore")
91− if _, err := os.Stat(crushignorePath); err == nil {
92− if ci, err := ignore.CompileIgnoreFile(crushignorePath); err == nil {
93− dl.crushignore = ci
105+// git checks, in order:
106+// - ./.gitignore, ../.gitignore, etc, until repo root
107+// ~/.config/git/ignore
108+// ~/.gitignore
109+//
110+// This will do the following:
111+// - the given ignorePatterns
112+// - [commonIgnorePatterns]
113+// - ./.gitignore, ../.gitignore, etc, until dl.rootPath
114+// - ./.crushignore, ../.crushignore, etc, until dl.rootPath
115+// ~/.config/git/ignore
116+// ~/.gitignore
117+// ~/.config/crush/ignore
118+func (dl *directoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
119+ if len(ignorePatterns) > 0 {
120+ base := filepath.Base(path)
121+ for _, pattern := range ignorePatterns {
122+ if matched, err := filepath.Match(pattern, base); err == nil && matched {
123+ return true
124+ }
94125 }
95126 }
96127
97− // Create common ignore patterns
98− dl.commonIgnore = ignore.CompileIgnoreLines(CommonIgnorePatterns...)
99−
100− return dl
101−}
102−
103−func (dl *DirectoryLister) shouldIgnore(path string, ignorePatterns []string) bool {
104128 relPath, err := filepath.Rel(dl.rootPath, path)
105129 if err != nil {
106130 relPath = path
107131 }
108132
109− // Check common ignore patterns
110− if dl.commonIgnore.MatchesPath(relPath) {
133+ if commonIgnorePatterns().MatchesPath(relPath) {
134+ slog.Debug("ingoring common pattern", "path", relPath)
111135 return true
112136 }
113137
114− // Check gitignore patterns if available
115− if dl.gitignore != nil && dl.gitignore.MatchesPath(relPath) {
138+ if dl.getIgnore(filepath.Dir(path)).MatchesPath(relPath) {
139+ slog.Debug("ingoring dir pattern", "path", relPath, "dir", filepath.Dir(path))
116140 return true
117141 }
118142
119− // Check crushignore patterns if available
120− if dl.crushignore != nil && dl.crushignore.MatchesPath(relPath) {
143+ if dl.checkParentIgnores(relPath) {
121144 return true
122145 }
123146
124− base := filepath.Base(path)
147+ if homeIgnore().MatchesPath(relPath) {
148+ slog.Debug("ingoring home dir pattern", "path", relPath)
149+ return true
150+ }
151+
152+ return false
153+}
125154
126− for _, pattern := range ignorePatterns {
127− matched, err := filepath.Match(pattern, base)
128− if err == nil && matched {
155+func (dl *directoryLister) checkParentIgnores(path string) bool {
156+ parent := filepath.Dir(filepath.Dir(path))
157+ for parent != dl.rootPath && parent != "." && path != "." {
158+ if dl.getIgnore(parent).MatchesPath(path) {
159+ slog.Debug("ingoring parent dir pattern", "path", path, "dir", parent)
129160 return true
130161 }
162+ parent = filepath.Dir(parent)
131163 }
132164 return false
133165}
134166
167+func (dl *directoryLister) getIgnore(path string) ignore.IgnoreParser {
168+ return dl.ignores.GetOrSet(path, func() ignore.IgnoreParser {
169+ var lines []string
170+ for _, ign := range []string{".crushignore", ".gitignore"} {
171+ name := filepath.Join(path, ign)
172+ if content, err := os.ReadFile(name); err == nil {
173+ lines = append(lines, strings.Split(string(content), "\n")...)
174+ }
175+ }
176+ if len(lines) == 0 {
177+ // Return a no-op parser to avoid nil checks
178+ return ignore.CompileIgnoreLines()
179+ }
180+ return ignore.CompileIgnoreLines(lines...)
181+ })
182+}
183+
135184// ListDirectory lists files and directories in the specified path,
136185func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]string, bool, error) {
137186 var results []string
@@ -144,6 +193,7 @@ func ListDirectory(initialPath string, ignorePatterns []string, limit int) ([]st
144193 ToSlash: fastwalk.DefaultToSlash(),
145194 Sort: fastwalk.SortDirsFirst,
146195 }
196+
147197 err := fastwalk.Walk(&conf, initialPath, func(path string, d os.DirEntry, err error) error {
148198 if err != nil {
149199 return nil // Skip files we don't have permission to access
internal/tui/components/chat/editor/editor.go+1−1
@@ -473,7 +473,7 @@ func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
473473}
474474
475475func (m *editorCmp) startCompletions() tea.Msg {
476− files, _, _ := fsext.ListDirectory(".", []string{}, 0)
476+ files, _, _ := fsext.ListDirectory(".", nil, 0)
477477 completionItems := make([]completions.Completion, 0, len(files))
478478 for _, file := range files {
479479 file = strings.TrimPrefix(file, "./")