1- """gitignore parsing.
2-
3- Each directory may have its own .gitignore. Patterns:
4- - lines starting with # are comments
5- - leading ! negates
6- - trailing / matches directories only
7- - leading / anchors to the .gitignore's directory
8- - ** matches any number of path components
9- """
1+ """gitignore parsing."""
102from __future__ import annotations
113
12- import fnmatch
134import os
145import re
156from pathlib import Path
16- from typing import Optional
177
188
199class IgnoreRule :
20- __slots__ = ("pattern" , "negate" , "dir_only" , "anchored " , "base" , "regex" )
10+ __slots__ = ("pattern" , "negate" , "dir_only" , "pathname " , "base" , "regex" )
2111
2212 def __init__ (self , raw : str , base : str ):
2313 self .negate = False
2414 self .dir_only = False
25- self .anchored = False
26- self .base = base
15+ self .pathname = False
16+ self .base = base . strip ( "/" )
2717 s = raw
2818 if s .startswith ("!" ):
2919 self .negate = True
3020 s = s [1 :]
21+ elif s .startswith ("\\ !" ) or s .startswith ("\\ #" ):
22+ s = s [1 :]
3123 if s .endswith ("/" ):
3224 self .dir_only = True
3325 s = s [:- 1 ]
3426 if s .startswith ("/" ):
35- self .anchored = True
27+ self .pathname = True
3628 s = s [1 :]
29+ if "/" in s :
30+ self .pathname = True
3731 self .pattern = s
38- # Translate glob to regex anchored to base
39- regex = ""
40- i = 0
41- while i < len (s ):
42- c = s [i ]
43- if c == "*" and i + 1 < len (s ) and s [i + 1 ] == "*" :
44- # **
45- regex += ".*"
46- i += 2
47- if i < len (s ) and s [i ] == "/" :
48- i += 1
49- elif c == "*" :
50- regex += "[^/]*"
51- i += 1
52- elif c == "?" :
53- regex += "[^/]"
54- i += 1
55- elif c in ".(){}+|^$\\ " :
56- regex += "\\ " + c
57- i += 1
58- elif c == "[" :
59- end = s .find ("]" , i )
60- if end == - 1 :
61- regex += "\\ ["
62- i += 1
63- else :
64- regex += s [i : end + 1 ]
65- i = end + 1
66- else :
67- regex += c
68- i += 1
69- self .regex = re .compile ("^" + regex + "$" )
32+ self .regex = re .compile ("^" + _wildmatch_to_regex (s ) + "$" )
7033
7134 def match (self , rel : str , is_dir : bool ) -> bool :
72- if self .dir_only and not is_dir :
35+ sub = self ._relative_path (rel )
36+ if not sub :
7337 return False
74- target = rel
75- if self .anchored :
76- if self .base and not target .startswith (self .base + "/" ) and target != self .base :
77- return False
78- sub = target [len (self .base ) + 1 :] if self .base else target
79- return self .regex .match (sub ) is not None
80- # match against any suffix
81- if self .regex .match (target ):
38+ if self ._match_path (sub , is_dir ):
8239 return True
83- for i , ch in enumerate (target ):
84- if ch == "/" and self .regex .match (target [i + 1 :]):
40+
41+ # A pattern that matches a directory also applies to everything below
42+ # that directory. This is what makes "build/" ignore "build/out.o".
43+ parts = sub .split ("/" )
44+ for i in range (1 , len (parts )):
45+ parent = "/" .join (parts [:i ])
46+ if self ._match_path (parent , True ):
8547 return True
86- # also match against final component
87- last = target .rsplit ("/" , 1 )[- 1 ]
88- return self .regex .match (last ) is not None
48+ return False
49+
50+ def _relative_path (self , rel : str ) -> str | None :
51+ if self .base :
52+ if rel == self .base :
53+ return None
54+ prefix = self .base + "/"
55+ if not rel .startswith (prefix ):
56+ return None
57+ return rel [len (prefix ) :]
58+ return rel
59+
60+ def _match_path (self , sub : str , is_dir : bool ) -> bool :
61+ if self .dir_only and not is_dir :
62+ return False
63+ if self .pathname :
64+ return self .regex .match (sub ) is not None
65+ if "/" in sub :
66+ sub = sub .rsplit ("/" , 1 )[- 1 ]
67+ return self .regex .match (sub ) is not None
8968
9069
9170class IgnoreSet :
9271 def __init__ (self ) -> None :
9372 self .rules : list [IgnoreRule ] = []
9473
9574 def add_file (self , gitignore_path : Path , base_rel : str ) -> None :
96- if not gitignore_path .exists ():
75+ if not gitignore_path .exists () or gitignore_path . is_symlink () :
9776 return
9877 for line in gitignore_path .read_text (encoding = "utf-8" , errors = "replace" ).splitlines ():
99- line = line . rstrip ( )
78+ line = _trim_trailing_spaces ( line )
10079 if not line or line .startswith ("#" ):
10180 continue
10281 self .rules .append (IgnoreRule (line , base_rel ))
10382
10483 def is_ignored (self , rel_path : str , is_dir : bool = False ) -> bool :
84+ rel_path , is_dir = _normalize_path (rel_path , is_dir )
85+ if not rel_path :
86+ return False
87+ parts = rel_path .split ("/" )
88+ for i in range (1 , len (parts )):
89+ if self ._last_match ("/" .join (parts [:i ]), True ):
90+ return True
91+ return self ._last_match (rel_path , is_dir )
92+
93+ def _last_match (self , rel_path : str , is_dir : bool ) -> bool :
10594 ignored = False
10695 for r in self .rules :
10796 if r .match (rel_path , is_dir ):
@@ -111,10 +100,9 @@ def is_ignored(self, rel_path: str, is_dir: bool = False) -> bool:
111100
112101def load (repo_path : Path ) -> IgnoreSet :
113102 s = IgnoreSet ()
114- # Root .gitignore + .git/info/exclude
115- s .add_file (repo_path / ".gitignore" , "" )
103+ # Lower-precedence excludes are loaded first; later matches override them.
116104 s .add_file (repo_path / ".git" / "info" / "exclude" , "" )
117- # Walk for nested .gitignore files
105+ s . add_file ( repo_path / " .gitignore" , "" )
118106 for root , dirs , files in os .walk (repo_path ):
119107 dirs [:] = [d for d in dirs if d != ".git" ]
120108 if ".gitignore" in files :
@@ -123,3 +111,103 @@ def load(repo_path: Path) -> IgnoreSet:
123111 continue
124112 s .add_file (Path (root ) / ".gitignore" , rel_root )
125113 return s
114+
115+
116+ def _normalize_path (path : str , is_dir : bool ) -> tuple [str , bool ]:
117+ path = path .replace (os .sep , "/" )
118+ if path .endswith ("/" ):
119+ is_dir = True
120+ path = path .rstrip ("/" )
121+ parts = [p for p in path .split ("/" ) if p and p != "." ]
122+ return "/" .join (parts ), is_dir
123+
124+
125+ def _trim_trailing_spaces (line : str ) -> str :
126+ last_space : int | None = None
127+ i = 0
128+ while i < len (line ):
129+ c = line [i ]
130+ if c == " " :
131+ if last_space is None :
132+ last_space = i
133+ elif c == "\\ " :
134+ i += 1
135+ if i >= len (line ):
136+ return line
137+ last_space = None
138+ else :
139+ last_space = None
140+ i += 1
141+ if last_space is not None :
142+ return line [:last_space ]
143+ return line
144+
145+
146+ def _wildmatch_to_regex (pattern : str ) -> str :
147+ out : list [str ] = []
148+ i = 0
149+ while i < len (pattern ):
150+ c = pattern [i ]
151+ if c == "*" :
152+ start = i
153+ while i < len (pattern ) and pattern [i ] == "*" :
154+ i += 1
155+ count = i - start
156+ prev_is_sep = start == 0 or pattern [start - 1 ] == "/"
157+ next_is_sep = i == len (pattern ) or pattern [i ] == "/"
158+ if count >= 2 and prev_is_sep and next_is_sep :
159+ if i < len (pattern ) and pattern [i ] == "/" :
160+ out .append ("(?:.*/)?" )
161+ i += 1
162+ else :
163+ out .append (".*" )
164+ else :
165+ out .append ("[^/]*" )
166+ continue
167+ if c == "?" :
168+ out .append ("[^/]" )
169+ elif c == "[" :
170+ token , end = _translate_char_class (pattern , i )
171+ out .append (token )
172+ i = end
173+ continue
174+ elif c == "\\ " :
175+ i += 1
176+ if i >= len (pattern ):
177+ return r"(?!)"
178+ out .append (re .escape (pattern [i ]))
179+ else :
180+ out .append (re .escape (c ))
181+ i += 1
182+ return "" .join (out )
183+
184+
185+ def _translate_char_class (pattern : str , start : int ) -> tuple [str , int ]:
186+ i = start + 1
187+ if i >= len (pattern ):
188+ return r"\[" , start + 1
189+
190+ negate = False
191+ if pattern [i ] in ("!" , "^" ):
192+ negate = True
193+ i += 1
194+ if i < len (pattern ) and pattern [i ] == "]" :
195+ i += 1
196+
197+ body = ""
198+ while i < len (pattern ) and pattern [i ] != "]" :
199+ if pattern [i ] == "\\ " and i + 1 < len (pattern ):
200+ i += 1
201+ body += re .escape (pattern [i ])
202+ else :
203+ body += pattern [i ]
204+ i += 1
205+ if i >= len (pattern ):
206+ return r"\[" , start + 1
207+
208+ body = body .replace ("\\ " , r"\\" )
209+ if negate :
210+ token = f"(?:(?!/)[^{ body } ])"
211+ else :
212+ token = f"(?:(?!/)[{ body } ])"
213+ return token , i + 1
0 commit comments