From dacd8f9310627f9e4fcd71522d6d4a4bddc869bb Mon Sep 17 00:00:00 2001 From: Jack Walker Date: Mon, 7 Sep 2026 22:41:07 -0400 Subject: [PATCH] Match newlines in gitignore globstar path segments --- CHANGES_1.in.rst | 2 + pathspec/patterns/gitignore/basic.py | 8 +-- pathspec/patterns/gitignore/spec.py | 6 +- tests/test_03_gitignore_basic.py | 95 ++++++++++++++++------------ tests/test_04_gitignore_spec.py | 44 ++++++------- tests/test_06_gitignore.py | 11 ++++ 6 files changed, 95 insertions(+), 71 deletions(-) diff --git a/CHANGES_1.in.rst b/CHANGES_1.in.rst index 33926fc..2881f16 100644 --- a/CHANGES_1.in.rst +++ b/CHANGES_1.in.rst @@ -16,6 +16,8 @@ New features: Bug fixes: +- Match newline characters in directory names when expanding gitignore globstars, including implicit leading globstars. + - `Pull #123`_: Ignore invalid gitignore bracket ranges for `GitIgnoreSpec`. - `Pull #128`_: Support POSIX character classes (e.g. `[[:alpha:]]`) in gitignore bracket expressions. - `Issue #129`_ / `Pull #132`_: Fix GitIgnoreSpec re-including files under an excluded directory diff --git a/pathspec/patterns/gitignore/basic.py b/pathspec/patterns/gitignore/basic.py index 770eab1..460314b 100644 --- a/pathspec/patterns/gitignore/basic.py +++ b/pathspec/patterns/gitignore/basic.py @@ -98,7 +98,7 @@ def __normalize_segments( return (None, '/') else: # The pattern "**" will match every path. Special case this pattern. - return (None, '.') + return (None, '(?s:.)') elif ( seg_count == 2 @@ -107,7 +107,7 @@ def __normalize_segments( ): # The pattern "*" will be normalized to "**/*" and will match every # path. Special case this pattern for efficiency. - return (None, '.') + return (None, '(?s:.)') elif ( seg_count == 3 @@ -265,12 +265,12 @@ def __translate_segments(cls, pattern_segs: list[str]) -> list[str]: # match any leading path segments. # - NOTICE: '(?:^|/)' benchmarks slower using p15 (sm=0.9382, # hs=0.9966, re2=0.9337). - out_parts.append('^(?:.+/)?') + out_parts.append('^(?:(?s:.)+/)?') elif i < end: # A pattern with inner double-asterisks ('**') will match multiple (or # zero) inner path segments. - out_parts.append('(?:/.+)?') + out_parts.append('(?:/(?s:.)+)?') need_slash = True else: diff --git a/pathspec/patterns/gitignore/spec.py b/pathspec/patterns/gitignore/spec.py index c6d5133..9993f04 100644 --- a/pathspec/patterns/gitignore/spec.py +++ b/pathspec/patterns/gitignore/spec.py @@ -39,7 +39,7 @@ This regular expression matches the optional directory marker and sub-path. """ -_MATCH_ALL = f'^(?:.+/)?[^/]+{_DIR_MARK_OPT}' +_MATCH_ALL = f'^(?:(?s:.)+/)?[^/]+{_DIR_MARK_OPT}' """ This regular expression matches every path. It is the expansion of the patterns "*" and "**" (i.e., "**/{any name}"), and it has to capture the directory marker @@ -306,12 +306,12 @@ def __translate_segments( if i == 0: # A normalized pattern beginning with double-asterisks ('**') will # match any leading path segments. - out_parts.append('^(?:.+/)?') + out_parts.append('^(?:(?s:.)+/)?') elif i < end: # A pattern with inner double-asterisks ('**') will match multiple (or # zero) inner path segments. - out_parts.append('(?:/.+)?') + out_parts.append('(?:/(?s:.)+)?') need_slash = True else: diff --git a/tests/test_03_gitignore_basic.py b/tests/test_03_gitignore_basic.py index f85cf2f..01daa9a 100644 --- a/tests/test_03_gitignore_basic.py +++ b/tests/test_03_gitignore_basic.py @@ -99,7 +99,7 @@ def test_01_absolute_root_1(self): # GitIgnoreSpecPattern. regex, include = GitIgnoreBasicPattern.pattern_to_regex('/') self.assertTrue(include) - self.assertEqual(regex, '.') + self.assertEqual(regex, '(?s:.)') def test_01_absolute_root_2_double_asterisk(self): """ @@ -138,7 +138,7 @@ def test_01_relative(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('spam') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?spam{_DIR_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?spam{_DIR_OPT}') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -202,7 +202,7 @@ def test_02_ignore(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('!temp') self.assertIs(include, False) - self.assertEqual(regex, f'^(?:.+/)?temp{_DIR_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?temp{_DIR_OPT}') # NOTE: The pattern match is backwards because the pattern itself # does not consider the include attribute. @@ -255,7 +255,7 @@ def test_03_inner_double_asterisk(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('left/**/right') self.assertTrue(include) - self.assertEqual(regex, f'^left(?:/.+)?/right{_DIR_OPT}') + self.assertEqual(regex, f'^left(?:/(?s:.)+)?/right{_DIR_OPT}') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -278,7 +278,7 @@ def test_03_only_double_asterisk(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('**') self.assertTrue(include) - self.assertEqual(regex, '.') + self.assertEqual(regex, '(?s:.)') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -315,7 +315,7 @@ def test_03_parent_double_asterisk(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/spam') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?spam{_DIR_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?spam{_DIR_OPT}') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -335,7 +335,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('**') self.assertTrue(include) - self.assertEqual(regex, '.') + self.assertEqual(regex, '(?s:.)') equiv_regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/**') self.assertTrue(include) @@ -347,7 +347,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/api') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?api{_DIR_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?api{_DIR_OPT}') equiv_regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/**/api') self.assertTrue(include) @@ -355,7 +355,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/api/') self.assertTrue(include) - self.assertEqual(regex, '^(?:.+/)?api/') + self.assertEqual(regex, '^(?:(?s:.)+/)?api/') equiv_regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/**/api/') self.assertTrue(include) @@ -363,7 +363,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/api/**') self.assertTrue(include) - self.assertEqual(regex, '^(?:.+/)?api/') + self.assertEqual(regex, '^(?:(?s:.)+/)?api/') equiv_regex, include = GitIgnoreBasicPattern.pattern_to_regex('**/**/api/**/**') self.assertTrue(include) @@ -397,7 +397,7 @@ def test_04_infix_wildcard(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('foo-*-bar') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?foo\\-[^/]*\\-bar{_DIR_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?foo\\-[^/]*\\-bar{_DIR_OPT}') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -429,7 +429,7 @@ def test_04_postfix_wildcard(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('~temp-*') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?\\~temp\\-[^/]*{_DIR_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?\\~temp\\-[^/]*{_DIR_OPT}') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -460,7 +460,7 @@ def test_04_prefix_wildcard(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('*.py') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?[^/]*\\.py{_DIR_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?[^/]*\\.py{_DIR_OPT}') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -492,7 +492,7 @@ def test_05_directory(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('dir/') self.assertTrue(include) - self.assertEqual(regex, '^(?:.+/)?dir/') + self.assertEqual(regex, '^(?:(?s:.)+/)?dir/') pattern = GitIgnoreBasicPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -750,7 +750,7 @@ def test_12_asterisk_1_regex(self): """ regex, include = GitIgnoreBasicPattern.pattern_to_regex('*') self.assertTrue(include) - self.assertEqual(regex, '.') + self.assertEqual(regex, '(?s:.)') def test_12_asterisk_2_regex_equivalent(self): """ @@ -865,7 +865,7 @@ def test_14_issue_81_c(self): # GitIgnoreSpecPattern should not. pattern = GitIgnoreBasicPattern('!libfoo/') - self.assertEqual(pattern.regex.pattern, '^(?:.+/)?libfoo/') + self.assertEqual(pattern.regex.pattern, '^(?:(?s:.)+/)?libfoo/') self.assertIs(pattern.include, False) self.assertTrue(pattern.match_file('libfoo/__init__.py')) @@ -875,7 +875,7 @@ def test_15_issue_93_a_1(self): """ pattern = GitIgnoreBasicPattern('foo**') self.assertIs(pattern.include, True) - self.assertEqual(pattern.regex.pattern, f'^(?:.+/)?foo[^/]*[^/]*{_DIR_OPT}') + self.assertEqual(pattern.regex.pattern, f'^(?:(?s:.)+/)?foo[^/]*[^/]*{_DIR_OPT}') self.assertTrue(pattern.match_file('foosrodah')) def test_15_issue_93_a_2(self): @@ -894,7 +894,7 @@ def test_15_issue_93_b_1_single(self): """ pattern = GitIgnoreBasicPattern(' foo') self.assertIs(pattern.include, True) - self.assertEqual(pattern.regex.pattern, f'^(?:.+/)?\\ foo{_DIR_OPT}') + self.assertEqual(pattern.regex.pattern, f'^(?:(?s:.)+/)?\\ foo{_DIR_OPT}') self.assertFalse(pattern.match_file('foo')) self.assertTrue(pattern.match_file(' foo')) @@ -904,7 +904,7 @@ def test_15_issue_93_b_2_double(self): """ pattern = GitIgnoreBasicPattern(' foo') self.assertIs(pattern.include, True) - self.assertEqual(pattern.regex.pattern, f'^(?:.+/)?\\ \\ foo{_DIR_OPT}') + self.assertEqual(pattern.regex.pattern, f'^(?:(?s:.)+/)?\\ \\ foo{_DIR_OPT}') self.assertFalse(pattern.match_file('foo')) self.assertFalse(pattern.match_file(' foo')) self.assertTrue(pattern.match_file(' foo')) @@ -914,12 +914,12 @@ def test_15_issue_93_c_1_valid(self): Test patterns with valid range notation. """ for raw_pattern, regex in [ - ('[!a-z]', f'^(?:.+/)?[^a-z]{_DIR_OPT}'), - ('[^a-z]', f'^(?:.+/)?[^a-z]{_DIR_OPT}'), - ('[a-z]', f'^(?:.+/)?[a-z]{_DIR_OPT}'), - ('a[!a-z]', f'^(?:.+/)?a[^a-z]{_DIR_OPT}'), - ('a[^a-z]', f'^(?:.+/)?a[^a-z]{_DIR_OPT}'), - ('a[a-z]', f'^(?:.+/)?a[a-z]{_DIR_OPT}'), + ('[!a-z]', f'^(?:(?s:.)+/)?[^a-z]{_DIR_OPT}'), + ('[^a-z]', f'^(?:(?s:.)+/)?[^a-z]{_DIR_OPT}'), + ('[a-z]', f'^(?:(?s:.)+/)?[a-z]{_DIR_OPT}'), + ('a[!a-z]', f'^(?:(?s:.)+/)?a[^a-z]{_DIR_OPT}'), + ('a[^a-z]', f'^(?:(?s:.)+/)?a[^a-z]{_DIR_OPT}'), + ('a[a-z]', f'^(?:(?s:.)+/)?a[a-z]{_DIR_OPT}'), ]: with self.subTest(f"p={raw_pattern!r}"): pattern = GitIgnoreBasicPattern(raw_pattern) @@ -932,10 +932,10 @@ def test_15_issue_93_c_2_invalid(self): """ # The basic pattern treats invalid range notation as a literal. for raw_pattern, regex in [ - ('[!]', f'^(?:.+/)?\\[!\\]{_DIR_OPT}'), - ('[^]', f'^(?:.+/)?\\[\\^\\]{_DIR_OPT}'), - ('a[!]', f'^(?:.+/)?a\\[!\\]{_DIR_OPT}'), - ('a[^]', f'^(?:.+/)?a\\[\\^\\]{_DIR_OPT}'), + ('[!]', f'^(?:(?s:.)+/)?\\[!\\]{_DIR_OPT}'), + ('[^]', f'^(?:(?s:.)+/)?\\[\\^\\]{_DIR_OPT}'), + ('a[!]', f'^(?:(?s:.)+/)?a\\[!\\]{_DIR_OPT}'), + ('a[^]', f'^(?:(?s:.)+/)?a\\[\\^\\]{_DIR_OPT}'), ]: with self.subTest(f"p={raw_pattern!r}"): pattern = GitIgnoreBasicPattern(raw_pattern) @@ -956,19 +956,19 @@ def test_15_issue_93_c_3_unclosed(self): Test patterns with unclosed range notation. """ for raw_pattern, regex in [ - ('[!', f'^(?:.+/)?\\[!{_DIR_OPT}'), - ('[', f'^(?:.+/)?\\[{_DIR_OPT}'), - ('[-', f'^(?:.+/)?\\[\\-{_DIR_OPT}'), - ('[^', f'^(?:.+/)?\\[\\^{_DIR_OPT}'), - ('[a', f'^(?:.+/)?\\[a{_DIR_OPT}'), - ('[a-', f'^(?:.+/)?\\[a\\-{_DIR_OPT}'), - ('[a-z', f'^(?:.+/)?\\[a\\-z{_DIR_OPT}'), - ('a[!', f'^(?:.+/)?a\\[!{_DIR_OPT}'), - ('a[', f'^(?:.+/)?a\\[{_DIR_OPT}'), - ('a[-', f'^(?:.+/)?a\\[\\-{_DIR_OPT}'), - ('a[^', f'^(?:.+/)?a\\[\\^{_DIR_OPT}'), - ('a[a-', f'^(?:.+/)?a\\[a\\-{_DIR_OPT}'), - ('a[a-z', f'^(?:.+/)?a\\[a\\-z{_DIR_OPT}'), + ('[!', f'^(?:(?s:.)+/)?\\[!{_DIR_OPT}'), + ('[', f'^(?:(?s:.)+/)?\\[{_DIR_OPT}'), + ('[-', f'^(?:(?s:.)+/)?\\[\\-{_DIR_OPT}'), + ('[^', f'^(?:(?s:.)+/)?\\[\\^{_DIR_OPT}'), + ('[a', f'^(?:(?s:.)+/)?\\[a{_DIR_OPT}'), + ('[a-', f'^(?:(?s:.)+/)?\\[a\\-{_DIR_OPT}'), + ('[a-z', f'^(?:(?s:.)+/)?\\[a\\-z{_DIR_OPT}'), + ('a[!', f'^(?:(?s:.)+/)?a\\[!{_DIR_OPT}'), + ('a[', f'^(?:(?s:.)+/)?a\\[{_DIR_OPT}'), + ('a[-', f'^(?:(?s:.)+/)?a\\[\\-{_DIR_OPT}'), + ('a[^', f'^(?:(?s:.)+/)?a\\[\\^{_DIR_OPT}'), + ('a[a-', f'^(?:(?s:.)+/)?a\\[a\\-{_DIR_OPT}'), + ('a[a-z', f'^(?:(?s:.)+/)?a\\[a\\-z{_DIR_OPT}'), ]: with self.subTest(f"p={raw_pattern!r}"): pattern = GitIgnoreBasicPattern(raw_pattern) @@ -982,3 +982,14 @@ def test_16_repr_str(self): pattern = GitIgnoreBasicPattern('*.py') self.assertEqual(repr(pattern), "GitIgnoreBasicPattern(pattern='*.py', include=True)") self.assertEqual(str(pattern), '*.py') + + def test_globstars_match_newlines(self): + for pattern, path in [ + ("target", "line\nbreak/target"), + ("**/target", "line\nbreak/target"), + ("root/**/target", "root/line\nbreak/target"), + ("**", "\n"), + ("*", "\n"), + ]: + with self.subTest(pattern=pattern, path=path): + self.assertIsNotNone(GitIgnoreBasicPattern(pattern).match_file(path)) diff --git a/tests/test_04_gitignore_spec.py b/tests/test_04_gitignore_spec.py index 618f658..74e8e44 100644 --- a/tests/test_04_gitignore_spec.py +++ b/tests/test_04_gitignore_spec.py @@ -137,7 +137,7 @@ def test_01_relative(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('spam') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?spam{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?spam{_DIR_MARK_OPT}') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -201,7 +201,7 @@ def test_02_ignore(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('!temp') self.assertIs(include, False) - self.assertEqual(regex, f'^(?:.+/)?temp{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?temp{_DIR_MARK_OPT}') # NOTE: The pattern match is backwards because the pattern itself # does not consider the include attribute. @@ -254,7 +254,7 @@ def test_03_inner_double_asterisk(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('left/**/right') self.assertTrue(include) - self.assertEqual(regex, f'^left(?:/.+)?/right{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^left(?:/(?s:.)+)?/right{_DIR_MARK_OPT}') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -314,7 +314,7 @@ def test_03_parent_double_asterisk(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/spam') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?spam{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?spam{_DIR_MARK_OPT}') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -346,7 +346,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/api') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?api{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?api{_DIR_MARK_OPT}') equiv_regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/**/api') self.assertTrue(include) @@ -354,7 +354,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/api/') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?api{_DIR_MARK_CG}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?api{_DIR_MARK_CG}') equiv_regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/**/api/') self.assertTrue(include) @@ -362,7 +362,7 @@ def test_03_duplicate_leading_double_asterisk_edge_case(self): regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/api/**') self.assertTrue(include) - self.assertEqual(regex, '^(?:.+/)?api/') + self.assertEqual(regex, '^(?:(?s:.)+/)?api/') equiv_regex, include = GitIgnoreSpecPattern.pattern_to_regex('**/**/api/**/**') self.assertTrue(include) @@ -396,7 +396,7 @@ def test_04_infix_wildcard(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('foo-*-bar') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?foo\\-[^/]*\\-bar{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?foo\\-[^/]*\\-bar{_DIR_MARK_OPT}') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -428,7 +428,7 @@ def test_04_postfix_wildcard(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('~temp-*') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?\\~temp\\-[^/]*{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?\\~temp\\-[^/]*{_DIR_MARK_OPT}') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -459,7 +459,7 @@ def test_04_prefix_wildcard(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('*.py') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?[^/]*\\.py{_DIR_MARK_OPT}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?[^/]*\\.py{_DIR_MARK_OPT}') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -491,7 +491,7 @@ def test_05_directory(self): """ regex, include = GitIgnoreSpecPattern.pattern_to_regex('dir/') self.assertTrue(include) - self.assertEqual(regex, f'^(?:.+/)?dir{_DIR_MARK_CG}') + self.assertEqual(regex, f'^(?:(?s:.)+/)?dir{_DIR_MARK_CG}') pattern = GitIgnoreSpecPattern(re.compile(regex), include) results = set(filter(pattern.match_file, [ @@ -897,7 +897,7 @@ def test_14_issue_81_c(self): # GitIgnoreSpecPattern will match the file, but GitIgnoreSpec should not. pattern = GitIgnoreSpecPattern('!libfoo/') - self.assertEqual(pattern.regex.pattern, f'^(?:.+/)?libfoo{_DIR_MARK_CG}') + self.assertEqual(pattern.regex.pattern, f'^(?:(?s:.)+/)?libfoo{_DIR_MARK_CG}') self.assertIs(pattern.include, False) self.assertTrue(pattern.match_file('libfoo/__init__.py')) @@ -907,7 +907,7 @@ def test_15_issue_93_a_1(self): """ pattern = GitIgnoreSpecPattern('foo**') self.assertIs(pattern.include, True) - self.assertEqual(pattern.regex.pattern, f'^(?:.+/)?foo[^/]*[^/]*{_DIR_MARK_OPT}') + self.assertEqual(pattern.regex.pattern, f'^(?:(?s:.)+/)?foo[^/]*[^/]*{_DIR_MARK_OPT}') self.assertTrue(pattern.match_file('foosrodah')) def test_15_issue_93_a_2(self): @@ -926,7 +926,7 @@ def test_15_issue_93_b_1_single(self): """ pattern = GitIgnoreSpecPattern(' foo') self.assertIs(pattern.include, True) - self.assertEqual(pattern.regex.pattern, f'^(?:.+/)?\\ foo{_DIR_MARK_OPT}') + self.assertEqual(pattern.regex.pattern, f'^(?:(?s:.)+/)?\\ foo{_DIR_MARK_OPT}') self.assertFalse(pattern.match_file('foo')) self.assertTrue(pattern.match_file(' foo')) @@ -936,7 +936,7 @@ def test_15_issue_93_b_2_double(self): """ pattern = GitIgnoreSpecPattern(' foo') self.assertIs(pattern.include, True) - self.assertEqual(pattern.regex.pattern, f'^(?:.+/)?\\ \\ foo{_DIR_MARK_OPT}') + self.assertEqual(pattern.regex.pattern, f'^(?:(?s:.)+/)?\\ \\ foo{_DIR_MARK_OPT}') self.assertFalse(pattern.match_file('foo')) self.assertFalse(pattern.match_file(' foo')) self.assertTrue(pattern.match_file(' foo')) @@ -946,12 +946,12 @@ def test_15_issue_93_c_1_valid(self): Test patterns with valid range notation. """ for raw_pattern, regex in [ - ('[!a-z]', f'^(?:.+/)?[^a-z]{_DIR_MARK_OPT}'), - ('[^a-z]', f'^(?:.+/)?[^a-z]{_DIR_MARK_OPT}'), - ('[a-z]', f'^(?:.+/)?[a-z]{_DIR_MARK_OPT}'), - ('a[!a-z]', f'^(?:.+/)?a[^a-z]{_DIR_MARK_OPT}'), - ('a[^a-z]', f'^(?:.+/)?a[^a-z]{_DIR_MARK_OPT}'), - ('a[a-z]', f'^(?:.+/)?a[a-z]{_DIR_MARK_OPT}'), + ('[!a-z]', f'^(?:(?s:.)+/)?[^a-z]{_DIR_MARK_OPT}'), + ('[^a-z]', f'^(?:(?s:.)+/)?[^a-z]{_DIR_MARK_OPT}'), + ('[a-z]', f'^(?:(?s:.)+/)?[a-z]{_DIR_MARK_OPT}'), + ('a[!a-z]', f'^(?:(?s:.)+/)?a[^a-z]{_DIR_MARK_OPT}'), + ('a[^a-z]', f'^(?:(?s:.)+/)?a[^a-z]{_DIR_MARK_OPT}'), + ('a[a-z]', f'^(?:(?s:.)+/)?a[a-z]{_DIR_MARK_OPT}'), ]: with self.subTest(f"p={raw_pattern!r}"): pattern = GitIgnoreSpecPattern(raw_pattern) @@ -1025,7 +1025,7 @@ def test_16_posix_class_a_regex(self): pattern = GitIgnoreSpecPattern(raw_pattern) self.assertIs(pattern.include, True) self.assertEqual( - pattern.regex.pattern, f'^(?:.+/)?{expr}{_DIR_MARK_OPT}', + pattern.regex.pattern, f'^(?:(?s:.)+/)?{expr}{_DIR_MARK_OPT}', ) def test_16_posix_class_b_match(self): diff --git a/tests/test_06_gitignore.py b/tests/test_06_gitignore.py index 980386f..492146f 100644 --- a/tests/test_06_gitignore.py +++ b/tests/test_06_gitignore.py @@ -907,3 +907,14 @@ def test_11_issue_134(self): "node_modules/", "node_modules/leaf.txt", }, debug) + + def test_globstars_match_newlines_in_directory_names(self): + for pattern, path in [ + ("target", "line\nbreak/target"), + ("**/target", "line\nbreak/target"), + ("root/**/target", "root/line\nbreak/target"), + ("**/target", "\n/target"), + ]: + for sub_test in self.parameterize_from_lines([pattern]): + with self.subTest(pattern=pattern, path=path), sub_test() as spec: + self.assertTrue(spec.match_file(path))