Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Compare each .gitignore found with an appropiate relative path #3338

Merged
merged 6 commits into from Nov 8, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Expand Up @@ -18,6 +18,9 @@

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

- Fix incorrectly applied .gitignore rules by considering the .gitignore location and
the relative path to the target file (#3338)

### Packaging

<!-- Changes to how Black is packaged, such as dependency requirements -->
Expand Down
12 changes: 7 additions & 5 deletions src/black/__init__.py
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).
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if there are more than two levels? For example, we're formatting a/b/c/ and a/.gitignore/, a/b/.gitignore, and a/b/c/.gitignore all exist.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Right now, if the command is black a/b/c/ then a/b/.gitignore is completely skipped while a/.gitignore and a/b/c/.gitignore are captured by root_gitignore and p_gitignore, respectively. What's the expected behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a separate issue/PR? If its an undefined behavior, then it should be documented properly I think

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if there are more than two levels? For example, we're formatting a/b/c/ and a/.gitignore/, a/b/.gitignore, and a/b/c/.gitignore all exist.

The current behavior seems to be that black a/b/c only detects a/b/c/.gitignore and completely ignores other gitignore files. This PR implements the same behavior

if gitignore != p_gitignore:
gitignore += p_gitignore
else:
gitignore = None
if root_gitignore == p_gitignore:
aaossa marked this conversation as resolved.
Show resolved Hide resolved
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
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(
aaossa marked this conversation as resolved.
Show resolved Hide resolved
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")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe include the (full) path to the .gitignore file that matched?

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
Empty file.
@@ -0,0 +1 @@
*/*
Empty file.
Empty file.
32 changes: 29 additions & 3 deletions tests/test_black.py
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 @@ -2056,6 +2056,32 @@ def test_invalid_nested_gitignore(self) -> None:
gitignore = path / "a" / ".gitignore"
assert f"Could not parse {gitignore}" in result.stderr_bytes.decode()

def test_gitignore_that_ignores_subfolders(self) -> None:
aaossa marked this conversation as resolved.
Show resolved Hide resolved
# If gitignore with */* is in root
root = Path(DATA_DIR / "ignore_subfolders_gitignore_tests" / "subdir")
expected = [root / "b.py"]
ctx = FakeContext()
ctx.obj["root"] = root
assert_collected_sources([root], expected, ctx=ctx)

# If .gitignore with */* is nested
root = Path(DATA_DIR / "ignore_subfolders_gitignore_tests")
expected = [
root / "a.py",
root / "subdir" / "b.py",
]
ctx = FakeContext()
ctx.obj["root"] = root
assert_collected_sources([root], expected, ctx=ctx)

# If command is executed from outer dir
root = Path(DATA_DIR / "ignore_subfolders_gitignore_tests")
target = root / "subdir"
expected = [target / "b.py"]
ctx = FakeContext()
ctx.obj["root"] = root
assert_collected_sources([target], expected, ctx=ctx)

def test_empty_include(self) -> None:
path = DATA_DIR / "include_exclude_tests"
src = [path]
Expand Down Expand Up @@ -2110,7 +2136,7 @@ def test_symlink_out_of_root_directory(self) -> None:
None,
None,
report,
gitignore,
{path: gitignore},
verbose=False,
quiet=False,
)
Expand Down