Finish up gitignore implementation

This commit is contained in:
Kovid Goyal
2025-07-08 12:03:58 +05:30
parent 4383398f25
commit c346735c74
3 changed files with 160 additions and 7 deletions

25
tools/ignorefiles/api.go Normal file
View File

@@ -0,0 +1,25 @@
package ignorefiles
import (
"fmt"
"io"
"io/fs"
)
var _ = fmt.Print
type IgnoreFile interface {
LoadString(string) error
LoadBytes([]byte) error
LoadLines(...string) error
LoadFile(io.Reader) error
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.
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} }

View File

@@ -2,29 +2,112 @@ package ignorefiles
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"github.com/kovidgoyal/kitty/tools/utils"
)
var _ = fmt.Print
type GitPattern struct {
only_dirs bool
negated bool
parts []string
matcher func(path string) bool
line_number int
only_dirs bool
negated bool
pattern string
parts []string
matcher func(path string) bool
}
type Gitignore struct {
patterns []GitPattern
index_of_last_negated_rule int
line_number_offset int
}
func (g Gitignore) IsIgnored(relpath string, ftype os.FileMode) (is_ignored bool, linenum_of_matching_rule int, pattern string) {
if os.PathSeparator != '/' {
relpath = strings.ReplaceAll(relpath, string(os.PathSeparator), "/")
}
linenum_of_matching_rule = -1
for i, pat := range g.patterns {
if is_ignored {
if i > g.index_of_last_negated_rule {
break
}
if pat.negated && pat.Match(relpath, ftype) {
is_ignored = false
linenum_of_matching_rule = -1
pattern = ""
}
} else {
if !pat.negated && pat.Match(relpath, ftype) {
is_ignored = true
linenum_of_matching_rule = pat.line_number
pattern = pat.pattern
}
}
}
return
}
func (g *Gitignore) load_line(line string, line_number int) {
if p, skipped_line := CompileGitIgnoreLine(line); !skipped_line {
p.line_number = g.line_number_offset + line_number
g.patterns = append(g.patterns, p)
if p.negated {
g.index_of_last_negated_rule = len(g.patterns) - 1
}
}
}
func (g *Gitignore) LoadLines(lines ...string) error {
for i, line := range lines {
g.load_line(line, i)
}
g.line_number_offset += len(lines)
return nil
}
func (g *Gitignore) LoadString(text string) error {
s := utils.NewLineScanner(text)
lnum := 0
for s.Scan() {
g.load_line(s.Text(), lnum)
lnum++
}
g.line_number_offset += lnum
return nil
}
func (g *Gitignore) LoadBytes(text []byte) error {
return g.LoadString(string(text))
}
func (g *Gitignore) LoadPath(path string) error {
if data, err := os.ReadFile(path); err == nil {
return g.LoadString(utils.UnsafeBytesToString(data))
} else {
return err
}
}
func (g *Gitignore) LoadFile(f io.Reader) error {
if data, err := io.ReadAll(f); err == nil {
return g.LoadString(utils.UnsafeBytesToString(data))
} else {
return err
}
}
func (p GitPattern) Match(path string, ftype fs.FileMode) bool {
if p.only_dirs && ftype&fs.ModeDir == 0 {
return false
}
if os.PathSeparator != '/' {
path = strings.ReplaceAll(path, string(os.PathSeparator), "/")
}
return p.matcher(path)
}
@@ -122,6 +205,7 @@ func CompileGitIgnoreLine(line string) (ans GitPattern, skipped_line bool) {
skipped_line = true
return
}
ans.pattern = line
// Handle negated (accept) patterns
if line[0] == '!' {

View File

@@ -98,4 +98,48 @@ func TestGitignore(t *testing.T) {
}
}
}
for text, tests := range map[string]map[string]bool{
``: {"foo": false},
`
# exclude everything except directory foo/bar
/*
!/foo
/foo/*
!/foo/bar`: {
"a": true, "foo": false, "foo/x": true, "foo/bar": false, "foo/bar/": false,
},
`
**/foo
bar `: {
"foo": true, "baz/foo": true, "bar": true, "baz/bar": true, "a": false,
},
`/*.c`: {"a.c": true, "b/a.c": false},
`
**/external/**/*.json
**/external/**/.*ignore
**/external/foobar/*.css`: {
"external/foobar/angular.foo.css": true, "external/barfoo/.gitignore": true, "external/barfoo/.bower.json": true,
},
"abc/def\r\nxyz": {"abc/def": true, "a/xyz": true},
`/**/foo`: {"foo": true, "foo/": true, "a/b/foo": true, "fooo": false, "ofoo": false},
"/.js": {".js": true, ".js/": true, ".js/a": true, ".jsa": false},
"*.js": {".js": true, ".js/": true, ".js/a": true, "a.js/a": true, "a.js/a.js": true, ".jsa": false, "a.jsa": false},
"foo/**/": {"foo/": false, "foo": false, "foo/abc/": true, "foo/a/b/c/": true, "foo/a": false},
"foo/**/*.bar": {"foo/": false, "abc.bar": false, "foo/abc.bar": true, "foo/a.bar/": true, "foo/x/y/z.bar": true},
`\#abc`: {"abc": false, "#abc": true},
"abc\n!abc/x": {"abc": true, "abc/x": false, "abc/y": true},
`abc/*`: {"abc": false, "abc/": false, "abc/x": true},
} {
p := NewGitignore()
if err := p.LoadString(text); err != nil {
t.Fatal(err)
}
for tpath, expected := range tests {
path := strings.TrimRight(tpath, "/")
ftype := utils.IfElse(len(path) < len(tpath), fs.ModeDir, 0)
if actual, _, _ := p.IsIgnored(path, ftype); actual != expected {
t.Fatalf("ignored: %v != %v for path: %#v and ignorefile:\n%s", expected, actual, tpath, text)
}
}
}
}