Skip to content

Commit

Permalink
Apply .gitignore files considering their location
Browse files Browse the repository at this point in the history
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 <aaossa@uc.cl>
  • Loading branch information
aaossa committed Nov 2, 2022
1 parent 575220f commit f265a96
Show file tree
Hide file tree
Showing 3 changed files with 33 additions and 12 deletions.
12 changes: 7 additions & 5 deletions src/black/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,8 @@ def get_sources(
sources: Set[Path] = set()
root = ctx.obj["root"]

gitignore = None

for s in src:
if s == "-" and stdin_filename:
p = Path(stdin_filename)
Expand Down Expand Up @@ -660,14 +662,14 @@ def get_sources(
elif p.is_dir():
if exclude is None:
exclude = re_compile_maybe_verbose(DEFAULT_EXCLUDES)
gitignore = get_gitignore(root)
root_gitignore = get_gitignore(root)
p_gitignore = get_gitignore(p)
# No need to use p's gitignore if it is identical to root's gitignore
# (i.e. root and p point to the same directory).
if gitignore != p_gitignore:
gitignore += p_gitignore
else:
gitignore = None
if root_gitignore == p_gitignore:
gitignore = {root: root_gitignore}
else:
gitignore = {root: root_gitignore, root / p: p_gitignore}
sources.update(
gen_python_files(
p.iterdir(),
Expand Down
27 changes: 23 additions & 4 deletions src/black/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def gen_python_files(
extend_exclude: Optional[Pattern[str]],
force_exclude: Optional[Pattern[str]],
report: Report,
gitignore: Optional[PathSpec],
gitignore_dict: Optional[Dict[Path, PathSpec]],
*,
verbose: bool,
quiet: bool,
Expand All @@ -211,15 +211,27 @@ def gen_python_files(
`report` is where output about exclusions goes.
"""

def is_ignored(
gitignore_dict: Dict[Path, PathSpec], child: Path, report: Report
) -> bool:
for _dir, _gitignore in gitignore_dict.items():
relative_path = normalize_path_maybe_ignore(child, _dir, report)
if relative_path is None:
break
if _gitignore is not None and _gitignore.match_file(relative_path):
report.path_ignored(child, "matches the .gitignore file content")
return True
return False

assert root.is_absolute(), f"INTERNAL ERROR: `root` must be absolute but is {root}"
for child in paths:
normalized_path = normalize_path_maybe_ignore(child, root, report)
if normalized_path is None:
continue

# First ignore files matching .gitignore, if passed
if gitignore is not None and gitignore.match_file(normalized_path):
report.path_ignored(child, "matches the .gitignore file content")
if gitignore_dict is not None and is_ignored(gitignore_dict, child, report):
continue

# Then ignore with `--exclude` `--extend-exclude` and `--force-exclude` options.
Expand All @@ -244,6 +256,13 @@ def gen_python_files(
if child.is_dir():
# If gitignore is None, gitignore usage is disabled, while a Falsey
# gitignore is when the directory doesn't have a .gitignore file.
if gitignore_dict is not None:
new_gitignore_dict = {
**gitignore_dict,
root / child: get_gitignore(child),
}
else:
new_gitignore_dict = None
yield from gen_python_files(
child.iterdir(),
root,
Expand All @@ -252,7 +271,7 @@ def gen_python_files(
extend_exclude,
force_exclude,
report,
gitignore + get_gitignore(child) if gitignore is not None else None,
new_gitignore_dict,
verbose=verbose,
quiet=quiet,
)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_black.py
Original file line number Diff line number Diff line change
Expand Up @@ -1989,7 +1989,7 @@ def test_gitignore_exclude(self) -> None:
None,
None,
report,
gitignore,
{path: gitignore},
verbose=False,
quiet=False,
)
Expand Down Expand Up @@ -2018,7 +2018,7 @@ def test_nested_gitignore(self) -> None:
None,
None,
report,
root_gitignore,
{path: root_gitignore},
verbose=False,
quiet=False,
)
Expand Down Expand Up @@ -2110,7 +2110,7 @@ def test_symlink_out_of_root_directory(self) -> None:
None,
None,
report,
gitignore,
{path: gitignore},
verbose=False,
quiet=False,
)
Expand Down

0 comments on commit f265a96

Please sign in to comment.