Skip to content

Commit ffaaf48

Browse files
authored
Compare each .gitignore found with an appropiate relative path (#3338)
* Apply .gitignore files considering their location When a .gitignore file contains the special rule to ignore every subfolder content (`*/*`) and the file is located in a subfolder relative to where the command is executed (root), the rule is incorrectly applied and ignores every file at the same level of the .gitignore file. The reason for this is that the `gitignore` variable accumulates the rules found in each .gitignore while traversing files and directories recursively. This makes sense and, in general, works as expected. The problem is that the gitignore rules are applied using as the relative path from root to target directory as a reference. This is the cause of the bug. The implemented solution keeps track of every .gitignore file found while traversing the targets and the absolute location of each .gitignore file. Then, when matching files to the .gitignore rules, compare each set of rules with the appropiate relative path to the candidate target file. To make this possible, we changed the single `gitignore` object with a dictionary of similar objects, where the corresponding key is the absolute path to the folder that contains that .gitignore file. This required changing the signature of the `get_sources` function. Also, we introduce a `is_ignored` function that compares a file with every set of rules. Finally, some tests required an update to pass the gitignore object in the new format. Signed-off-by: Antonio Ossa Guerra <[email protected]> * Test .gitignore with `*/*` is applied correctly The test contains three cases: 1) when the .gitignore with the special rule to ignore every subfolder and its contents (*/*) is in the root, 2) when the file is inside a subfolder relative to root (nested), and 3) when the target folder contains the .gitignore and root is a parent folder of the target. In all of these cases, we compare the files that are visible by Black with a known list of paths containing the expected values. Before the fix introduced in the previous commit, these tests failed when the .gitignore file was nested (second case). Now, the test is passed for all cases. Signed-off-by: Antonio Ossa Guerra <[email protected]> * Update CHANGES.md Add entry about fixed bug and changes introduced: ignore files by considering the location of each .gitignore file and the relative path of each target Signed-off-by: Antonio Ossa Guerra <[email protected]> * Small refactor to improve code readability These changes are small improvements to improve code readability: rename a variable to a more descriptive name (from `exclude_is_None` to `using_default_exclude`), use a better syntax to include the type annotation for `gitignore` variable (from typing comment to Python-style typing annotation), and replace an if-else block with a single dictionary definition (in this case, we need to compare keys instead of values, meaning that the change works) Signed-off-by: Antonio Ossa Guerra <[email protected]> * Make nested function a top-level function The function to match a given path with every discovered .gitignore file does not need to be a nested function and can be a top-level function. The arguments did not change, but the naming of local variables was improved for readability. Signed-off-by: Antonio Ossa Guerra <[email protected]> Signed-off-by: Antonio Ossa Guerra <[email protected]>
1 parent 0e9d29a commit ffaaf48

File tree

8 files changed

+63
-17
lines changed

8 files changed

+63
-17
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
<!-- Changes to how Black can be configured -->
2424

25+
- Fix incorrectly applied .gitignore rules by considering the .gitignore location and
26+
the relative path to the target file (#3338)
2527
- Fix incorrectly ignoring .gitignore presence when more than one source directory is
2628
specified (#3336)
2729

src/black/__init__.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -628,9 +628,9 @@ def get_sources(
628628
sources: Set[Path] = set()
629629
root = ctx.obj["root"]
630630

631-
exclude_is_None = exclude is None
631+
using_default_exclude = exclude is None
632632
exclude = re_compile_maybe_verbose(DEFAULT_EXCLUDES) if exclude is None else exclude
633-
gitignore = None # type: Optional[PathSpec]
633+
gitignore: Optional[PathSpec] = None
634634
root_gitignore = get_gitignore(root)
635635

636636
for s in src:
@@ -666,14 +666,11 @@ def get_sources(
666666

667667
sources.add(p)
668668
elif p.is_dir():
669-
if exclude_is_None:
670-
p_gitignore = get_gitignore(p)
671-
# No need to use p's gitignore if it is identical to root's gitignore
672-
# (i.e. root and p point to the same directory).
673-
if root_gitignore == p_gitignore:
674-
gitignore = root_gitignore
675-
else:
676-
gitignore = root_gitignore + p_gitignore
669+
if using_default_exclude:
670+
gitignore = {
671+
root: root_gitignore,
672+
root / p: get_gitignore(p),
673+
}
677674
sources.update(
678675
gen_python_files(
679676
p.iterdir(),

src/black/files.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,19 @@ def normalize_path_maybe_ignore(
182182
return root_relative_path
183183

184184

185+
def path_is_ignored(
186+
path: Path, gitignore_dict: Dict[Path, PathSpec], report: Report
187+
) -> bool:
188+
for gitignore_path, pattern in gitignore_dict.items():
189+
relative_path = normalize_path_maybe_ignore(path, gitignore_path, report)
190+
if relative_path is None:
191+
break
192+
if pattern.match_file(relative_path):
193+
report.path_ignored(path, "matches a .gitignore file content")
194+
return True
195+
return False
196+
197+
185198
def path_is_excluded(
186199
normalized_path: str,
187200
pattern: Optional[Pattern[str]],
@@ -198,7 +211,7 @@ def gen_python_files(
198211
extend_exclude: Optional[Pattern[str]],
199212
force_exclude: Optional[Pattern[str]],
200213
report: Report,
201-
gitignore: Optional[PathSpec],
214+
gitignore_dict: Optional[Dict[Path, PathSpec]],
202215
*,
203216
verbose: bool,
204217
quiet: bool,
@@ -211,15 +224,15 @@ def gen_python_files(
211224
212225
`report` is where output about exclusions goes.
213226
"""
227+
214228
assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
215229
for child in paths:
216230
normalized_path = normalize_path_maybe_ignore(child, root, report)
217231
if normalized_path is None:
218232
continue
219233

220234
# First ignore files matching .gitignore, if passed
221-
if gitignore is not None and gitignore.match_file(normalized_path):
222-
report.path_ignored(child, "matches the .gitignore file content")
235+
if gitignore_dict and path_is_ignored(child, gitignore_dict, report):
223236
continue
224237

225238
# Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
@@ -244,6 +257,13 @@ def gen_python_files(
244257
if child.is_dir():
245258
# If gitignore is None, gitignore usage is disabled, while a Falsey
246259
# gitignore is when the directory doesn't have a .gitignore file.
260+
if gitignore_dict is not None:
261+
new_gitignore_dict = {
262+
**gitignore_dict,
263+
root / child: get_gitignore(child),
264+
}
265+
else:
266+
new_gitignore_dict = None
247267
yield from gen_python_files(
248268
child.iterdir(),
249269
root,
@@ -252,7 +272,7 @@ def gen_python_files(
252272
extend_exclude,
253273
force_exclude,
254274
report,
255-
gitignore + get_gitignore(child) if gitignore is not None else None,
275+
new_gitignore_dict,
256276
verbose=verbose,
257277
quiet=quiet,
258278
)

tests/data/ignore_subfolders_gitignore_tests/a.py

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*/*

tests/data/ignore_subfolders_gitignore_tests/subdir/b.py

Whitespace-only changes.

tests/data/ignore_subfolders_gitignore_tests/subdir/subdir/c.py

Whitespace-only changes.

tests/test_black.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,7 +2042,7 @@ def test_gitignore_exclude(self) -> None:
20422042
None,
20432043
None,
20442044
report,
2045-
gitignore,
2045+
{path: gitignore},
20462046
verbose=False,
20472047
quiet=False,
20482048
)
@@ -2071,7 +2071,7 @@ def test_nested_gitignore(self) -> None:
20712071
None,
20722072
None,
20732073
report,
2074-
root_gitignore,
2074+
{path: root_gitignore},
20752075
verbose=False,
20762076
quiet=False,
20772077
)
@@ -2109,6 +2109,32 @@ def test_invalid_nested_gitignore(self) -> None:
21092109
gitignore = path / "a" / ".gitignore"
21102110
assert f"Could not parse {gitignore}" in result.stderr_bytes.decode()
21112111

2112+
def test_gitignore_that_ignores_subfolders(self) -> None:
2113+
# If gitignore with */* is in root
2114+
root = Path(DATA_DIR / "ignore_subfolders_gitignore_tests" / "subdir")
2115+
expected = [root / "b.py"]
2116+
ctx = FakeContext()
2117+
ctx.obj["root"] = root
2118+
assert_collected_sources([root], expected, ctx=ctx)
2119+
2120+
# If .gitignore with */* is nested
2121+
root = Path(DATA_DIR / "ignore_subfolders_gitignore_tests")
2122+
expected = [
2123+
root / "a.py",
2124+
root / "subdir" / "b.py",
2125+
]
2126+
ctx = FakeContext()
2127+
ctx.obj["root"] = root
2128+
assert_collected_sources([root], expected, ctx=ctx)
2129+
2130+
# If command is executed from outer dir
2131+
root = Path(DATA_DIR / "ignore_subfolders_gitignore_tests")
2132+
target = root / "subdir"
2133+
expected = [target / "b.py"]
2134+
ctx = FakeContext()
2135+
ctx.obj["root"] = root
2136+
assert_collected_sources([target], expected, ctx=ctx)
2137+
21122138
def test_empty_include(self) -> None:
21132139
path = DATA_DIR / "include_exclude_tests"
21142140
src = [path]
@@ -2163,7 +2189,7 @@ def test_symlink_out_of_root_directory(self) -> None:
21632189
None,
21642190
None,
21652191
report,
2166-
gitignore,
2192+
{path: gitignore},
21672193
verbose=False,
21682194
quiet=False,
21692195
)

0 commit comments

Comments
 (0)