mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-11 11:09:16 +02:00
314 lines
7.3 KiB
Go
314 lines
7.3 KiB
Go
package ignorefiles
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/kovidgoyal/kitty/tools/utils"
|
|
)
|
|
|
|
var _ = fmt.Print
|
|
|
|
type GitPattern struct {
|
|
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) Len() int { return len(g.patterns) }
|
|
|
|
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 = pat.line_number
|
|
pattern = pat.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
|
|
}
|
|
return p.matcher(path)
|
|
}
|
|
|
|
func anchored_single_match(path string, pattern string) bool {
|
|
name, _, _ := strings.Cut(path, "/")
|
|
matches, err := filepath.Match(pattern, name)
|
|
return err == nil && matches
|
|
}
|
|
|
|
func unanchored_single_match(path string, pattern string) bool {
|
|
for path != "" {
|
|
var name string
|
|
name, path, _ = strings.Cut(path, "/")
|
|
matches, err := filepath.Match(pattern, name)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if matches {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func anchored_simple_match(path string, parts []string) bool {
|
|
for ; path != "" && len(parts) > 0; parts = parts[1:] {
|
|
var name string
|
|
name, path, _ = strings.Cut(path, "/")
|
|
if matches, err := filepath.Match(parts[0], name); err != nil || !matches {
|
|
return false
|
|
}
|
|
}
|
|
return path == "" && len(parts) == 0
|
|
}
|
|
|
|
func anchored_full_match(path string, parts []string) bool {
|
|
pos, last := 0, len(parts)-1
|
|
for pos <= last && path != "" {
|
|
var name string
|
|
name, path, _ = strings.Cut(path, "/")
|
|
switch parts[pos] {
|
|
case "**":
|
|
for pos+1 < len(parts) && parts[pos+1] == "**" {
|
|
pos++
|
|
}
|
|
if pos == last {
|
|
return true
|
|
}
|
|
pos++
|
|
for {
|
|
matches, err := filepath.Match(parts[pos], name)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if matches {
|
|
return anchored_full_match(path, parts[pos+1:])
|
|
}
|
|
if path == "" {
|
|
return false
|
|
}
|
|
name, path, _ = strings.Cut(path, "/")
|
|
}
|
|
default:
|
|
if matches, err := filepath.Match(parts[pos], name); err != nil || !matches {
|
|
return false
|
|
}
|
|
pos++
|
|
}
|
|
}
|
|
return path == "" && pos > last
|
|
}
|
|
|
|
// Parse a line from a .gitignore file, see man gitignore for the syntax
|
|
func CompileGitIgnoreLine(line string) (ans GitPattern, skipped_line bool) {
|
|
// Strip comments
|
|
if strings.HasPrefix(line, `#`) {
|
|
skipped_line = true
|
|
return
|
|
}
|
|
|
|
// Trim OS-specific carriage returns.
|
|
line = strings.TrimRight(line, "\r")
|
|
|
|
// Trim trailing spaces unless backslash escaped
|
|
for strings.HasSuffix(line, " ") {
|
|
if strings.HasSuffix(line, `\ `) {
|
|
line = line[:len(line)-2] + " "
|
|
break
|
|
}
|
|
line = line[:len(line)-1]
|
|
}
|
|
|
|
// Empty lines are ignored
|
|
if line == "" {
|
|
skipped_line = true
|
|
return
|
|
}
|
|
ans.pattern = line
|
|
|
|
// Handle negated (accept) patterns
|
|
if line[0] == '!' {
|
|
line = line[1:]
|
|
ans.negated = true
|
|
}
|
|
|
|
// Handle leading slash used to escape leading # or !
|
|
if line[0] == '\\' && len(line) > 1 && (line[1] == '#' || line[1] == '!') {
|
|
line = line[1:]
|
|
}
|
|
if strings.HasSuffix(line, "/") {
|
|
ans.only_dirs = true
|
|
line = strings.TrimRight(line, "/")
|
|
if line == "" {
|
|
skipped_line = true
|
|
return
|
|
}
|
|
}
|
|
starts_with_slash := strings.HasPrefix(line, "/")
|
|
if starts_with_slash {
|
|
line = strings.TrimLeft(line, "/")
|
|
}
|
|
ans.parts = strings.Split(line, "/")
|
|
if slices.Contains(ans.parts, "") {
|
|
ans.parts = slices.DeleteFunc(ans.parts, func(x string) bool { return x == "" })
|
|
}
|
|
if len(ans.parts) == 0 {
|
|
skipped_line = true
|
|
return
|
|
}
|
|
if len(ans.parts) == 1 {
|
|
pattern := ans.parts[0]
|
|
if pattern == "**" {
|
|
ans.matcher = func(string) bool { return true }
|
|
} else {
|
|
if starts_with_slash {
|
|
ans.matcher = func(path string) bool { return anchored_single_match(path, pattern) }
|
|
} else {
|
|
ans.matcher = func(path string) bool { return unanchored_single_match(path, pattern) }
|
|
}
|
|
}
|
|
} else {
|
|
if slices.Contains(ans.parts, "**") {
|
|
ans.matcher = func(path string) bool { return anchored_full_match(path, ans.parts) }
|
|
} else {
|
|
ans.matcher = func(path string) bool { return anchored_simple_match(path, ans.parts) }
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func get_global_gitconfig_excludesfile() (ans string) {
|
|
return _get_global_gitconfig_excludesfile(utils.Expanduser("~/.gitconfig"))
|
|
}
|
|
|
|
func _get_global_gitconfig_excludesfile(home_gitconfig string) (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"), home_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 = line == "[core]"
|
|
continue
|
|
}
|
|
if k, rest, found := strings.Cut(line, "="); found && strings.ToLower(strings.TrimSpace(k)) == `excludesfile` {
|
|
ans = strings.TrimSpace(rest)
|
|
ans = utils.Expanduser(ans)
|
|
if !filepath.IsAbs(ans) {
|
|
if a, err := filepath.Abs(ans); err != nil {
|
|
ans = a
|
|
}
|
|
}
|
|
}
|
|
} 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
|
|
}
|