test_ignore.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. # test_ignore.py -- Tests for ignore files.
  2. # Copyright (C) 2017 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Tests for ignore files."""
  22. import os
  23. import re
  24. import shutil
  25. import tempfile
  26. from io import BytesIO
  27. from dulwich.ignore import (
  28. IgnoreFilter,
  29. IgnoreFilterManager,
  30. IgnoreFilterStack,
  31. Pattern,
  32. match_pattern,
  33. read_ignore_patterns,
  34. translate,
  35. )
  36. from dulwich.repo import Repo
  37. from . import TestCase
  38. POSITIVE_MATCH_TESTS = [
  39. (b"foo.c", b"*.c"),
  40. (b".c", b"*.c"),
  41. (b"foo/foo.c", b"*.c"),
  42. (b"foo/foo.c", b"foo.c"),
  43. (b"foo.c", b"/*.c"),
  44. (b"foo.c", b"/foo.c"),
  45. (b"foo.c", b"foo.c"),
  46. (b"foo.c", b"foo.[ch]"),
  47. (b"foo/bar/bla.c", b"foo/**"),
  48. (b"foo/bar/bla/blie.c", b"foo/**/blie.c"),
  49. (b"foo/bar/bla.c", b"**/bla.c"),
  50. (b"bla.c", b"**/bla.c"),
  51. (b"foo/bar", b"foo/**/bar"),
  52. (b"foo/bla/bar", b"foo/**/bar"),
  53. (b"foo/bar/", b"bar/"),
  54. (b"foo/bar/", b"bar"),
  55. (b"foo/bar/something", b"foo/bar/*"),
  56. ]
  57. NEGATIVE_MATCH_TESTS = [
  58. (b"foo.c", b"foo.[dh]"),
  59. (b"foo/foo.c", b"/foo.c"),
  60. (b"foo/foo.c", b"/*.c"),
  61. (b"foo/bar/", b"/bar/"),
  62. (b"foo/bar/", b"foo/bar/*"),
  63. (b"foo/bar", b"foo?bar"),
  64. ]
  65. TRANSLATE_TESTS = [
  66. (b"*.c", b"(?ms)(.*/)?[^/]*\\.c/?\\Z"),
  67. (b"foo.c", b"(?ms)(.*/)?foo\\.c/?\\Z"),
  68. (b"/*.c", b"(?ms)[^/]*\\.c/?\\Z"),
  69. (b"/foo.c", b"(?ms)foo\\.c/?\\Z"),
  70. (b"foo.c", b"(?ms)(.*/)?foo\\.c/?\\Z"),
  71. (b"foo.[ch]", b"(?ms)(.*/)?foo\\.[ch]/?\\Z"),
  72. (b"bar/", b"(?ms)(.*/)?bar\\/\\Z"),
  73. (b"foo/**", b"(?ms)foo/.*/?\\Z"),
  74. (b"foo/**/blie.c", b"(?ms)foo/(?:[^/]+/)*blie\\.c/?\\Z"),
  75. (b"**/bla.c", b"(?ms)(.*/)?bla\\.c/?\\Z"),
  76. (b"foo/**/bar", b"(?ms)foo/(?:[^/]+/)*bar/?\\Z"),
  77. (b"foo/bar/*", b"(?ms)foo\\/bar\\/[^/]+/?\\Z"),
  78. (b"/foo\\[bar\\]", b"(?ms)foo\\[bar\\]/?\\Z"),
  79. (b"/foo[bar]", b"(?ms)foo[bar]/?\\Z"),
  80. (b"/foo[0-9]", b"(?ms)foo[0-9]/?\\Z"),
  81. ]
  82. class TranslateTests(TestCase):
  83. def test_translate(self) -> None:
  84. for pattern, regex in TRANSLATE_TESTS:
  85. if re.escape(b"/") == b"/":
  86. # Slash is no longer escaped in Python3.7, so undo the escaping
  87. # in the expected return value..
  88. regex = regex.replace(b"\\/", b"/")
  89. self.assertEqual(
  90. regex,
  91. translate(pattern),
  92. f"orig pattern: {pattern!r}, regex: {translate(pattern)!r}, expected: {regex!r}",
  93. )
  94. class ReadIgnorePatterns(TestCase):
  95. def test_read_file(self) -> None:
  96. f = BytesIO(
  97. b"""
  98. # a comment
  99. \x20\x20
  100. # and an empty line:
  101. \\#not a comment
  102. !negative
  103. with trailing whitespace
  104. with escaped trailing whitespace\\
  105. """
  106. )
  107. self.assertEqual(
  108. list(read_ignore_patterns(f)),
  109. [
  110. b"\\#not a comment",
  111. b"!negative",
  112. b"with trailing whitespace",
  113. b"with escaped trailing whitespace ",
  114. ],
  115. )
  116. class MatchPatternTests(TestCase):
  117. def test_matches(self) -> None:
  118. for path, pattern in POSITIVE_MATCH_TESTS:
  119. self.assertTrue(
  120. match_pattern(path, pattern),
  121. f"path: {path!r}, pattern: {pattern!r}",
  122. )
  123. def test_no_matches(self) -> None:
  124. for path, pattern in NEGATIVE_MATCH_TESTS:
  125. self.assertFalse(
  126. match_pattern(path, pattern),
  127. f"path: {path!r}, pattern: {pattern!r}",
  128. )
  129. class ParentExclusionTests(TestCase):
  130. """Tests for parent directory exclusion helper functions."""
  131. def test_check_parent_exclusion_direct_directory(self) -> None:
  132. """Test _check_parent_exclusion with direct directory exclusion."""
  133. from dulwich.ignore import Pattern, _check_parent_exclusion
  134. # Pattern: dir/, !dir/file.txt
  135. patterns = [Pattern(b"dir/"), Pattern(b"!dir/file.txt")]
  136. # dir/file.txt has parent 'dir' excluded
  137. self.assertTrue(_check_parent_exclusion("dir/file.txt", patterns))
  138. # dir/subdir/file.txt also has parent 'dir' excluded
  139. self.assertTrue(_check_parent_exclusion("dir/subdir/file.txt", patterns))
  140. # other/file.txt has no parent excluded
  141. self.assertFalse(_check_parent_exclusion("other/file.txt", patterns))
  142. def test_check_parent_exclusion_no_negation(self) -> None:
  143. """Test _check_parent_exclusion when there's no negation pattern."""
  144. from dulwich.ignore import Pattern, _check_parent_exclusion
  145. # Only exclusion patterns
  146. patterns = [Pattern(b"*.log"), Pattern(b"build/")]
  147. # No negation pattern, so no parent exclusion check needed
  148. self.assertFalse(_check_parent_exclusion("build/file.txt", patterns))
  149. def test_pattern_excludes_parent_directory_slash(self) -> None:
  150. """Test _pattern_excludes_parent for patterns ending with /."""
  151. from dulwich.ignore import _pattern_excludes_parent
  152. # Pattern: parent/
  153. self.assertTrue(
  154. _pattern_excludes_parent("parent/", "parent/file.txt", "!parent/file.txt")
  155. )
  156. self.assertTrue(
  157. _pattern_excludes_parent(
  158. "parent/", "parent/sub/file.txt", "!parent/sub/file.txt"
  159. )
  160. )
  161. self.assertFalse(
  162. _pattern_excludes_parent("parent/", "other/file.txt", "!other/file.txt")
  163. )
  164. self.assertFalse(
  165. _pattern_excludes_parent("parent/", "parent", "!parent")
  166. ) # No / in path
  167. def test_pattern_excludes_parent_double_asterisk(self) -> None:
  168. """Test _pattern_excludes_parent for **/ patterns."""
  169. from dulwich.ignore import _pattern_excludes_parent
  170. # Pattern: **/node_modules/**
  171. self.assertTrue(
  172. _pattern_excludes_parent(
  173. "**/node_modules/**",
  174. "foo/node_modules/bar/file.txt",
  175. "!foo/node_modules/bar/file.txt",
  176. )
  177. )
  178. self.assertTrue(
  179. _pattern_excludes_parent(
  180. "**/node_modules/**", "node_modules/file.txt", "!node_modules/file.txt"
  181. )
  182. )
  183. self.assertFalse(
  184. _pattern_excludes_parent(
  185. "**/node_modules/**", "foo/bar/file.txt", "!foo/bar/file.txt"
  186. )
  187. )
  188. def test_pattern_excludes_parent_glob(self) -> None:
  189. """Test _pattern_excludes_parent for dir/** patterns."""
  190. from dulwich.ignore import _pattern_excludes_parent
  191. # Pattern: logs/** - allows exact file negations for immediate children
  192. self.assertFalse(
  193. _pattern_excludes_parent("logs/**", "logs/file.txt", "!logs/file.txt")
  194. )
  195. # Directory negations still have parent exclusion
  196. self.assertTrue(
  197. _pattern_excludes_parent("logs/**", "logs/keep/", "!logs/keep/")
  198. )
  199. # Non-exact negations have parent exclusion
  200. self.assertTrue(
  201. _pattern_excludes_parent("logs/**", "logs/keep/", "!logs/keep/file.txt")
  202. )
  203. # Nested paths have parent exclusion
  204. self.assertTrue(
  205. _pattern_excludes_parent("logs/**", "logs/sub/file.txt", "!logs/sub/")
  206. )
  207. self.assertTrue(
  208. _pattern_excludes_parent(
  209. "logs/**", "logs/sub/file.txt", "!logs/sub/file.txt"
  210. )
  211. )
  212. class IgnoreFilterTests(TestCase):
  213. def test_included(self) -> None:
  214. filter = IgnoreFilter([b"a.c", b"b.c"])
  215. self.assertTrue(filter.is_ignored(b"a.c"))
  216. self.assertIs(None, filter.is_ignored(b"c.c"))
  217. self.assertEqual([Pattern(b"a.c")], list(filter.find_matching(b"a.c")))
  218. self.assertEqual([], list(filter.find_matching(b"c.c")))
  219. def test_included_ignorecase(self) -> None:
  220. filter = IgnoreFilter([b"a.c", b"b.c"], ignorecase=False)
  221. self.assertTrue(filter.is_ignored(b"a.c"))
  222. self.assertFalse(filter.is_ignored(b"A.c"))
  223. filter = IgnoreFilter([b"a.c", b"b.c"], ignorecase=True)
  224. self.assertTrue(filter.is_ignored(b"a.c"))
  225. self.assertTrue(filter.is_ignored(b"A.c"))
  226. self.assertTrue(filter.is_ignored(b"A.C"))
  227. def test_excluded(self) -> None:
  228. filter = IgnoreFilter([b"a.c", b"b.c", b"!c.c"])
  229. self.assertFalse(filter.is_ignored(b"c.c"))
  230. self.assertIs(None, filter.is_ignored(b"d.c"))
  231. self.assertEqual([Pattern(b"!c.c")], list(filter.find_matching(b"c.c")))
  232. self.assertEqual([], list(filter.find_matching(b"d.c")))
  233. def test_include_exclude_include(self) -> None:
  234. filter = IgnoreFilter([b"a.c", b"!a.c", b"a.c"])
  235. self.assertTrue(filter.is_ignored(b"a.c"))
  236. self.assertEqual(
  237. [Pattern(b"a.c"), Pattern(b"!a.c"), Pattern(b"a.c")],
  238. list(filter.find_matching(b"a.c")),
  239. )
  240. def test_manpage(self) -> None:
  241. # A specific example from the gitignore manpage
  242. filter = IgnoreFilter([b"/*", b"!/foo", b"/foo/*", b"!/foo/bar"])
  243. self.assertTrue(filter.is_ignored(b"a.c"))
  244. self.assertTrue(filter.is_ignored(b"foo/blie"))
  245. self.assertFalse(filter.is_ignored(b"foo"))
  246. self.assertFalse(filter.is_ignored(b"foo/bar"))
  247. self.assertFalse(filter.is_ignored(b"foo/bar/"))
  248. self.assertFalse(filter.is_ignored(b"foo/bar/bloe"))
  249. def test_regex_special(self) -> None:
  250. # See https://github.com/dulwich/dulwich/issues/930#issuecomment-1026166429
  251. filter = IgnoreFilter([b"/foo\\[bar\\]", b"/foo"])
  252. self.assertTrue(filter.is_ignored("foo"))
  253. self.assertTrue(filter.is_ignored("foo[bar]"))
  254. def test_from_path_pathlib(self) -> None:
  255. import tempfile
  256. from pathlib import Path
  257. # Create a temporary .gitignore file
  258. with tempfile.NamedTemporaryFile(
  259. mode="w", suffix=".gitignore", delete=False
  260. ) as f:
  261. f.write("*.pyc\n__pycache__/\n")
  262. temp_path = f.name
  263. self.addCleanup(os.unlink, temp_path)
  264. # Test with pathlib.Path
  265. path_obj = Path(temp_path)
  266. ignore_filter = IgnoreFilter.from_path(path_obj)
  267. # Test that it loaded the patterns correctly
  268. self.assertTrue(ignore_filter.is_ignored("test.pyc"))
  269. self.assertTrue(ignore_filter.is_ignored("__pycache__/"))
  270. self.assertFalse(ignore_filter.is_ignored("test.py"))
  271. class IgnoreFilterStackTests(TestCase):
  272. def test_stack_first(self) -> None:
  273. filter1 = IgnoreFilter([b"[a].c", b"[b].c", b"![d].c"])
  274. filter2 = IgnoreFilter([b"[a].c", b"![b],c", b"[c].c", b"[d].c"])
  275. stack = IgnoreFilterStack([filter1, filter2])
  276. self.assertIs(True, stack.is_ignored(b"a.c"))
  277. self.assertIs(True, stack.is_ignored(b"b.c"))
  278. self.assertIs(True, stack.is_ignored(b"c.c"))
  279. self.assertIs(False, stack.is_ignored(b"d.c"))
  280. self.assertIs(None, stack.is_ignored(b"e.c"))
  281. class IgnoreFilterManagerTests(TestCase):
  282. def test_load_ignore(self) -> None:
  283. tmp_dir = tempfile.mkdtemp()
  284. self.addCleanup(shutil.rmtree, tmp_dir)
  285. repo = Repo.init(tmp_dir)
  286. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  287. f.write(b"/foo/bar\n")
  288. f.write(b"/dir2\n")
  289. f.write(b"/dir3/\n")
  290. os.mkdir(os.path.join(repo.path, "dir"))
  291. with open(os.path.join(repo.path, "dir", ".gitignore"), "wb") as f:
  292. f.write(b"/blie\n")
  293. with open(os.path.join(repo.path, "dir", "blie"), "wb") as f:
  294. f.write(b"IGNORED")
  295. p = os.path.join(repo.controldir(), "info", "exclude")
  296. with open(p, "wb") as f:
  297. f.write(b"/excluded\n")
  298. m = IgnoreFilterManager.from_repo(repo)
  299. self.assertTrue(m.is_ignored("dir/blie"))
  300. self.assertIs(None, m.is_ignored(os.path.join("dir", "bloe")))
  301. self.assertIs(None, m.is_ignored("dir"))
  302. self.assertTrue(m.is_ignored(os.path.join("foo", "bar")))
  303. self.assertTrue(m.is_ignored(os.path.join("excluded")))
  304. self.assertTrue(m.is_ignored(os.path.join("dir2", "fileinignoreddir")))
  305. self.assertFalse(m.is_ignored("dir3"))
  306. self.assertTrue(m.is_ignored("dir3/"))
  307. self.assertTrue(m.is_ignored("dir3/bla"))
  308. def test_nested_gitignores(self) -> None:
  309. tmp_dir = tempfile.mkdtemp()
  310. self.addCleanup(shutil.rmtree, tmp_dir)
  311. repo = Repo.init(tmp_dir)
  312. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  313. f.write(b"/*\n")
  314. f.write(b"!/foo\n")
  315. os.mkdir(os.path.join(repo.path, "foo"))
  316. with open(os.path.join(repo.path, "foo", ".gitignore"), "wb") as f:
  317. f.write(b"/bar\n")
  318. with open(os.path.join(repo.path, "foo", "bar"), "wb") as f:
  319. f.write(b"IGNORED")
  320. m = IgnoreFilterManager.from_repo(repo)
  321. self.assertTrue(m.is_ignored("foo/bar"))
  322. def test_load_ignore_ignorecase(self) -> None:
  323. tmp_dir = tempfile.mkdtemp()
  324. self.addCleanup(shutil.rmtree, tmp_dir)
  325. repo = Repo.init(tmp_dir)
  326. config = repo.get_config()
  327. config.set(b"core", b"ignorecase", True)
  328. config.write_to_path()
  329. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  330. f.write(b"/foo/bar\n")
  331. f.write(b"/dir\n")
  332. m = IgnoreFilterManager.from_repo(repo)
  333. self.assertTrue(m.is_ignored(os.path.join("dir", "blie")))
  334. self.assertTrue(m.is_ignored(os.path.join("DIR", "blie")))
  335. def test_ignored_contents(self) -> None:
  336. tmp_dir = tempfile.mkdtemp()
  337. self.addCleanup(shutil.rmtree, tmp_dir)
  338. repo = Repo.init(tmp_dir)
  339. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  340. f.write(b"a/*\n")
  341. f.write(b"!a/*.txt\n")
  342. m = IgnoreFilterManager.from_repo(repo)
  343. os.mkdir(os.path.join(repo.path, "a"))
  344. self.assertIs(None, m.is_ignored("a"))
  345. self.assertIs(None, m.is_ignored("a/"))
  346. self.assertFalse(m.is_ignored("a/b.txt"))
  347. self.assertTrue(m.is_ignored("a/c.dat"))
  348. def test_issue_1203_directory_negation(self) -> None:
  349. """Test for issue #1203: gitignore patterns with directory negation."""
  350. tmp_dir = tempfile.mkdtemp()
  351. self.addCleanup(shutil.rmtree, tmp_dir)
  352. repo = Repo.init(tmp_dir)
  353. # Create .gitignore with the patterns from the issue
  354. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  355. f.write(b"data/**\n")
  356. f.write(b"!data/*/\n")
  357. # Create directory structure
  358. os.makedirs(os.path.join(repo.path, "data", "subdir"))
  359. m = IgnoreFilterManager.from_repo(repo)
  360. # Test the expected behavior
  361. self.assertTrue(
  362. m.is_ignored("data/test.dvc")
  363. ) # File in data/ should be ignored
  364. self.assertFalse(m.is_ignored("data/")) # data/ directory should not be ignored
  365. self.assertTrue(
  366. m.is_ignored("data/subdir/")
  367. ) # Subdirectory should be ignored (matches Git behavior)
  368. def test_issue_1721_directory_negation_with_double_asterisk(self) -> None:
  369. """Test for issue #1721: regression with negated subdirectory patterns using **."""
  370. tmp_dir = tempfile.mkdtemp()
  371. self.addCleanup(shutil.rmtree, tmp_dir)
  372. repo = Repo.init(tmp_dir)
  373. # Create .gitignore with the patterns from issue #1721
  374. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  375. f.write(b"data/**\n")
  376. f.write(b"!data/**/\n")
  377. f.write(b"!data/**/*.csv\n")
  378. # Create directory structure
  379. os.makedirs(os.path.join(repo.path, "data", "subdir"))
  380. m = IgnoreFilterManager.from_repo(repo)
  381. # Test the expected behavior - issue #1721 was that data/myfile was not ignored
  382. self.assertTrue(
  383. m.is_ignored("data/myfile")
  384. ) # File should be ignored (fixes issue #1721)
  385. self.assertFalse(m.is_ignored("data/")) # data/ is matched by !data/**/
  386. self.assertFalse(
  387. m.is_ignored("data/subdir/")
  388. ) # Subdirectory is matched by !data/**/
  389. # With data/** pattern, Git allows CSV files to be re-included via !data/**/*.csv
  390. self.assertFalse(m.is_ignored("data/test.csv")) # CSV files are not ignored
  391. self.assertFalse(
  392. m.is_ignored("data/subdir/test.csv")
  393. ) # CSV files in subdirs are not ignored
  394. self.assertTrue(
  395. m.is_ignored("data/subdir/other.txt")
  396. ) # Non-CSV files in subdirs are ignored
  397. def test_parent_directory_exclusion(self) -> None:
  398. """Test Git's parent directory exclusion rule.
  399. Git rule: "It is not possible to re-include a file if a parent directory of that file is excluded."
  400. """
  401. tmp_dir = tempfile.mkdtemp()
  402. self.addCleanup(shutil.rmtree, tmp_dir)
  403. repo = Repo.init(tmp_dir)
  404. # Test case 1: Direct parent directory exclusion
  405. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  406. f.write(b"parent/\n")
  407. f.write(b"!parent/file.txt\n")
  408. f.write(b"!parent/child/\n")
  409. m = IgnoreFilterManager.from_repo(repo)
  410. # parent/ is excluded, so files inside cannot be re-included
  411. self.assertTrue(m.is_ignored("parent/"))
  412. self.assertTrue(m.is_ignored("parent/file.txt")) # Cannot re-include
  413. self.assertTrue(m.is_ignored("parent/child/")) # Cannot re-include
  414. self.assertTrue(m.is_ignored("parent/child/file.txt"))
  415. def test_parent_exclusion_with_wildcards(self) -> None:
  416. """Test parent directory exclusion with wildcard patterns."""
  417. tmp_dir = tempfile.mkdtemp()
  418. self.addCleanup(shutil.rmtree, tmp_dir)
  419. repo = Repo.init(tmp_dir)
  420. # Test case 2: Parent excluded by wildcard
  421. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  422. f.write(b"*/build/\n")
  423. f.write(b"!*/build/important.txt\n")
  424. m = IgnoreFilterManager.from_repo(repo)
  425. self.assertTrue(m.is_ignored("src/build/"))
  426. self.assertTrue(m.is_ignored("src/build/important.txt")) # Cannot re-include
  427. self.assertTrue(m.is_ignored("test/build/"))
  428. self.assertTrue(m.is_ignored("test/build/important.txt")) # Cannot re-include
  429. def test_parent_exclusion_with_double_asterisk(self) -> None:
  430. """Test parent directory exclusion with ** patterns."""
  431. tmp_dir = tempfile.mkdtemp()
  432. self.addCleanup(shutil.rmtree, tmp_dir)
  433. repo = Repo.init(tmp_dir)
  434. # Test case 3: Complex ** pattern with parent exclusion
  435. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  436. f.write(b"**/node_modules/\n")
  437. f.write(b"!**/node_modules/keep.txt\n")
  438. m = IgnoreFilterManager.from_repo(repo)
  439. self.assertTrue(m.is_ignored("node_modules/"))
  440. self.assertTrue(m.is_ignored("node_modules/keep.txt")) # Cannot re-include
  441. self.assertTrue(m.is_ignored("src/node_modules/"))
  442. self.assertTrue(m.is_ignored("src/node_modules/keep.txt")) # Cannot re-include
  443. self.assertTrue(m.is_ignored("deep/nested/node_modules/"))
  444. self.assertTrue(
  445. m.is_ignored("deep/nested/node_modules/keep.txt")
  446. ) # Cannot re-include
  447. def test_no_parent_exclusion_with_glob_contents(self) -> None:
  448. """Test that dir/** allows specific file negations for immediate children."""
  449. tmp_dir = tempfile.mkdtemp()
  450. self.addCleanup(shutil.rmtree, tmp_dir)
  451. repo = Repo.init(tmp_dir)
  452. # Test: dir/** allows specific file negations (unlike dir/ which doesn't)
  453. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  454. f.write(b"logs/**\n")
  455. f.write(b"!logs/important.log\n")
  456. f.write(b"!logs/keep/\n")
  457. m = IgnoreFilterManager.from_repo(repo)
  458. # logs/ itself is excluded by logs/**
  459. self.assertTrue(m.is_ignored("logs/"))
  460. # Specific file negation works with dir/** patterns
  461. self.assertFalse(m.is_ignored("logs/important.log"))
  462. # Directory negations still don't work (parent exclusion)
  463. self.assertTrue(m.is_ignored("logs/keep/"))
  464. # Nested paths are ignored
  465. self.assertTrue(m.is_ignored("logs/subdir/"))
  466. self.assertTrue(m.is_ignored("logs/subdir/file.txt"))
  467. def test_parent_exclusion_ordering(self) -> None:
  468. """Test that parent exclusion depends on pattern ordering."""
  469. tmp_dir = tempfile.mkdtemp()
  470. self.addCleanup(shutil.rmtree, tmp_dir)
  471. repo = Repo.init(tmp_dir)
  472. # Test case 5: Order matters for parent exclusion
  473. with open(os.path.join(repo.path, ".gitignore"), "wb") as f:
  474. f.write(b"!data/important/\n") # This comes first but won't work
  475. f.write(b"data/\n") # This excludes the parent
  476. m = IgnoreFilterManager.from_repo(repo)
  477. self.assertTrue(m.is_ignored("data/"))
  478. self.assertTrue(m.is_ignored("data/important/")) # Cannot re-include
  479. self.assertTrue(m.is_ignored("data/important/file.txt"))