test_check_ignore.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113
  1. # test_check_ignore.py -- Compatibility tests for git check-ignore
  2. # Copyright (C) 2025 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 public 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. """Compatibility tests for git check-ignore functionality."""
  22. import os
  23. import tempfile
  24. from dulwich import porcelain
  25. from dulwich.repo import Repo
  26. from .utils import CompatTestCase, run_git_or_fail
  27. class CheckIgnoreCompatTestCase(CompatTestCase):
  28. """Test git check-ignore compatibility between dulwich and git."""
  29. min_git_version = (1, 8, 5) # git check-ignore was added in 1.8.5
  30. def setUp(self) -> None:
  31. super().setUp()
  32. self.test_dir = tempfile.mkdtemp()
  33. self.addCleanup(self._cleanup_test_dir)
  34. self.repo = Repo.init(self.test_dir)
  35. self.addCleanup(self.repo.close)
  36. def _cleanup_test_dir(self) -> None:
  37. import shutil
  38. shutil.rmtree(self.test_dir)
  39. def _write_gitignore(self, content: str) -> None:
  40. """Write .gitignore file with given content."""
  41. gitignore_path = os.path.join(self.test_dir, ".gitignore")
  42. with open(gitignore_path, "w") as f:
  43. f.write(content)
  44. def _create_file(self, path: str, content: str = "") -> None:
  45. """Create a file with given content."""
  46. full_path = os.path.join(self.test_dir, path)
  47. os.makedirs(os.path.dirname(full_path), exist_ok=True)
  48. with open(full_path, "w") as f:
  49. f.write(content)
  50. def _create_dir(self, path: str) -> None:
  51. """Create a directory."""
  52. full_path = os.path.join(self.test_dir, path)
  53. os.makedirs(full_path, exist_ok=True)
  54. def _git_check_ignore(self, paths: list[str]) -> set[str]:
  55. """Run git check-ignore and return set of ignored paths."""
  56. try:
  57. output = run_git_or_fail(
  58. ["-c", "core.quotePath=false", "check-ignore", *paths],
  59. cwd=self.test_dir,
  60. )
  61. # git check-ignore returns paths separated by newlines
  62. return set(
  63. line.decode("utf-8") for line in output.strip().split(b"\n") if line
  64. )
  65. except AssertionError:
  66. # git check-ignore returns non-zero when no paths are ignored
  67. return set()
  68. def _dulwich_check_ignore(self, paths: list[str]) -> set[str]:
  69. """Run dulwich check_ignore and return set of ignored paths."""
  70. # Convert to absolute paths relative to the test directory
  71. abs_paths = [os.path.join(self.test_dir, path) for path in paths]
  72. ignored = set(
  73. porcelain.check_ignore(self.test_dir, abs_paths, quote_path=False)
  74. )
  75. # Convert back to relative paths and preserve original path format
  76. result = set()
  77. path_mapping = {}
  78. for orig_path, abs_path in zip(paths, abs_paths):
  79. path_mapping[abs_path] = orig_path
  80. for path in ignored:
  81. if path.startswith(self.test_dir + "/"):
  82. rel_path = path[len(self.test_dir) + 1 :]
  83. # Find the original path format that was requested
  84. orig_path = None
  85. for requested_path in paths:
  86. if requested_path.rstrip("/") == rel_path.rstrip("/"):
  87. orig_path = requested_path
  88. break
  89. result.add(orig_path if orig_path else rel_path)
  90. else:
  91. result.add(path)
  92. return result
  93. def _assert_ignore_match(self, paths: list[str]) -> None:
  94. """Assert that dulwich and git return the same ignored paths."""
  95. git_ignored = self._git_check_ignore(paths)
  96. dulwich_ignored = self._dulwich_check_ignore(paths)
  97. self.assertEqual(
  98. git_ignored,
  99. dulwich_ignored,
  100. f"Mismatch for paths {paths}: git={git_ignored}, dulwich={dulwich_ignored}",
  101. )
  102. def test_issue_1203_directory_negation(self) -> None:
  103. """Test issue #1203: directory negation patterns with data/**,!data/*/."""
  104. self._write_gitignore("data/**\n!data/*/\n")
  105. self._create_file("data/test.dvc", "content")
  106. self._create_dir("data/subdir")
  107. # Based on dulwich's own test for issue #1203, the expected behavior is:
  108. # data/test.dvc: ignored, data/: not ignored, data/subdir/: not ignored
  109. # But git check-ignore might behave differently...
  110. # Test the core case that issue #1203 was about
  111. self._assert_ignore_match(["data/test.dvc"])
  112. def test_basic_patterns(self) -> None:
  113. """Test basic gitignore patterns."""
  114. self._write_gitignore("*.tmp\n*.log\n")
  115. self._create_file("test.tmp")
  116. self._create_file("debug.log")
  117. self._create_file("readme.txt")
  118. paths = ["test.tmp", "debug.log", "readme.txt"]
  119. self._assert_ignore_match(paths)
  120. def test_directory_patterns(self) -> None:
  121. """Test directory-specific patterns."""
  122. self._write_gitignore("build/\nnode_modules/\n")
  123. self._create_dir("build")
  124. self._create_dir("node_modules")
  125. self._create_file("build.txt")
  126. paths = ["build/", "node_modules/", "build.txt"]
  127. self._assert_ignore_match(paths)
  128. def test_wildcard_patterns(self) -> None:
  129. """Test wildcard patterns."""
  130. self._write_gitignore("*.py[cod]\n__pycache__/\n*.so\n")
  131. self._create_file("test.pyc")
  132. self._create_file("test.pyo")
  133. self._create_file("test.pyd")
  134. self._create_file("test.py")
  135. self._create_dir("__pycache__")
  136. paths = ["test.pyc", "test.pyo", "test.pyd", "test.py", "__pycache__/"]
  137. self._assert_ignore_match(paths)
  138. def test_negation_patterns(self) -> None:
  139. """Test negation patterns with !."""
  140. self._write_gitignore("*.log\n!important.log\n")
  141. self._create_file("debug.log")
  142. self._create_file("error.log")
  143. self._create_file("important.log")
  144. paths = ["debug.log", "error.log", "important.log"]
  145. self._assert_ignore_match(paths)
  146. def test_double_asterisk_patterns(self) -> None:
  147. """Test double asterisk ** patterns."""
  148. self._write_gitignore("**/temp\nvendor/**/cache\n")
  149. self._create_file("temp")
  150. self._create_file("src/temp")
  151. self._create_file("deep/nested/temp")
  152. self._create_file("vendor/lib/cache")
  153. self._create_file("vendor/gem/deep/cache")
  154. paths = [
  155. "temp",
  156. "src/temp",
  157. "deep/nested/temp",
  158. "vendor/lib/cache",
  159. "vendor/gem/deep/cache",
  160. ]
  161. self._assert_ignore_match(paths)
  162. def test_subdirectory_gitignore(self) -> None:
  163. """Test .gitignore files in subdirectories."""
  164. # Root .gitignore
  165. self._write_gitignore("*.tmp\n")
  166. # Subdirectory .gitignore
  167. self._create_dir("subdir")
  168. subdir_gitignore = os.path.join(self.test_dir, "subdir", ".gitignore")
  169. with open(subdir_gitignore, "w") as f:
  170. f.write("*.local\n!important.local\n")
  171. self._create_file("test.tmp")
  172. self._create_file("subdir/test.tmp")
  173. self._create_file("subdir/config.local")
  174. self._create_file("subdir/important.local")
  175. paths = [
  176. "test.tmp",
  177. "subdir/test.tmp",
  178. "subdir/config.local",
  179. "subdir/important.local",
  180. ]
  181. self._assert_ignore_match(paths)
  182. def test_complex_directory_negation(self) -> None:
  183. """Test complex directory negation patterns."""
  184. self._write_gitignore("dist/\n!dist/assets/\ndist/assets/*.tmp\n")
  185. self._create_dir("dist/assets")
  186. self._create_file("dist/main.js")
  187. self._create_file("dist/assets/style.css")
  188. self._create_file("dist/assets/temp.tmp")
  189. paths = [
  190. "dist/",
  191. "dist/main.js",
  192. "dist/assets/",
  193. "dist/assets/style.css",
  194. "dist/assets/temp.tmp",
  195. ]
  196. self._assert_ignore_match(paths)
  197. def test_leading_slash_patterns(self) -> None:
  198. """Test patterns with leading slash."""
  199. self._write_gitignore("/root-only.txt\nsubdir/specific.txt\n")
  200. self._create_file("root-only.txt")
  201. self._create_file("deep/root-only.txt") # Should not be ignored
  202. self._create_file("subdir/specific.txt")
  203. self._create_file("deep/subdir/specific.txt") # Should also be ignored
  204. paths = [
  205. "root-only.txt",
  206. "deep/root-only.txt",
  207. "subdir/specific.txt",
  208. "deep/subdir/specific.txt",
  209. ]
  210. self._assert_ignore_match(paths)
  211. def test_empty_directory_edge_case(self) -> None:
  212. """Test edge case with empty directories."""
  213. self._write_gitignore("empty/\n!empty/keep\n")
  214. self._create_dir("empty")
  215. self._create_file("empty/keep", "keep this")
  216. paths = ["empty/", "empty/keep"]
  217. self._assert_ignore_match(paths)
  218. def test_nested_wildcard_negation(self) -> None:
  219. """Test nested wildcard patterns with negation."""
  220. self._write_gitignore("docs/**\n!docs/*/\n!docs/**/*.md\n")
  221. self._create_file("docs/readme.txt") # Should be ignored
  222. self._create_file("docs/guide.md") # Should not be ignored
  223. self._create_dir("docs/api") # Should not be ignored
  224. self._create_file("docs/api/index.md") # Should not be ignored
  225. self._create_file("docs/api/temp.txt") # Should be ignored
  226. paths = [
  227. "docs/readme.txt",
  228. "docs/guide.md",
  229. "docs/api/",
  230. "docs/api/index.md",
  231. "docs/api/temp.txt",
  232. ]
  233. self._assert_ignore_match(paths)
  234. def test_case_sensitivity(self) -> None:
  235. """Test case sensitivity in patterns."""
  236. self._write_gitignore("*.TMP\nREADME\n")
  237. self._create_file("test.tmp") # Lowercase
  238. self._create_file("test.TMP") # Uppercase
  239. self._create_file("readme") # Lowercase
  240. self._create_file("README") # Uppercase
  241. paths = ["test.tmp", "test.TMP", "readme", "README"]
  242. self._assert_ignore_match(paths)
  243. def test_unicode_filenames(self) -> None:
  244. """Test unicode filenames in patterns."""
  245. try:
  246. self._write_gitignore("тест*\n*.测试\n")
  247. self._create_file("тест.txt")
  248. self._create_file("файл.测试")
  249. self._create_file("normal.txt")
  250. paths = ["тест.txt", "файл.测试", "normal.txt"]
  251. self._assert_ignore_match(paths)
  252. except (UnicodeEncodeError, OSError):
  253. # Skip test if filesystem doesn't support unicode
  254. self.skipTest("Filesystem doesn't support unicode filenames")
  255. def test_double_asterisk_edge_cases(self) -> None:
  256. """Test edge cases with ** patterns."""
  257. self._write_gitignore("**/afile\ndir1/**/b\n**/*.tmp\n")
  258. # Test **/afile pattern
  259. self._create_file("afile") # Root level
  260. self._create_file("dir/afile") # One level deep
  261. self._create_file("deep/nested/afile") # Multiple levels deep
  262. # Test dir1/**/b pattern
  263. self._create_file("dir1/b") # Direct child
  264. self._create_file("dir1/subdir/b") # One level deep in dir1/
  265. self._create_file("dir1/deep/nested/b") # Multiple levels deep in dir1/
  266. self._create_file("other/dir1/b") # Should not match (dir1/ not at start)
  267. # Test **/*.tmp pattern
  268. self._create_file("test.tmp") # Root level
  269. self._create_file("dir/test.tmp") # One level deep
  270. self._create_file("deep/nested/test.tmp") # Multiple levels deep
  271. paths = [
  272. "afile",
  273. "dir/afile",
  274. "deep/nested/afile",
  275. "dir1/b",
  276. "dir1/subdir/b",
  277. "dir1/deep/nested/b",
  278. "other/dir1/b",
  279. "test.tmp",
  280. "dir/test.tmp",
  281. "deep/nested/test.tmp",
  282. ]
  283. self._assert_ignore_match(paths)
  284. def test_double_asterisk_with_negation(self) -> None:
  285. """Test ** patterns combined with negation."""
  286. self._write_gitignore(
  287. "**/build/**\n!**/build/assets/**\n**/build/assets/*.tmp\n"
  288. )
  289. # Create build directories at different levels
  290. self._create_file("build/main.js")
  291. self._create_file("build/assets/style.css")
  292. self._create_file("build/assets/temp.tmp")
  293. self._create_file("src/build/app.js")
  294. self._create_file("src/build/assets/logo.png")
  295. self._create_file("src/build/assets/cache.tmp")
  296. self._create_file("deep/nested/build/lib.js")
  297. self._create_file("deep/nested/build/assets/icon.svg")
  298. self._create_file("deep/nested/build/assets/debug.tmp")
  299. paths = [
  300. "build/main.js",
  301. "build/assets/style.css",
  302. "build/assets/temp.tmp",
  303. "src/build/app.js",
  304. "src/build/assets/logo.png",
  305. "src/build/assets/cache.tmp",
  306. "deep/nested/build/lib.js",
  307. "deep/nested/build/assets/icon.svg",
  308. "deep/nested/build/assets/debug.tmp",
  309. ]
  310. self._assert_ignore_match(paths)
  311. def test_double_asterisk_middle_patterns(self) -> None:
  312. """Test ** patterns in the middle of paths."""
  313. self._write_gitignore("src/**/test/**\nlib/**/node_modules\n**/cache/**/temp\n")
  314. # Test src/**/test/** pattern
  315. self._create_file("src/test/unit.js")
  316. self._create_file("src/components/test/unit.js")
  317. self._create_file("src/deep/nested/test/integration.js")
  318. self._create_file("other/src/test/unit.js") # Should not match
  319. # Test lib/**/node_modules pattern
  320. self._create_file("lib/node_modules/package.json")
  321. self._create_file("lib/vendor/node_modules/package.json")
  322. self._create_file("lib/deep/path/node_modules/package.json")
  323. self._create_file("other/lib/node_modules/package.json") # Should not match
  324. # Test **/cache/**/temp pattern
  325. self._create_file("cache/temp")
  326. self._create_file("cache/data/temp")
  327. self._create_file("app/cache/temp")
  328. self._create_file("app/cache/nested/temp")
  329. self._create_file("deep/cache/very/nested/temp")
  330. paths = [
  331. "src/test/unit.js",
  332. "src/components/test/unit.js",
  333. "src/deep/nested/test/integration.js",
  334. "other/src/test/unit.js",
  335. "lib/node_modules/package.json",
  336. "lib/vendor/node_modules/package.json",
  337. "lib/deep/path/node_modules/package.json",
  338. "other/lib/node_modules/package.json",
  339. "cache/temp",
  340. "cache/data/temp",
  341. "app/cache/temp",
  342. "app/cache/nested/temp",
  343. "deep/cache/very/nested/temp",
  344. ]
  345. self._assert_ignore_match(paths)
  346. def test_multiple_double_asterisks(self) -> None:
  347. """Test patterns with multiple ** segments."""
  348. self._write_gitignore("**/**/test/**/*.js\n**/src/**/build/**/dist\n")
  349. # Test **/**/test/**/*.js pattern (multiple ** in one pattern)
  350. self._create_file("test/file.js")
  351. self._create_file("a/test/file.js")
  352. self._create_file("a/b/test/file.js")
  353. self._create_file("test/c/file.js")
  354. self._create_file("test/c/d/file.js")
  355. self._create_file("a/b/test/c/d/file.js")
  356. self._create_file("a/b/test/c/d/file.txt") # Different extension
  357. # Test **/src/**/build/**/dist pattern
  358. self._create_file("src/build/dist")
  359. self._create_file("app/src/build/dist")
  360. self._create_file("src/lib/build/dist")
  361. self._create_file("src/build/prod/dist")
  362. self._create_file("app/src/lib/build/prod/dist")
  363. paths = [
  364. "test/file.js",
  365. "a/test/file.js",
  366. "a/b/test/file.js",
  367. "test/c/file.js",
  368. "test/c/d/file.js",
  369. "a/b/test/c/d/file.js",
  370. "a/b/test/c/d/file.txt",
  371. "src/build/dist",
  372. "app/src/build/dist",
  373. "src/lib/build/dist",
  374. "src/build/prod/dist",
  375. "app/src/lib/build/prod/dist",
  376. ]
  377. self._assert_ignore_match(paths)
  378. def test_double_asterisk_directory_traversal(self) -> None:
  379. """Test ** patterns with directory traversal edge cases."""
  380. self._write_gitignore("**/.*\n!**/.gitkeep\n**/.git/**\n")
  381. # Test **/.* pattern (hidden files at any level)
  382. self._create_file(".hidden")
  383. self._create_file("dir/.hidden")
  384. self._create_file("deep/nested/.hidden")
  385. self._create_file(".gitkeep") # Should be negated
  386. self._create_file("dir/.gitkeep") # Should be negated
  387. # Test **/.git/** pattern
  388. self._create_file(".git/config")
  389. self._create_file(".git/objects/abc123")
  390. self._create_file("submodule/.git/config")
  391. self._create_file("deep/submodule/.git/refs/heads/master")
  392. paths = [
  393. ".hidden",
  394. "dir/.hidden",
  395. "deep/nested/.hidden",
  396. ".gitkeep",
  397. "dir/.gitkeep",
  398. ".git/config",
  399. ".git/objects/abc123",
  400. "submodule/.git/config",
  401. "deep/submodule/.git/refs/heads/master",
  402. ]
  403. self._assert_ignore_match(paths)
  404. def test_double_asterisk_empty_segments(self) -> None:
  405. """Test ** patterns with edge cases around empty path segments."""
  406. self._write_gitignore("a/**//b\n**//**/test\nc/**/**/\n")
  407. # These patterns test edge cases with path separator handling
  408. self._create_file("a/b")
  409. self._create_file("a/x/b")
  410. self._create_file("a/x/y/b")
  411. self._create_file("test")
  412. self._create_file("dir/test")
  413. self._create_file("dir/nested/test")
  414. self._create_file("c/file")
  415. self._create_file("c/dir/file")
  416. self._create_file("c/deep/nested/file")
  417. paths = [
  418. "a/b",
  419. "a/x/b",
  420. "a/x/y/b",
  421. "test",
  422. "dir/test",
  423. "dir/nested/test",
  424. "c/file",
  425. "c/dir/file",
  426. "c/deep/nested/file",
  427. ]
  428. self._assert_ignore_match(paths)
  429. def test_double_asterisk_root_patterns(self) -> None:
  430. """Test ** patterns at repository root with complex negations."""
  431. self._write_gitignore("/**\n!/**/\n!/**/*.md\n/**/*.tmp\n")
  432. # Pattern explanation:
  433. # /** - Ignore everything at any depth
  434. # !/**/ - But don't ignore directories
  435. # !/**/*.md - And don't ignore .md files
  436. # /**/*.tmp - But do ignore .tmp files (overrides .md negation for .tmp.md files)
  437. self._create_file("file.txt")
  438. self._create_file("readme.md")
  439. self._create_file("temp.tmp")
  440. self._create_file("backup.tmp.md") # Edge case: both .tmp and .md
  441. self._create_dir("dir")
  442. self._create_file("dir/file.txt")
  443. self._create_file("dir/guide.md")
  444. self._create_file("dir/cache.tmp")
  445. self._create_file("deep/nested/doc.md")
  446. self._create_file("deep/nested/log.tmp")
  447. paths = [
  448. "file.txt",
  449. "readme.md",
  450. "temp.tmp",
  451. "backup.tmp.md",
  452. "dir/",
  453. "dir/file.txt",
  454. "dir/guide.md",
  455. "dir/cache.tmp",
  456. "deep/nested/doc.md",
  457. "deep/nested/log.tmp",
  458. ]
  459. self._assert_ignore_match(paths)
  460. def test_single_asterisk_patterns(self) -> None:
  461. """Test single asterisk * patterns in various positions."""
  462. self._write_gitignore("src/*/build\n*.log\ntest*/\n*_backup\nlib/*\n*/temp/*\n")
  463. # Test src/*/build pattern
  464. self._create_file("src/app/build")
  465. self._create_file("src/lib/build")
  466. self._create_file("src/nested/deep/build") # Should not match (only one level)
  467. self._create_file("other/src/app/build") # Should not match
  468. # Test *.log pattern
  469. self._create_file("app.log")
  470. self._create_file("error.log")
  471. self._create_file("logs/debug.log") # Should match
  472. self._create_file("app.log.old") # Should not match
  473. # Test test*/ pattern (directories starting with test)
  474. self._create_dir("test")
  475. self._create_dir("testing")
  476. self._create_dir("test_data")
  477. self._create_file("test_file") # Should not match (not a directory)
  478. # Test *_backup pattern
  479. self._create_file("db_backup")
  480. self._create_file("config_backup")
  481. self._create_file("old_backup_file") # Should not match (backup not at end)
  482. # Test lib/* pattern
  483. self._create_file("lib/module.js")
  484. self._create_file("lib/utils.py")
  485. self._create_file("lib/nested/deep.js") # Should not match (only one level)
  486. # Test */temp/* pattern
  487. self._create_file("app/temp/cache")
  488. self._create_file("src/temp/logs")
  489. self._create_file("deep/nested/temp/file") # Should not match (nested too deep)
  490. self._create_file("temp/file") # Should not match (temp at root)
  491. paths = [
  492. "src/app/build",
  493. "src/lib/build",
  494. "src/nested/deep/build",
  495. "other/src/app/build",
  496. "app.log",
  497. "error.log",
  498. "logs/debug.log",
  499. "app.log.old",
  500. "test/",
  501. "testing/",
  502. "test_data/",
  503. "test_file",
  504. "db_backup",
  505. "config_backup",
  506. "old_backup_file",
  507. "lib/module.js",
  508. "lib/utils.py",
  509. "lib/nested/deep.js",
  510. "app/temp/cache",
  511. "src/temp/logs",
  512. "deep/nested/temp/file",
  513. "temp/file",
  514. ]
  515. self._assert_ignore_match(paths)
  516. def test_single_asterisk_edge_cases(self) -> None:
  517. """Test edge cases with single asterisk patterns."""
  518. self._write_gitignore("*\n!*/\n!*.txt\n*.*.*\n")
  519. # Pattern explanation:
  520. # * - Ignore everything
  521. # !*/ - But don't ignore directories
  522. # !*.txt - And don't ignore .txt files
  523. # *.*.* - But ignore files with multiple dots
  524. self._create_file("file")
  525. self._create_file("readme.txt")
  526. self._create_file("config.json")
  527. self._create_file("archive.tar.gz") # Multiple dots
  528. self._create_file("backup.sql.old") # Multiple dots
  529. self._create_dir("folder")
  530. self._create_file("folder/nested.txt")
  531. self._create_file("folder/data.json")
  532. paths = [
  533. "file",
  534. "readme.txt",
  535. "config.json",
  536. "archive.tar.gz",
  537. "backup.sql.old",
  538. "folder/",
  539. "folder/nested.txt",
  540. "folder/data.json",
  541. ]
  542. self._assert_ignore_match(paths)
  543. def test_single_asterisk_with_character_classes(self) -> None:
  544. """Test single asterisk with character classes and special patterns."""
  545. self._write_gitignore("*.[oa]\n*~\n.*\n!.gitignore\n[Tt]emp*\n")
  546. # Test *.[oa] pattern (object and archive files)
  547. self._create_file("main.o")
  548. self._create_file("lib.a")
  549. self._create_file("app.so") # Should not match
  550. self._create_file("test.c") # Should not match
  551. # Test *~ pattern (backup files)
  552. self._create_file("file~")
  553. self._create_file("config~")
  554. self._create_file("~file") # Should not match (~ at start)
  555. # Test .* pattern with negation
  556. self._create_file(".hidden")
  557. self._create_file(".secret")
  558. self._create_file(".gitignore") # Should be negated
  559. # Test [Tt]emp* pattern (case variations)
  560. self._create_file("temp_file")
  561. self._create_file("Temp_data")
  562. self._create_file("TEMP_LOG") # Should not match (not T or t)
  563. self._create_file("temporary")
  564. paths = [
  565. "main.o",
  566. "lib.a",
  567. "app.so",
  568. "test.c",
  569. "file~",
  570. "config~",
  571. "~file",
  572. ".hidden",
  573. ".secret",
  574. ".gitignore",
  575. "temp_file",
  576. "Temp_data",
  577. "TEMP_LOG",
  578. "temporary",
  579. ]
  580. self._assert_ignore_match(paths)
  581. def test_mixed_single_double_asterisk_patterns(self) -> None:
  582. """Test patterns that mix single (*) and double (**) asterisks."""
  583. self._write_gitignore(
  584. "src/**/test/*.js\n**/build/*\n*/cache/**\nlib/*/vendor/**/*.min.*\n"
  585. )
  586. # Test src/**/test/*.js - double asterisk in middle, single at end
  587. self._create_file("src/test/unit.js")
  588. self._create_file("src/components/test/spec.js")
  589. self._create_file("src/deep/nested/test/integration.js")
  590. self._create_file(
  591. "src/test/nested/unit.js"
  592. ) # Should not match (nested after test)
  593. self._create_file(
  594. "src/components/test/unit.ts"
  595. ) # Should not match (wrong extension)
  596. # Test **/build/* - double asterisk at start, single at end
  597. self._create_file("build/app.js")
  598. self._create_file("src/build/main.js")
  599. self._create_file("deep/nested/build/lib.js")
  600. self._create_file("build/dist/app.js") # Should not match (nested after build)
  601. # Test */cache/** - single at start, double at end
  602. self._create_file("app/cache/temp")
  603. self._create_file("src/cache/data/file")
  604. self._create_file("lib/cache/deep/nested/item")
  605. self._create_file(
  606. "nested/deep/cache/file"
  607. ) # Should not match (cache not at second level)
  608. self._create_file("cache/file") # Should not match (cache at root)
  609. # Test lib/*/vendor/**/*.min.* - complex mixed pattern
  610. self._create_file("lib/app/vendor/jquery.min.js")
  611. self._create_file("lib/ui/vendor/bootstrap.min.css")
  612. self._create_file("lib/core/vendor/deep/nested/lib.min.map")
  613. self._create_file("lib/app/vendor/jquery.js") # Should not match (not .min.)
  614. self._create_file(
  615. "lib/nested/deep/vendor/lib.min.js"
  616. ) # Should not match (too deep before vendor)
  617. paths = [
  618. "src/test/unit.js",
  619. "src/components/test/spec.js",
  620. "src/deep/nested/test/integration.js",
  621. "src/test/nested/unit.js",
  622. "src/components/test/unit.ts",
  623. "build/app.js",
  624. "src/build/main.js",
  625. "deep/nested/build/lib.js",
  626. "build/dist/app.js",
  627. "app/cache/temp",
  628. "src/cache/data/file",
  629. "lib/cache/deep/nested/item",
  630. "nested/deep/cache/file",
  631. "cache/file",
  632. "lib/app/vendor/jquery.min.js",
  633. "lib/ui/vendor/bootstrap.min.css",
  634. "lib/core/vendor/deep/nested/lib.min.map",
  635. "lib/app/vendor/jquery.js",
  636. "lib/nested/deep/vendor/lib.min.js",
  637. ]
  638. self._assert_ignore_match(paths)
  639. def test_asterisk_pattern_overlaps(self) -> None:
  640. """Test overlapping single and double asterisk patterns with negations."""
  641. self._write_gitignore(
  642. "**/*.tmp\n!src/**/*.tmp\nsrc/*/cache/*.tmp\n**/test/*\n!**/test/*.spec.*\n"
  643. )
  644. # Pattern explanation:
  645. # **/*.tmp - Ignore all .tmp files anywhere
  646. # !src/**/*.tmp - But don't ignore .tmp files under src/
  647. # src/*/cache/*.tmp - But do ignore .tmp files in src/*/cache/ (overrides negation)
  648. # **/test/* - Ignore everything directly in test directories
  649. # !**/test/*.spec.* - But don't ignore spec files in test directories
  650. # Test tmp file patterns with src/ negation
  651. self._create_file("temp.tmp") # Should be ignored
  652. self._create_file("build/cache.tmp") # Should be ignored
  653. self._create_file("src/app.tmp") # Should not be ignored (src negation)
  654. self._create_file("src/lib/utils.tmp") # Should not be ignored (src negation)
  655. self._create_file(
  656. "src/app/cache/data.tmp"
  657. ) # Should be ignored (cache override)
  658. self._create_file(
  659. "src/lib/cache/temp.tmp"
  660. ) # Should be ignored (cache override)
  661. # Test test directory patterns with spec negation
  662. self._create_file("test/unit.js") # Should be ignored
  663. self._create_file("src/test/helper.js") # Should be ignored
  664. self._create_file("test/app.spec.js") # Should not be ignored (spec negation)
  665. self._create_file(
  666. "src/test/lib.spec.ts"
  667. ) # Should not be ignored (spec negation)
  668. self._create_file(
  669. "test/nested/file.js"
  670. ) # Should not be ignored (not direct child)
  671. paths = [
  672. "temp.tmp",
  673. "build/cache.tmp",
  674. "src/app.tmp",
  675. "src/lib/utils.tmp",
  676. "src/app/cache/data.tmp",
  677. "src/lib/cache/temp.tmp",
  678. "test/unit.js",
  679. "src/test/helper.js",
  680. "test/app.spec.js",
  681. "src/test/lib.spec.ts",
  682. "test/nested/file.js",
  683. ]
  684. self._assert_ignore_match(paths)
  685. def test_asterisk_boundary_conditions(self) -> None:
  686. """Test boundary conditions between single and double asterisk patterns."""
  687. self._write_gitignore("a/**/b/*\nc/**/**/d\n*/e/**/*\nf/*/g/**\n")
  688. # Test a/**/b/* - ** in middle, * at end
  689. self._create_file("a/b/file") # Direct path
  690. self._create_file("a/x/b/file") # One level between a and b
  691. self._create_file("a/x/y/b/file") # Multiple levels between a and b
  692. self._create_file("a/b/nested/file") # Should not match (nested after b)
  693. # Test c/**/**/d - multiple ** separated by single level
  694. self._create_file("c/d") # Minimal match
  695. self._create_file("c/x/d") # One level before d
  696. self._create_file("c/x/y/d") # Multiple levels before d
  697. self._create_file("c/x/y/z/d") # Even more levels
  698. # Test */e/**/* - * at start, ** in middle, * at end
  699. self._create_file("a/e/file") # Minimal match
  700. self._create_file("x/e/nested/file") # Nested after e
  701. self._create_file("y/e/deep/nested/file") # Deep nesting after e
  702. self._create_file(
  703. "nested/path/e/file"
  704. ) # Should not match (path before e too deep)
  705. # Test f/*/g/** - * in middle, ** at end
  706. self._create_file("f/x/g/file") # Basic match
  707. self._create_file("f/y/g/nested/file") # Nested after g
  708. self._create_file("f/z/g/deep/nested/file") # Deep nesting after g
  709. self._create_file(
  710. "f/nested/path/g/file"
  711. ) # Should not match (path between f and g too deep)
  712. paths = [
  713. "a/b/file",
  714. "a/x/b/file",
  715. "a/x/y/b/file",
  716. "a/b/nested/file",
  717. "c/d",
  718. "c/x/d",
  719. "c/x/y/d",
  720. "c/x/y/z/d",
  721. "a/e/file",
  722. "x/e/nested/file",
  723. "y/e/deep/nested/file",
  724. "nested/path/e/file",
  725. "f/x/g/file",
  726. "f/y/g/nested/file",
  727. "f/z/g/deep/nested/file",
  728. "f/nested/path/g/file",
  729. ]
  730. self._assert_ignore_match(paths)
  731. def test_asterisk_edge_case_combinations(self) -> None:
  732. """Test really tricky edge cases with asterisk combinations."""
  733. self._write_gitignore("***\n**/*\n*/**\n*/*/\n**/*/*\n*/*/**\n")
  734. # Test *** pattern (should behave like **)
  735. self._create_file("file1")
  736. self._create_file("dir/file2")
  737. self._create_file("deep/nested/file3")
  738. # Test **/* pattern (anything with at least one path segment)
  739. self._create_file("path1/item1")
  740. self._create_file("path2/sub/item2")
  741. # Test */** pattern (anything under a single-level directory)
  742. self._create_file("single/file4")
  743. self._create_file("single/nested/deep")
  744. # Test */*/ pattern (directories exactly two levels deep)
  745. self._create_dir("level1/level2")
  746. self._create_dir("dir1/dir2")
  747. self._create_dir("path3/sub1/sub2") # Should not match (too deep)
  748. # Test **/*/* pattern (at least two path segments after any prefix)
  749. self._create_file("test1/test2/test3")
  750. self._create_file("deep/nested/item3/item4")
  751. self._create_file(
  752. "simple/item"
  753. ) # Should not match (only one segment after any prefix at root)
  754. # Test */*/** pattern (single/single/anything)
  755. self._create_file("part1/part2/anything")
  756. self._create_file("seg1/seg2/deep/nested")
  757. paths = [
  758. "file1",
  759. "dir/file2",
  760. "deep/nested/file3",
  761. "path1/item1",
  762. "path2/sub/item2",
  763. "single/file4",
  764. "single/nested/deep",
  765. "level1/level2/",
  766. "dir1/dir2/",
  767. "path3/sub1/sub2/",
  768. "test1/test2/test3",
  769. "deep/nested/item3/item4",
  770. "simple/item",
  771. "part1/part2/anything",
  772. "seg1/seg2/deep/nested",
  773. ]
  774. self._assert_ignore_match(paths)
  775. def test_asterisk_consecutive_patterns(self) -> None:
  776. """Test patterns with consecutive asterisks and weird spacing."""
  777. self._write_gitignore("a*/b*\n*x*y*\n**z**\n**/.*/**\n*.*./*\n")
  778. # Test a*/b* pattern
  779. self._create_file("a/b") # Minimal match
  780. self._create_file("app/build") # Both have suffixes
  781. self._create_file("api/backup") # Both have suffixes
  782. self._create_file("a/build") # a exact, b with suffix
  783. self._create_file("app/b") # a with suffix, b exact
  784. self._create_file("x/a/b") # Should not match (a not at start)
  785. # Test *x*y* pattern
  786. self._create_file("xy") # Minimal
  787. self._create_file("axby") # x and y in middle
  788. self._create_file("prefixsuffyend") # x and y with text around
  789. self._create_file("xyz") # Should not match (no y after x)
  790. self._create_file("axy") # x and y consecutive
  791. # Test **z** pattern
  792. self._create_file("z") # Just z
  793. self._create_file("az") # z at end
  794. self._create_file("za") # z at start
  795. self._create_file("aza") # z in middle
  796. self._create_file("dir/z") # z at any depth
  797. self._create_file("deep/nested/prefix_z_suffix") # z anywhere in name
  798. # Test **/.*/** pattern (hidden files in any directory structure)
  799. self._create_file("dir/.hidden/file")
  800. self._create_file("deep/nested/.secret/data")
  801. self._create_file(".visible/file") # At root level
  802. self._create_file("other/.config") # Should not match (no trailing path)
  803. # Test *.*./* pattern (files with dots in specific structure)
  804. self._create_file("app.min.js/file") # Two dots, then directory
  805. self._create_file("lib.bundle.css/asset") # Two dots, then directory
  806. self._create_file("simple.js") # Should not match (only one dot, no directory)
  807. self._create_file("no.dots.here") # Should not match (no trailing directory)
  808. paths = [
  809. "a/b",
  810. "app/build",
  811. "api/backup",
  812. "a/build",
  813. "app/b",
  814. "x/a/b",
  815. "xy",
  816. "axby",
  817. "prefixsuffyend",
  818. "xyz",
  819. "axy",
  820. "z",
  821. "az",
  822. "za",
  823. "aza",
  824. "dir/z",
  825. "deep/nested/prefix_z_suffix",
  826. "dir/.hidden/file",
  827. "deep/nested/.secret/data",
  828. ".visible/file",
  829. "other/.config",
  830. "app.min.js/file",
  831. "lib.bundle.css/asset",
  832. "simple.js",
  833. "no.dots.here",
  834. ]
  835. self._assert_ignore_match(paths)
  836. def test_asterisk_escaping_and_special_chars(self) -> None:
  837. """Test asterisk patterns with special characters and potential escaping."""
  838. self._write_gitignore(
  839. "\\*literal\n**/*.\\*\n[*]bracket\n*\\[escape\\]\n*.{tmp,log}\n"
  840. )
  841. # Test \*literal pattern (literal asterisk)
  842. self._create_file("*literal") # Literal asterisk at start
  843. self._create_file("xliteral") # Should not match (no literal asterisk)
  844. self._create_file("prefix*literal") # Literal asterisk in middle
  845. # Test **/*.* pattern (files with .* extension)
  846. self._create_file("file.*") # Literal .* extension
  847. self._create_file("dir/test.*") # At any depth
  848. self._create_file("file.txt") # Should not match (not .* extension)
  849. # Test [*]bracket pattern (bracket containing asterisk)
  850. self._create_file("*bracket") # Literal asterisk from bracket
  851. self._create_file("xbracket") # Should not match
  852. self._create_file("abracket") # Should not match
  853. # Test *\[escape\] pattern (literal brackets)
  854. self._create_file("test[escape]") # Literal brackets
  855. self._create_file("prefix[escape]") # With prefix
  856. self._create_file("test[other]") # Should not match (wrong brackets)
  857. # Test *.{tmp,log} pattern (brace expansion - may not work in gitignore)
  858. self._create_file("file.{tmp,log}") # Literal braces
  859. self._create_file("test.tmp") # Might match if braces are expanded
  860. self._create_file("test.log") # Might match if braces are expanded
  861. self._create_file("test.{other}") # Should not match
  862. paths = [
  863. "*literal",
  864. "xliteral",
  865. "prefix*literal",
  866. "file.*",
  867. "dir/test.*",
  868. "file.txt",
  869. "*bracket",
  870. "xbracket",
  871. "abracket",
  872. "test[escape]",
  873. "prefix[escape]",
  874. "test[other]",
  875. "file.{tmp,log}",
  876. "test.tmp",
  877. "test.log",
  878. "test.{other}",
  879. ]
  880. self._assert_ignore_match(paths)
  881. def test_quote_path_true_unicode_filenames(self) -> None:
  882. """Test quote_path=True functionality with unicode filenames."""
  883. try:
  884. self._write_gitignore("тест*\n*.测试\n")
  885. self._create_file("тест.txt")
  886. self._create_file("файл.测试")
  887. self._create_file("normal.txt")
  888. paths = ["тест.txt", "файл.测试", "normal.txt"]
  889. # Test that dulwich with quote_path=True matches git's quoted output
  890. git_ignored = self._git_check_ignore_quoted(paths)
  891. dulwich_ignored = self._dulwich_check_ignore_quoted(paths)
  892. self.assertEqual(
  893. git_ignored,
  894. dulwich_ignored,
  895. f"Mismatch for quoted paths {paths}: git={git_ignored}, dulwich={dulwich_ignored}",
  896. )
  897. except (UnicodeEncodeError, OSError):
  898. # Skip test if filesystem doesn't support unicode
  899. self.skipTest("Filesystem doesn't support unicode filenames")
  900. def test_quote_path_consistency(self) -> None:
  901. """Test that quote_path=True and quote_path=False are consistent."""
  902. try:
  903. self._write_gitignore("тест*\n*.测试\nmixed_тест*\n")
  904. self._create_file("тест.txt")
  905. self._create_file("файл.测试")
  906. self._create_file("normal.txt")
  907. self._create_file("mixed_тест.log")
  908. paths = ["тест.txt", "файл.测试", "normal.txt", "mixed_тест.log"]
  909. # Get both quoted and unquoted results from dulwich
  910. quoted_ignored = self._dulwich_check_ignore_quoted(paths)
  911. unquoted_ignored = self._dulwich_check_ignore(paths)
  912. # Verify that the number of ignored files is the same
  913. self.assertEqual(
  914. len(quoted_ignored),
  915. len(unquoted_ignored),
  916. "Quote path setting should not change which files are ignored",
  917. )
  918. # Verify quoted paths contain the expected files
  919. expected_quoted = {
  920. '"\\321\\202\\320\\265\\321\\201\\321\\202.txt"',
  921. '"\\321\\204\\320\\260\\320\\271\\320\\273.\\346\\265\\213\\350\\257\\225"',
  922. '"mixed_\\321\\202\\320\\265\\321\\201\\321\\202.log"',
  923. }
  924. self.assertEqual(quoted_ignored, expected_quoted)
  925. # Verify unquoted paths contain the expected files
  926. expected_unquoted = {"тест.txt", "файл.测试", "mixed_тест.log"}
  927. self.assertEqual(unquoted_ignored, expected_unquoted)
  928. except (UnicodeEncodeError, OSError):
  929. # Skip test if filesystem doesn't support unicode
  930. self.skipTest("Filesystem doesn't support unicode filenames")
  931. def _git_check_ignore_quoted(self, paths: list[str]) -> set[str]:
  932. """Run git check-ignore with default quoting and return set of ignored paths."""
  933. try:
  934. # Use default git settings (core.quotePath=true by default)
  935. output = run_git_or_fail(
  936. ["check-ignore", *paths],
  937. cwd=self.test_dir,
  938. )
  939. # git check-ignore returns paths separated by newlines
  940. return set(
  941. line.decode("utf-8") for line in output.strip().split(b"\n") if line
  942. )
  943. except AssertionError:
  944. # git check-ignore returns non-zero when no paths are ignored
  945. return set()
  946. def _dulwich_check_ignore_quoted(self, paths: list[str]) -> set[str]:
  947. """Run dulwich check_ignore with quote_path=True and return set of ignored paths."""
  948. # Convert to absolute paths relative to the test directory
  949. abs_paths = [os.path.join(self.test_dir, path) for path in paths]
  950. ignored = set(porcelain.check_ignore(self.test_dir, abs_paths, quote_path=True))
  951. # Convert back to relative paths and preserve original path format
  952. result = set()
  953. path_mapping = {}
  954. for orig_path, abs_path in zip(paths, abs_paths):
  955. path_mapping[abs_path] = orig_path
  956. for path in ignored:
  957. if path.startswith(self.test_dir + "/"):
  958. rel_path = path[len(self.test_dir) + 1 :]
  959. # Find the original path format that was requested
  960. orig_path = None
  961. for requested_path in paths:
  962. if requested_path.rstrip("/") == rel_path.rstrip("/"):
  963. orig_path = requested_path
  964. break
  965. result.add(orig_path if orig_path else rel_path)
  966. else:
  967. result.add(path)
  968. return result