From 7e955d223b28e6537c4e5ce6ad14ade135c9f0a3 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Fri, 24 Jul 2026 23:47:25 +0100 Subject: [PATCH] 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) --- graphify/detect.py | 4 +-- tests/test_detect.py | 86 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/graphify/detect.py b/graphify/detect.py index 62efad01..338b1695 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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)) diff --git a/tests/test_detect.py b/tests/test_detect.py index 2c6f9a73..e97ff5d4 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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()