fix(detect): scope nested ignore patterns to their own subtree (#1873)

Non-anchored patterns from a nested .gitignore/.graphifyignore were
matched against root-relative paths first, so a nested ignore file's
patterns leaked outside its directory. In the wild, .hypothesis/.gitignore
(a bare "*" auto-written by the hypothesis library) ignored the entire
repository and detect() returned 0 files.

Per gitignore semantics, patterns from A/.gitignore apply only to paths
under A. Match every pattern against the path relative to its own anchor
(the anchor dir itself exempt — an ignore file governs its directory's
contents, not the directory), and skip patterns whose anchor does not
contain the target.

Regression introduced with nested-ignore support in 8a5287a (#1206).
tests/test_detect.py: 143 passed, including the existing nested-ignore
and nested-negation tests plus two new regressions.

Fixes #1873

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alwyn93
2026-07-14 14:01:58 +04:00
committed by safishamsi
parent 368407874f
commit fb4d45226e
2 changed files with 43 additions and 18 deletions
+11 -18
View File
@@ -955,25 +955,18 @@ def _is_ignored(
if not p:
continue
# gitignore semantics: patterns from A/.gitignore apply ONLY to paths
# under A. Matching non-anchored patterns against root-relative paths
# let e.g. .hypothesis/.gitignore's bare "*" ignore the ENTIRE repo
# (detect() returned 0 files). The anchor dir itself is exempt — an
# ignore file governs its directory's contents, not the directory.
matched = False
if anchored:
try:
rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/")
matched = _matches(rel_anchor, p, anchored=True)
except ValueError:
pass
else:
try:
rel = str(target.relative_to(root)).replace(os.sep, "/")
matched = _matches(rel, p, anchored=False)
except ValueError:
pass
if not matched and anchor != root:
try:
rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/")
matched = _matches(rel_anchor, p, anchored=False)
except ValueError:
pass
try:
rel_anchor = str(target.relative_to(anchor)).replace(os.sep, "/")
except ValueError:
continue # target outside this pattern's anchor: cannot match
if rel_anchor != ".":
matched = _matches(rel_anchor, p, anchored=anchored)
if matched:
result = not negated # last match wins; ! flips to un-ignore
+32
View File
@@ -1815,3 +1815,35 @@ def test_detect_surfaces_unreadable_dir_instead_of_silent_skip(tmp_path, capsys)
assert any(f.endswith("a.py") for f in code) # rest of tree still enumerated
assert len(res["walk_errors"]) >= 1
assert "could not scan" in capsys.readouterr().err
def test_nested_gitignore_star_does_not_ignore_outside_its_dir(tmp_path):
"""A nested .gitignore containing a bare `*` (auto-written by e.g. the
hypothesis library into .hypothesis/) must ignore ONLY that directory's
contents matching it against root-relative paths ignored the entire
corpus (detect() returned 0 files on a real repo). Regression for #1873."""
(tmp_path / "README.md").write_text("# hello")
(tmp_path / "main.py").write_text("x = 1")
hyp = tmp_path / ".hypothesis"
hyp.mkdir()
(hyp / ".gitignore").write_text("*\n")
(hyp / "cached.py").write_text("y = 2")
result = detect(tmp_path)
assert result["total_files"] == 2 # README.md + main.py survive; .hypothesis/* ignored
def test_nested_gitignore_patterns_still_apply_inside_their_dir(tmp_path):
"""Counterpart guard: the anchor-scoped fix must not stop nested ignore
files from working WITHIN their own subtree."""
(tmp_path / "main.py").write_text("x = 1")
sub = tmp_path / "sub"
sub.mkdir()
(sub / ".gitignore").write_text("*.log\n")
(sub / "keep.py").write_text("y = 2")
(sub / "noise.log").write_text("z")
result = detect(tmp_path)
assert result["total_files"] == 2 # main.py + sub/keep.py; sub/noise.log ignored