From 351275cb8c7cda1c9c629b1d18de72a6c4e88573 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 9 Jul 2025 11:54:43 +0530 Subject: [PATCH] Work on adding ignorefiles support to choose-files --- kittens/choose_files/collection.go | 3 +- kittens/choose_files/scan.go | 115 ++++++++++++++++++++++++++--- kittens/choose_files/scan_test.go | 43 ++++++++++- tools/ignorefiles/api.go | 12 ++- tools/ignorefiles/gitignore.go | 46 +++++++++++- 5 files changed, 200 insertions(+), 19 deletions(-) diff --git a/kittens/choose_files/collection.go b/kittens/choose_files/collection.go index 4a5abbae5..134160a1a 100644 --- a/kittens/choose_files/collection.go +++ b/kittens/choose_files/collection.go @@ -74,10 +74,11 @@ func (c *ResultCollection) Batch(offset *CollectionIndex) (ans []ResultItem) { return } -func (c *ResultCollection) NextDir(offset *CollectionIndex) (ans string) { +func (c *ResultCollection) NextDir(offset *CollectionIndex) (ans string, ignore_files []ignore_file_with_prefix) { for ans == "" && offset.Compare(c.append_idx) < 0 { if c.slices[offset.Slice][offset.Pos].ftype&fs.ModeDir != 0 { ans = c.slices[offset.Slice][offset.Pos].text + ignore_files = c.slices[offset.Slice][offset.Pos].ignore_files } offset.Pos++ if offset.Pos >= len(c.slices[offset.Slice]) { diff --git a/kittens/choose_files/scan.go b/kittens/choose_files/scan.go index 253e561e0..52b5dba7d 100644 --- a/kittens/choose_files/scan.go +++ b/kittens/choose_files/scan.go @@ -18,6 +18,7 @@ import ( "unicode/utf8" "github.com/kovidgoyal/kitty/tools/fzf" + "github.com/kovidgoyal/kitty/tools/ignorefiles" "github.com/kovidgoyal/kitty/tools/utils" "golang.org/x/sys/unix" ) @@ -28,11 +29,23 @@ func (c CombinedScore) String() string { return fmt.Sprintf("{score: %d length: %d index: %d}", c.Score(), c.Length(), c.Index()) } +type ignore_file_with_prefix struct { + impl ignorefiles.IgnoreFile + prefix string +} + +func (i *ignore_file_with_prefix) is_ignored(name string, ftype fs.FileMode) (ans bool, was_match bool) { + ans, linenum, _ := i.impl.IsIgnored(i.prefix+name, ftype) + was_match = linenum > -1 + return +} + type ResultItem struct { - text string - ftype fs.FileMode - positions []int // may be nil - score CombinedScore + text string + ftype fs.FileMode + positions []int // may be nil + score CombinedScore + ignore_files []ignore_file_with_prefix } type ResultsType []*ResultItem @@ -63,7 +76,9 @@ type FileSystemScanner struct { mutex sync.Mutex collection *ResultCollection dir_reader func(path string) ([]fs.DirEntry, error) + file_reader func(path string) ([]byte, error) filter_func func(filename string) bool + global_gitignore ignorefiles.IgnoreFile err error } @@ -72,7 +87,9 @@ func NewFileSystemScanner(root_dir string, notify chan bool, filter_func func(st ans.in_progress.Store(true) ans.keep_going.Store(true) ans.dir_reader = os.ReadDir + ans.file_reader = os.ReadFile ans.filter_func = utils.IfElse(filter_func == nil, accept_all, filter_func) + ans.global_gitignore = ignorefiles.GlobalGitignore() return ans } @@ -206,11 +223,17 @@ func (fss *FileSystemScanner) worker() { root_dir += string(os.PathSeparator) } dir := root_dir + var ignore_files []ignore_file_with_prefix base := "" pos := &CollectionIndex{} var arena []sortable_dir_entry var sortable []*sortable_dir_entry + var ignoreable []*sortable_dir_entry var idx uint32 + dot_git := os.Getenv("GIT_DIR") + if dot_git == "" { + dot_git = ".git" + } // do a breadth first traversal of the filesystem is_root := true for dir != "" { @@ -230,15 +253,59 @@ func (fss *FileSystemScanner) worker() { if cap(arena) < len(entries) { arena = make([]sortable_dir_entry, 0, max(1024, len(entries), 2*cap(arena))) sortable = make([]*sortable_dir_entry, 0, cap(arena)) + ignoreable = make([]*sortable_dir_entry, 0, cap(arena)) } arena = arena[:len(entries)] sortable = sortable[:0] + ignoreable = ignoreable[:0] + ignore_files_copied := false + add_ignore_file_from_impl := func(impl ignorefiles.IgnoreFile) { + // we want ignore_files to be a copy as we dont want to + // change the underlying array of ignore_files as it is + // referenced by multiple ResultItems + if !ignore_files_copied { + ignore_files_copied = true + n := make([]ignore_file_with_prefix, len(ignore_files), len(ignore_files)+4) + copy(n, ignore_files) + ignore_files = n + } + ignore_files = append(ignore_files, ignore_file_with_prefix{impl: impl}) + } + add_ignore_file := func(name string) { + if data, rerr := fss.file_reader(dir + string(os.PathSeparator) + name); rerr == nil { + impl := ignorefiles.NewGitignore() + if rerr = impl.LoadString(utils.UnsafeBytesToString(data)); rerr == nil { + add_ignore_file_from_impl(impl) + } + } + } + entry_is_ignored := func(name string, ftype fs.FileMode) (is_ignored bool) { + for _, ignore_file := range ignore_files { + if iig, was_match := ignore_file.is_ignored(name, ftype); was_match { + is_ignored = iig + } + } + return + } + has_git_ignore, has_dot_git, has_dot_ignore := false, false, false for i, e := range entries { name := e.Name() ftype := e.Type() is_dir := ftype&fs.ModeDir != 0 - if !is_dir && !fss.filter_func(name) { - continue + if !is_dir { + switch name { + case ".ignore": + has_git_ignore = false + case ".gitignore": + has_git_ignore = true + } + if !fss.filter_func(name) { + continue + } + } else { + if name == dot_git { + has_dot_git = true + } } arena[i].name = name if ftype&fs.ModeSymlink != 0 { @@ -256,13 +323,36 @@ func (fss *FileSystemScanner) worker() { arena[i].sort_key = arena[i].buf[:1+n] sortable = append(sortable, &arena[i]) } - slices.SortFunc(sortable, func(a, b *sortable_dir_entry) int { return bytes.Compare(a.sort_key, b.sort_key) }) + if has_dot_git { + if fss.global_gitignore != nil { + add_ignore_file_from_impl(fss.global_gitignore) + } + add_ignore_file(filepath.Join(dot_git, "info", "exclude")) + if has_git_ignore { + add_ignore_file(".gitignore") + } + } + if has_dot_ignore { + add_ignore_file(".ignore") + } + final_entries := sortable + if len(ignore_files) > 0 { + for _, e := range sortable { + if !entry_is_ignored(e.name, e.ftype) { + ignoreable = append(ignoreable, e) + } + + } + final_entries = ignoreable + } + slices.SortFunc(final_entries, func(a, b *sortable_dir_entry) int { return bytes.Compare(a.sort_key, b.sort_key) }) fss.lock() - for _, e := range sortable { + for _, e := range final_entries { i := fss.collection.NextAppendPointer() i.ftype = e.ftype i.text = base + e.name i.score.Set_index(idx) + i.ignore_files = ignore_files idx++ } listeners := fss.listeners @@ -273,9 +363,16 @@ func (fss *FileSystemScanner) worker() { default: } } - if relpath := fss.collection.NextDir(pos); relpath != "" { + ignore_files = nil + if relpath, ignf := fss.collection.NextDir(pos); relpath != "" { base = relpath + string(os.PathSeparator) dir = root_dir + base + if len(ignf) != 0 { + name := filepath.Base(relpath) + string(os.PathSeparator) + ignore_files = utils.Map(func(ignore_file ignore_file_with_prefix) ignore_file_with_prefix { + return ignore_file_with_prefix{impl: ignore_file.impl, prefix: ignore_file.prefix + name} + }, ignf) + } } else { dir = "" } diff --git a/kittens/choose_files/scan_test.go b/kittens/choose_files/scan_test.go index 08020c458..7b0c2c85b 100644 --- a/kittens/choose_files/scan_test.go +++ b/kittens/choose_files/scan_test.go @@ -33,6 +33,7 @@ func TestAsLower(t *testing.T) { type node struct { name string children map[string]*node + data string } func (n node) Name() string { @@ -87,6 +88,21 @@ func (n node) dir_entries() []fs.DirEntry { return entries } +func (n node) ReadFile(name string) ([]byte, error) { + if name == string(os.PathSeparator) { + return nil, fs.ErrNotExist + } + p := &n + for _, x := range strings.Split(strings.Trim(name, string(os.PathSeparator)), string(os.PathSeparator)) { + c, found := p.children[x] + if !found || c.IsDir() { + return nil, fs.ErrNotExist + } + p = c + } + return []byte(p.data), nil +} + func (n node) ReadDir(name string) ([]fs.DirEntry, error) { if name == string(os.PathSeparator) { return n.dir_entries(), nil @@ -105,19 +121,38 @@ func (n node) ReadDir(name string) ([]fs.DirEntry, error) { return p.dir_entries(), nil } +func TestChooseFilesIgnore(t *testing.T) { + root := node{name: string(os.PathSeparator), children: map[string]*node{ + "b": {name: "b"}, + "a": {name: "a"}, + "c.png": {name: "c.png"}, + ".ignore": {name: ".ignore", data: "a\nx/s/n"}, + "x": {name: "x", children: map[string]*node{ + "1": {name: "1"}, "2": {name: "2"}, "3": {name: "3"}, + "s": {name: "s", children: map[string]*node{ + "m": {name: "m"}, "n": {name: "n"}, + }}, + }}, + "y": {name: "y", children: map[string]*node{ + "3": {name: "3"}, "4": {name: "4"}, "5": {name: "5"}, + }}, + }} + _ = root +} + func TestChooseFilesScoring(t *testing.T) { root := node{name: string(os.PathSeparator), children: map[string]*node{ "b": {name: "b"}, "a": {name: "a"}, "c.png": {name: "c.png"}, "x": {name: "x", children: map[string]*node{ - "1": {"1", nil}, "2": {"2", nil}, "3": {"3", nil}, - "s": {"s", map[string]*node{ - "m": {"m", nil}, "n": {"n", nil}, + "1": {name: "1"}, "2": {name: "2"}, "3": {name: "3"}, + "s": {name: "s", children: map[string]*node{ + "m": {name: "m"}, "n": {name: "n"}, }}, }}, "y": {name: "y", children: map[string]*node{ - "3": {"3", nil}, "4": {"4", nil}, "5": {"5", nil}, + "3": {name: "3"}, "4": {name: "4"}, "5": {name: "5"}, }}, }} wg := sync.WaitGroup{} diff --git a/tools/ignorefiles/api.go b/tools/ignorefiles/api.go index d91a6038a..4821fafbf 100644 --- a/tools/ignorefiles/api.go +++ b/tools/ignorefiles/api.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "io/fs" + "sync" ) var _ = fmt.Print @@ -16,10 +17,15 @@ type IgnoreFile interface { LoadPath(string) error // relpath is the path relative to the directory containing the ignorefile. - // When is_ignored is true, linenum_of_matching_rule will be the line - // number of the rule causing relpath to be ignored and pattern is the - // textual representation of the matching pattern. + // When the result is due to a rule matching, linenum_of_matching_rule is + // >=0 and pattern is the textual representation of the rule. Otherwise + // linenum_of_matching_rule is -1 and pattern is the empty string. IsIgnored(relpath string, ftype fs.FileMode) (is_ignored bool, linenum_of_matching_rule int, pattern string) } func NewGitignore() IgnoreFile { return &Gitignore{index_of_last_negated_rule: -1} } + +// The global gitignore from ~/.config/git/ignore +var GlobalGitignore = sync.OnceValue(func() IgnoreFile { + return get_global_gitignore() +}) diff --git a/tools/ignorefiles/gitignore.go b/tools/ignorefiles/gitignore.go index 85af292b0..7c266b708 100644 --- a/tools/ignorefiles/gitignore.go +++ b/tools/ignorefiles/gitignore.go @@ -41,8 +41,8 @@ func (g Gitignore) IsIgnored(relpath string, ftype os.FileMode) (is_ignored bool } if pat.negated && pat.Match(relpath, ftype) { is_ignored = false - linenum_of_matching_rule = -1 - pattern = "" + linenum_of_matching_rule = pat.line_number + pattern = pat.pattern } } else { if !pat.negated && pat.Match(relpath, ftype) { @@ -257,3 +257,45 @@ func CompileGitIgnoreLine(line string) (ans GitPattern, skipped_line bool) { } return } + +func get_global_gitconfig_excludesfile() (ans string) { + cfhome := os.Getenv("XDG_CONFIG_HOME") + if cfhome == "" { + cfhome = utils.Expanduser("~/.config") + } + for _, candidate := range []string{"/etc/gitconfig", filepath.Join(cfhome, "git", "config"), utils.Expanduser("~/.gitconfig")} { + if data, err := os.ReadFile(candidate); err == nil { + s := utils.NewLineScanner(utils.UnsafeBytesToString(data)) + in_core := false + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if in_core { + if strings.HasPrefix(line, "[") { + in_core = false + continue + } + if k, rest, found := strings.Cut(line, "="); found && strings.ToLower(strings.TrimSpace(k)) == `excludesfile` { + ans = strings.TrimSpace(rest) + } + } else if strings.ToLower(line) == "[core]" { + in_core = true + } + } + } + } + if ans == "" { + ans = filepath.Join(cfhome, "git", "ignore") + } + return +} + +func get_global_gitignore() (ans IgnoreFile) { + excludesfile := get_global_gitconfig_excludesfile() + if data, err := os.ReadFile(excludesfile); err == nil { + q := NewGitignore() + if q.LoadString(utils.UnsafeBytesToString(data)) == nil { + ans = q + } + } + return +}