fix(detect): honor ignore files saved with a UTF-8 BOM (#2163)

.gitignore/.graphifyignore/info-exclude read with encoding=utf-8 kept a
leading BOM (U+FEFF) on the first line, so the first pattern (e.g. *.log
or .fable-wt/) silently matched nothing and a BOM'd full-line comment
became a bogus pattern. git strips a single leading BOM; switching the
two ignore read sites to utf-8-sig matches git exactly (strips at most
one, file-start only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-24 23:47:25 +01:00
co-authored by Claude Opus 4.8
parent 0019fc4d90
commit 7e955d223b
2 changed files with 88 additions and 2 deletions
+2 -2
View File
@@ -970,7 +970,7 @@ def _load_dir_own_ignore(d: Path, *, gitignore: bool = True) -> list[tuple[Path,
for fname in ((".gitignore", ".graphifyignore") if gitignore else (".graphifyignore",)):
ignore_file = d / fname
if ignore_file.exists():
for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
for raw in ignore_file.read_text(encoding="utf-8-sig", errors="ignore").splitlines():
line = _parse_gitignore_line(raw)
if line:
patterns.append((d, line))
@@ -1012,7 +1012,7 @@ def _load_graphifyignore(root: Path, *, gitignore: bool = True) -> list[tuple[Pa
# re-include still override it (#1810).
info_exclude = _git_info_exclude(ceiling) if gitignore else None
if info_exclude is not None:
for raw in info_exclude.read_text(encoding="utf-8", errors="ignore").splitlines():
for raw in info_exclude.read_text(encoding="utf-8-sig", errors="ignore").splitlines():
line = _parse_gitignore_line(raw)
if line:
patterns.append((ceiling, line))
+86
View File
@@ -136,6 +136,92 @@ def test_graphifyignore_comments_ignored(tmp_path):
assert any("other.py" in f for f in result["files"]["code"])
def test_graphifyignore_utf8_bom_first_pattern_honored(tmp_path):
"""A UTF-8 BOM at the start of .graphifyignore must not corrupt the first
pattern (#2163): git strips a single leading BOM, so `*.log` on line 1
must still exclude app.log."""
(tmp_path / ".graphifyignore").write_bytes(b"\xef\xbb\xbf*.log\nbuild/\n")
build = tmp_path / "build"
build.mkdir()
(build / "lib.py").write_text("x = 1")
(tmp_path / "app.log").write_text("log line")
(tmp_path / "main.py").write_text("print('hi')")
result = detect(tmp_path)
all_files = [f for files in result["files"].values() for f in files]
assert not any("app.log" in f for f in all_files), "BOM'd first pattern was dropped"
assert not any("build" in f for f in all_files)
assert any("main.py" in f for f in all_files)
assert result["graphifyignore_patterns"] == 2
def test_gitignore_utf8_bom_matches_git(tmp_path):
"""A BOM'd .gitignore first pattern must match, exactly like git (#2163)."""
(tmp_path / ".gitignore").write_bytes(b"\xef\xbb\xbf*.log\n")
(tmp_path / "app.log").write_text("log line")
(tmp_path / "main.py").write_text("print('hi')")
result = detect(tmp_path)
all_files = [f for files in result["files"].values() for f in files]
assert not any("app.log" in f for f in all_files)
assert any("main.py" in f for f in all_files)
def test_graphifyignore_bom_only_file(tmp_path):
"""A .graphifyignore containing only a BOM yields zero patterns, not one
bogus U+FEFF pattern (#2163)."""
(tmp_path / ".graphifyignore").write_bytes(b"\xef\xbb\xbf")
(tmp_path / "main.py").write_text("x = 1")
result = detect(tmp_path)
assert result["graphifyignore_patterns"] == 0
assert any("main.py" in f for f in result["files"]["code"])
def test_graphifyignore_bom_then_comment(tmp_path):
"""A BOM followed by a comment must still parse as a comment, not become
a `\\ufeff# comment` pattern (#2163)."""
(tmp_path / ".graphifyignore").write_bytes(b"\xef\xbb\xbf# comment\nmain.py\n")
(tmp_path / "main.py").write_text("x = 1")
(tmp_path / "other.py").write_text("x = 2")
result = detect(tmp_path)
assert not any("main.py" in f for f in result["files"]["code"])
assert any("other.py" in f for f in result["files"]["code"])
assert result["graphifyignore_patterns"] == 1, "BOM'd comment became a pattern"
def test_nested_gitignore_utf8_bom(tmp_path):
"""A BOM'd .gitignore below the scan root (loaded live during the walk,
#1206 path) must also have its first pattern honored (#2163)."""
sub = tmp_path / "sub"
sub.mkdir()
(sub / ".gitignore").write_bytes(b"\xef\xbb\xbf*.log\n")
(sub / "app.log").write_text("log line")
(sub / "keep.py").write_text("x = 1")
result = detect(tmp_path)
all_files = [f for files in result["files"].values() for f in files]
assert not any("app.log" in f for f in all_files)
assert any("keep.py" in f for f in all_files)
def test_git_info_exclude_utf8_bom(tmp_path):
"""A BOM at the start of $GIT_DIR/info/exclude must not corrupt the first
pattern either (#2163) — second read site in _load_graphifyignore."""
(tmp_path / ".git" / "info").mkdir(parents=True)
(tmp_path / ".git" / "info" / "exclude").write_bytes(b"\xef\xbb\xbfsecrets/\n")
secrets = tmp_path / "secrets"
secrets.mkdir()
(secrets / "x.py").write_text("token = 'x'")
(tmp_path / "real.py").write_text("def real(): pass")
result = detect(tmp_path)
all_files = [f for files in result["files"].values() for f in files]
assert not any("secrets" in f for f in all_files), "BOM'd info/exclude pattern was dropped"
assert any("real.py" in f for f in all_files)
def test_detect_follows_symlinked_directory(tmp_path):
real_dir = tmp_path / "real_lib"
real_dir.mkdir()