1// Package ignore provides a minimal .gitignore matcher shared across
2// zot. It is intentionally small: enough to drop obvious non-source
3// directories (build outputs, dependency and tool caches) from
4// recursive walks, not a faithful git reimplementation.
5package ignore
6
7import (
8 "os"
9 "path/filepath"
10 "strings"
11)
12
13// Gitignore is a minimal .gitignore matcher. It supports the common
14// patterns used in real repos: blank lines, comments (#), negation (!),
15// directory-only patterns (trailing /), anchored patterns (leading /),
16// and the * / ? / [..] wildcards via filepath.Match. It intentionally
17// does not implement ** globstar or nested per-directory .gitignore
18// files.
19type Gitignore struct {
20 rules []rule
21}
22
23type rule struct {
24 pattern string
25 negate bool
26 dirOnly bool
27 anchored bool
28}
29
30// Load reads the .gitignore at the root directory. A missing or
31// unreadable file yields an empty matcher that ignores nothing.
32func Load(root string) *Gitignore {
33 data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
34 if err != nil {
35 return &Gitignore{}
36 }
37 return Parse(string(data))
38}
39
40// Parse builds a matcher from raw .gitignore file contents.
41func Parse(data string) *Gitignore {
42 g := &Gitignore{}
43 for _, line := range strings.Split(data, "\n") {
44 line = strings.TrimRight(line, "\r")
45 trimmed := strings.TrimSpace(line)
46 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
47 continue
48 }
49 r := rule{pattern: trimmed}
50 if strings.HasPrefix(r.pattern, "!") {
51 r.negate = true
52 r.pattern = r.pattern[1:]
53 }
54 if strings.HasSuffix(r.pattern, "/") {
55 r.dirOnly = true
56 r.pattern = strings.TrimSuffix(r.pattern, "/")
57 }
58 if strings.HasPrefix(r.pattern, "/") {
59 r.anchored = true
60 r.pattern = strings.TrimPrefix(r.pattern, "/")
61 }
62 if r.pattern == "" {
63 continue
64 }
65 g.rules = append(g.rules, r)
66 }
67 return g
68}
69
70// Match reports whether the slash-separated relative path should be
71// ignored. Later rules win, so a trailing negation can re-include a
72// previously ignored path.
73func (g *Gitignore) Match(rel string, isDir bool) bool {
74 ignored := false
75 for _, r := range g.rules {
76 if r.dirOnly && !isDir {
77 continue
78 }
79 if r.matchPath(rel) {
80 ignored = !r.negate
81 }
82 }
83 return ignored
84}
85
86func (r rule) matchPath(rel string) bool {
87 if r.anchored || strings.Contains(r.pattern, "/") {
88 if ok, _ := filepath.Match(r.pattern, rel); ok {
89 return true
90 }
91 // Anchored directory pattern also matches everything beneath it.
92 return strings.HasPrefix(rel, r.pattern+"/")
93 }
94 // Unanchored: match the basename of any path component.
95 base := rel
96 if i := strings.LastIndex(rel, "/"); i >= 0 {
97 base = rel[i+1:]
98 }
99 if ok, _ := filepath.Match(r.pattern, base); ok {
100 return true
101 }
102 // Match a directory component anywhere in the path.
103 for _, part := range strings.Split(rel, "/") {
104 if ok, _ := filepath.Match(r.pattern, part); ok {
105 return true
106 }
107 }
108 return false
109}