v0.4.4 e5506c1

gitignore.go RawHistoryFolder

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// Stack tracks the chain of .gitignore files from a walk's root down to
41// the directory currently being visited. Real repositories routinely
42// place a .gitignore inside a subdirectory (for example a vendored tool
43// directory that ignores its own node_modules), and those nested rules
44// are invisible to a single root-only matcher. During a recursive walk
45// such an unhonored node_modules can swamp the entry budget before the
46// walk ever reaches the files the user is actually looking for, so the
47// nested files must be applied.
48//
49// Each frame holds the matcher parsed from a directory's .gitignore
50// plus that directory's path relative to the walk root (slash
51// separated, "" for the root). A nested matcher's patterns are
52// evaluated against the path relative to the directory that owns them,
53// matching git's own semantics.
54type Stack struct {
55	root   string
56	frames []stackFrame
57}
58
59type stackFrame struct {
60	relDir string // dir owning the .gitignore, relative to root, slash-sep
61	ig     *Gitignore
62}
63
64// NewStack returns a Stack seeded with the root .gitignore (if any).
65func NewStack(root string) *Stack {
66	s := &Stack{root: root}
67	s.frames = append(s.frames, stackFrame{relDir: "", ig: Load(root)})
68	return s
69}
70
71// Push loads the .gitignore in dir (an absolute path under root, with
72// relDir its slash-separated path relative to root) and adds it to the
73// stack. Call when the walk descends into a directory that is being
74// kept; pair with Pop when leaving it. A directory with no .gitignore
75// still gets a frame so the push/pop bookkeeping stays balanced.
76func (s *Stack) Push(absDir, relDir string) {
77	data, err := os.ReadFile(filepath.Join(absDir, ".gitignore"))
78	var ig *Gitignore
79	if err != nil {
80		ig = &Gitignore{}
81	} else {
82		ig = Parse(string(data))
83	}
84	s.frames = append(s.frames, stackFrame{relDir: filepath.ToSlash(relDir), ig: ig})
85}
86
87// Pop removes the most recently pushed frame. The seeded root frame is
88// never popped.
89func (s *Stack) Pop() {
90	if len(s.frames) > 1 {
91		s.frames = s.frames[:len(s.frames)-1]
92	}
93}
94
95// Match reports whether rel (slash-separated, relative to the walk
96// root) should be ignored, consulting every .gitignore from the root
97// down to the current directory. Each frame matches against rel made
98// relative to the directory that owns it; a deeper frame's later rules
99// win, mirroring git's nearest-file-wins ordering.
100func (s *Stack) Match(rel string, isDir bool) bool {
101	ignored := false
102	for _, f := range s.frames {
103		sub := rel
104		if f.relDir != "" {
105			prefix := f.relDir + "/"
106			if !strings.HasPrefix(rel, prefix) {
107				continue
108			}
109			sub = rel[len(prefix):]
110		}
111		if matched, neg := f.ig.matchResult(sub, isDir); matched {
112			ignored = !neg
113		}
114	}
115	return ignored
116}
117
118// Parse builds a matcher from raw .gitignore file contents.
119func Parse(data string) *Gitignore {
120	g := &Gitignore{}
121	for _, line := range strings.Split(data, "\n") {
122		line = strings.TrimRight(line, "\r")
123		trimmed := strings.TrimSpace(line)
124		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
125			continue
126		}
127		r := rule{pattern: trimmed}
128		if strings.HasPrefix(r.pattern, "!") {
129			r.negate = true
130			r.pattern = r.pattern[1:]
131		}
132		if strings.HasSuffix(r.pattern, "/") {
133			r.dirOnly = true
134			r.pattern = strings.TrimSuffix(r.pattern, "/")
135		}
136		if strings.HasPrefix(r.pattern, "/") {
137			r.anchored = true
138			r.pattern = strings.TrimPrefix(r.pattern, "/")
139		}
140		if r.pattern == "" {
141			continue
142		}
143		g.rules = append(g.rules, r)
144	}
145	return g
146}
147
148// Match reports whether the slash-separated relative path should be
149// ignored. Later rules win, so a trailing negation can re-include a
150// previously ignored path.
151func (g *Gitignore) Match(rel string, isDir bool) bool {
152	matched, negate := g.matchResult(rel, isDir)
153	return matched && !negate
154}
155
156// matchResult reports whether any rule matched rel and, if so, whether
157// the winning (last) matching rule was a negation. This lets a Stack
158// combine matchers across nested .gitignore files while still honoring
159// negations correctly: a nested "!keep.me" must be able to re-include a
160// path a parent .gitignore excluded.
161func (g *Gitignore) matchResult(rel string, isDir bool) (matched, negate bool) {
162	for _, r := range g.rules {
163		if r.dirOnly && !isDir {
164			continue
165		}
166		if r.matchPath(rel) {
167			matched = true
168			negate = r.negate
169		}
170	}
171	return matched, negate
172}
173
174func (r rule) matchPath(rel string) bool {
175	if r.anchored || strings.Contains(r.pattern, "/") {
176		if ok, _ := filepath.Match(r.pattern, rel); ok {
177			return true
178		}
179		// Anchored directory pattern also matches everything beneath it.
180		return strings.HasPrefix(rel, r.pattern+"/")
181	}
182	// Unanchored: match the basename of any path component.
183	base := rel
184	if i := strings.LastIndex(rel, "/"); i >= 0 {
185		base = rel[i+1:]
186	}
187	if ok, _ := filepath.Match(r.pattern, base); ok {
188		return true
189	}
190	// Match a directory component anywhere in the path.
191	for _, part := range strings.Split(rel, "/") {
192		if ok, _ := filepath.Match(r.pattern, part); ok {
193			return true
194		}
195	}
196	return false
197}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close