diff --git a/graphify/detect.py b/graphify/detect.py index 338449291..f739b677a 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -272,17 +272,41 @@ def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]: parent directory still work when graphify is run on a subfolder. Walks upward from *root* towards the filesystem root, stopping at a - ``.git`` boundary. Lines starting with # are comments; blank lines ignored. + ``.git`` boundary. + + Comment handling (gitignore extension): + * Lines starting with # are full-line comments — skipped. + * Inline comments (whitespace + hash to end of line) are stripped + from each pattern. This matches user intuition for documenting + patterns inline, e.g. `chitta/varta/ # daily briefings`. + * Use ``\\#`` to keep a literal hash in a pattern (rare). + * Blank lines after stripping are ignored. """ patterns: list[tuple[Path, str]] = [] current = root.resolve() while True: ignore_file = current / ".graphifyignore" if ignore_file.exists(): - for line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines(): - line = line.strip() - if line and not line.startswith("#"): - patterns.append((current, line)) + for raw_line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + # Strip inline comment (whitespace + '#' to end of line), + # preserve escaped backslash-# as literal hash in pattern. + pieces = [] + i = 0 + while i < len(line): + if line[i] == "\\" and i + 1 < len(line) and line[i + 1] == "#": + pieces.append("#") + i += 2 + continue + if line[i].isspace() and i + 1 < len(line) and line[i + 1] == "#": + break + pieces.append(line[i]) + i += 1 + pattern = "".join(pieces).rstrip() + if pattern: + patterns.append((current, pattern)) # Stop climbing once we've processed the git repo root if (current / ".git").exists(): break diff --git a/tests/test_detect.py b/tests/test_detect.py index ed43fea2b..b365c7940 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -114,6 +114,57 @@ def test_graphifyignore_comments_ignored(tmp_path): assert any("other.py" in f for f in result["files"]["code"]) +def test_graphifyignore_inline_comments_stripped(tmp_path): + """Inline comments (whitespace + '#' to end of line) are stripped from + pattern lines, so `vendor/ # legacy code` ignores the `vendor/` directory + rather than treating the entire line including the comment as the pattern. + """ + (tmp_path / ".graphifyignore").write_text( + "vendor/ # legacy code\n" + "*.generated.py # auto-generated, skip\n" + "main.py\n" + ) + (tmp_path / "vendor").mkdir() + (tmp_path / "vendor" / "lib.py").write_text("x = 1") + (tmp_path / "auto.generated.py").write_text("x = 2") + (tmp_path / "main.py").write_text("x = 3") + (tmp_path / "other.py").write_text("x = 4") + result = detect(tmp_path) + assert not any("lib.py" in f for f in result["files"]["code"]), ( + "vendor/ should be ignored after stripping inline comment" + ) + assert not any("auto.generated.py" in f for f in result["files"]["code"]), ( + "*.generated.py should match after stripping inline comment" + ) + 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"]) + + +def test_graphifyignore_inline_comment_requires_whitespace_before_hash(tmp_path): + """A '#' without preceding whitespace is part of the pattern, not a comment. + Standard gitignore semantics — `path#name` matches a file literally named + `path#name`, not a comment. + """ + (tmp_path / ".graphifyignore").write_text("file#with#hash.py\n") + (tmp_path / "file#with#hash.py").write_text("x = 1") + (tmp_path / "other.py").write_text("x = 2") + result = detect(tmp_path) + assert not any("file#with#hash.py" in f for f in result["files"]["code"]) + assert any("other.py" in f for f in result["files"]["code"]) + + +def test_graphifyignore_escaped_hash_is_literal(tmp_path): + r"""Backslash-hash (``\#``) keeps a literal '#' in the pattern even after + whitespace, escaping the inline-comment marker. + """ + (tmp_path / ".graphifyignore").write_text("file \\# name.py\n") + (tmp_path / "file # name.py").write_text("x = 1") + (tmp_path / "other.py").write_text("x = 2") + result = detect(tmp_path) + assert not any("file # name.py" in f for f in result["files"]["code"]) + assert any("other.py" in f for f in result["files"]["code"]) + + def test_detect_follows_symlinked_directory(tmp_path): real_dir = tmp_path / "real_lib" real_dir.mkdir()