2
0

ignore.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. # Copyright (C) 2017 Jelmer Vernooij <jelmer@jelmer.uk>
  2. #
  3. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  4. # General Public License as public by the Free Software Foundation; version 2.0
  5. # or (at your option) any later version. You can redistribute it and/or
  6. # modify it under the terms of either of these two licenses.
  7. #
  8. # Unless required by applicable law or agreed to in writing, software
  9. # distributed under the License is distributed on an "AS IS" BASIS,
  10. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  11. # See the License for the specific language governing permissions and
  12. # limitations under the License.
  13. #
  14. # You should have received a copy of the licenses; if not, see
  15. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  16. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  17. # License, Version 2.0.
  18. #
  19. """Parsing of gitignore files.
  20. For details for the matching rules, see https://git-scm.com/docs/gitignore
  21. """
  22. import os.path
  23. import re
  24. from contextlib import suppress
  25. from typing import TYPE_CHECKING, BinaryIO, Dict, Iterable, List, Optional, Union
  26. if TYPE_CHECKING:
  27. from .repo import Repo
  28. from .config import Config, get_xdg_config_home_path
  29. def _translate_segment(segment: bytes) -> bytes:
  30. if segment == b"*":
  31. return b"[^/]+"
  32. res = b""
  33. i, n = 0, len(segment)
  34. while i < n:
  35. c = segment[i : i + 1]
  36. i = i + 1
  37. if c == b"*":
  38. res += b"[^/]*"
  39. elif c == b"?":
  40. res += b"[^/]"
  41. elif c == b"\\":
  42. res += re.escape(segment[i : i + 1])
  43. i += 1
  44. elif c == b"[":
  45. j = i
  46. if j < n and segment[j : j + 1] == b"!":
  47. j = j + 1
  48. if j < n and segment[j : j + 1] == b"]":
  49. j = j + 1
  50. while j < n and segment[j : j + 1] != b"]":
  51. j = j + 1
  52. if j >= n:
  53. res += b"\\["
  54. else:
  55. stuff = segment[i:j].replace(b"\\", b"\\\\")
  56. i = j + 1
  57. if stuff.startswith(b"!"):
  58. stuff = b"^" + stuff[1:]
  59. elif stuff.startswith(b"^"):
  60. stuff = b"\\" + stuff
  61. res += b"[" + stuff + b"]"
  62. else:
  63. res += re.escape(c)
  64. return res
  65. def translate(pat: bytes) -> bytes:
  66. """Translate a shell PATTERN to a regular expression.
  67. There is no way to quote meta-characters.
  68. Originally copied from fnmatch in Python 2.7, but modified for Dulwich
  69. to cope with features in Git ignore patterns.
  70. """
  71. res = b"(?ms)"
  72. if b"/" not in pat[:-1]:
  73. # If there's no slash, this is a filename-based match
  74. res += b"(.*/)?"
  75. if pat.startswith(b"**/"):
  76. # Leading **/
  77. pat = pat[2:]
  78. res += b"(.*/)?"
  79. if pat.startswith(b"/"):
  80. pat = pat[1:]
  81. for i, segment in enumerate(pat.split(b"/")):
  82. if segment == b"**":
  83. res += b"(/.*)?"
  84. continue
  85. else:
  86. res += (re.escape(b"/") if i > 0 else b"") + _translate_segment(segment)
  87. if not pat.endswith(b"/"):
  88. res += b"/?"
  89. return res + b"\\Z"
  90. def read_ignore_patterns(f: BinaryIO) -> Iterable[bytes]:
  91. """Read a git ignore file.
  92. Args:
  93. f: File-like object to read from
  94. Returns: List of patterns
  95. """
  96. for line in f:
  97. line = line.rstrip(b"\r\n")
  98. # Ignore blank lines, they're used for readability.
  99. if not line.strip():
  100. continue
  101. if line.startswith(b"#"):
  102. # Comment
  103. continue
  104. # Trailing spaces are ignored unless they are quoted with a backslash.
  105. while line.endswith(b" ") and not line.endswith(b"\\ "):
  106. line = line[:-1]
  107. line = line.replace(b"\\ ", b" ")
  108. yield line
  109. def match_pattern(path: bytes, pattern: bytes, ignorecase: bool = False) -> bool:
  110. """Match a gitignore-style pattern against a path.
  111. Args:
  112. path: Path to match
  113. pattern: Pattern to match
  114. ignorecase: Whether to do case-sensitive matching
  115. Returns:
  116. bool indicating whether the pattern matched
  117. """
  118. return Pattern(pattern, ignorecase).match(path)
  119. class Pattern:
  120. """A single ignore pattern."""
  121. def __init__(self, pattern: bytes, ignorecase: bool = False) -> None:
  122. self.pattern = pattern
  123. self.ignorecase = ignorecase
  124. if pattern[0:1] == b"!":
  125. self.is_exclude = False
  126. pattern = pattern[1:]
  127. else:
  128. if pattern[0:1] == b"\\":
  129. pattern = pattern[1:]
  130. self.is_exclude = True
  131. flags = 0
  132. if self.ignorecase:
  133. flags = re.IGNORECASE
  134. self._re = re.compile(translate(pattern), flags)
  135. def __bytes__(self) -> bytes:
  136. return self.pattern
  137. def __str__(self) -> str:
  138. return os.fsdecode(self.pattern)
  139. def __eq__(self, other: object) -> bool:
  140. return (
  141. isinstance(other, type(self))
  142. and self.pattern == other.pattern
  143. and self.ignorecase == other.ignorecase
  144. )
  145. def __repr__(self) -> str:
  146. return "{}({!r}, {!r})".format(
  147. type(self).__name__,
  148. self.pattern,
  149. self.ignorecase,
  150. )
  151. def match(self, path: bytes) -> bool:
  152. """Try to match a path against this ignore pattern.
  153. Args:
  154. path: Path to match (relative to ignore location)
  155. Returns: boolean
  156. """
  157. return bool(self._re.match(path))
  158. class IgnoreFilter:
  159. def __init__(self, patterns: Iterable[bytes], ignorecase: bool = False, path=None) -> None:
  160. self._patterns: List[Pattern] = []
  161. self._ignorecase = ignorecase
  162. self._path = path
  163. for pattern in patterns:
  164. self.append_pattern(pattern)
  165. def append_pattern(self, pattern: bytes) -> None:
  166. """Add a pattern to the set."""
  167. self._patterns.append(Pattern(pattern, self._ignorecase))
  168. def find_matching(self, path: Union[bytes, str]) -> Iterable[Pattern]:
  169. """Yield all matching patterns for path.
  170. Args:
  171. path: Path to match
  172. Returns:
  173. Iterator over iterators
  174. """
  175. if not isinstance(path, bytes):
  176. path = os.fsencode(path)
  177. for pattern in self._patterns:
  178. if pattern.match(path):
  179. yield pattern
  180. def is_ignored(self, path: bytes) -> Optional[bool]:
  181. """Check whether a path is ignored.
  182. For directories, include a trailing slash.
  183. Returns: status is None if file is not mentioned, True if it is
  184. included, False if it is explicitly excluded.
  185. """
  186. status = None
  187. for pattern in self.find_matching(path):
  188. status = pattern.is_exclude
  189. return status
  190. @classmethod
  191. def from_path(cls, path, ignorecase: bool = False) -> "IgnoreFilter":
  192. with open(path, "rb") as f:
  193. return cls(read_ignore_patterns(f), ignorecase, path=path)
  194. def __repr__(self) -> str:
  195. path = getattr(self, "_path", None)
  196. if path is not None:
  197. return "{}.from_path({!r})".format(type(self).__name__, path)
  198. else:
  199. return "<%s>" % (type(self).__name__)
  200. class IgnoreFilterStack:
  201. """Check for ignore status in multiple filters."""
  202. def __init__(self, filters) -> None:
  203. self._filters = filters
  204. def is_ignored(self, path: str) -> Optional[bool]:
  205. """Check whether a path is explicitly included or excluded in ignores.
  206. Args:
  207. path: Path to check
  208. Returns:
  209. None if the file is not mentioned, True if it is included,
  210. False if it is explicitly excluded.
  211. """
  212. status = None
  213. for filter in self._filters:
  214. status = filter.is_ignored(path)
  215. if status is not None:
  216. return status
  217. return status
  218. def default_user_ignore_filter_path(config: Config) -> str:
  219. """Return default user ignore filter path.
  220. Args:
  221. config: A Config object
  222. Returns:
  223. Path to a global ignore file
  224. """
  225. try:
  226. value = config.get((b"core",), b"excludesFile")
  227. assert isinstance(value, bytes)
  228. return value.decode(encoding="utf-8")
  229. except KeyError:
  230. pass
  231. return get_xdg_config_home_path("git", "ignore")
  232. class IgnoreFilterManager:
  233. """Ignore file manager."""
  234. def __init__(
  235. self,
  236. top_path: str,
  237. global_filters: List[IgnoreFilter],
  238. ignorecase: bool,
  239. ) -> None:
  240. self._path_filters: Dict[str, Optional[IgnoreFilter]] = {}
  241. self._top_path = top_path
  242. self._global_filters = global_filters
  243. self._ignorecase = ignorecase
  244. def __repr__(self) -> str:
  245. return "{}({}, {!r}, {!r})".format(
  246. type(self).__name__,
  247. self._top_path,
  248. self._global_filters,
  249. self._ignorecase,
  250. )
  251. def _load_path(self, path: str) -> Optional[IgnoreFilter]:
  252. try:
  253. return self._path_filters[path]
  254. except KeyError:
  255. pass
  256. p = os.path.join(self._top_path, path, ".gitignore")
  257. try:
  258. self._path_filters[path] = IgnoreFilter.from_path(p, self._ignorecase)
  259. except OSError:
  260. self._path_filters[path] = None
  261. return self._path_filters[path]
  262. def find_matching(self, path: str) -> Iterable[Pattern]:
  263. """Find matching patterns for path.
  264. Args:
  265. path: Path to check
  266. Returns:
  267. Iterator over Pattern instances
  268. """
  269. if os.path.isabs(path):
  270. raise ValueError("%s is an absolute path" % path)
  271. filters = [(0, f) for f in self._global_filters]
  272. if os.path.sep != "/":
  273. path = path.replace(os.path.sep, "/")
  274. parts = path.split("/")
  275. matches = []
  276. for i in range(len(parts) + 1):
  277. dirname = "/".join(parts[:i])
  278. for s, f in filters:
  279. relpath = "/".join(parts[s:i])
  280. if i < len(parts):
  281. # Paths leading up to the final part are all directories,
  282. # so need a trailing slash.
  283. relpath += "/"
  284. matches += list(f.find_matching(relpath))
  285. ignore_filter = self._load_path(dirname)
  286. if ignore_filter is not None:
  287. filters.insert(0, (i, ignore_filter))
  288. return iter(matches)
  289. def is_ignored(self, path: str) -> Optional[bool]:
  290. """Check whether a path is explicitly included or excluded in ignores.
  291. Args:
  292. path: Path to check
  293. Returns:
  294. None if the file is not mentioned, True if it is included,
  295. False if it is explicitly excluded.
  296. """
  297. matches = list(self.find_matching(path))
  298. if matches:
  299. return matches[-1].is_exclude
  300. return None
  301. @classmethod
  302. def from_repo(cls, repo: "Repo") -> "IgnoreFilterManager":
  303. """Create a IgnoreFilterManager from a repository.
  304. Args:
  305. repo: Repository object
  306. Returns:
  307. A `IgnoreFilterManager` object
  308. """
  309. global_filters = []
  310. for p in [
  311. os.path.join(repo.controldir(), "info", "exclude"),
  312. default_user_ignore_filter_path(repo.get_config_stack()),
  313. ]:
  314. with suppress(OSError):
  315. global_filters.append(IgnoreFilter.from_path(os.path.expanduser(p)))
  316. config = repo.get_config_stack()
  317. ignorecase = config.get_boolean((b"core"), (b"ignorecase"), False)
  318. return cls(repo.path, global_filters, ignorecase)