diff --git a/src/basic_memory/ignore_utils.py b/src/basic_memory/ignore_utils.py index b06250163..c3386970c 100644 --- a/src/basic_memory/ignore_utils.py +++ b/src/basic_memory/ignore_utils.py @@ -1,6 +1,7 @@ """Utilities for handling .gitignore patterns and file filtering.""" import fnmatch +import re from pathlib import Path from typing import Set @@ -68,6 +69,22 @@ } +def _parse_ignore_pattern_line(raw_line: str) -> str | None: + """Return the pattern for one gitignore-style line, or None to skip it. + + Blank lines and comments (leading ``#``) are skipped. A leading backslash + escapes a pattern that itself begins with ``#`` (gitignore rule), so + ``\\#*#`` yields the pattern ``#*#``. + """ + line = raw_line.rstrip("\r\n") + if not line or line.startswith("#"): + return None + # fnmatch has no backslash escapes. Decode literal hashes, spaces, and + # backslashes together so escaped trailing spaces survive, while unescaped + # trailing spaces are discarded without changing significant leading ones. + return re.sub(r"\\([\\ #])| +$", lambda match: match[1] or "", line) or None + + def get_bmignore_path() -> Path: """Get path to .bmignore file. @@ -170,10 +187,9 @@ def load_bmignore_patterns() -> Set[str]: try: with bmignore_path.open("r", encoding="utf-8") as f: for line in f: - line = line.strip() - # Skip empty lines and comments - if line and not line.startswith("#"): - patterns.add(line) + pattern = _parse_ignore_pattern_line(line) + if pattern: + patterns.add(pattern) except Exception: # pragma: no cover # If we can't read .bmignore, fall back to defaults return set(DEFAULT_IGNORE_PATTERNS) # pragma: no cover @@ -209,10 +225,9 @@ def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[ try: with gitignore_file.open("r", encoding="utf-8") as f: for line in f: - line = line.strip() - # Skip empty lines and comments - if line and not line.startswith("#"): - patterns.add(line) + pattern = _parse_ignore_pattern_line(line) + if pattern: + patterns.add(pattern) except Exception: # If we can't read .gitignore, just use default patterns pass diff --git a/tests/cli/test_ignore_utils.py b/tests/cli/test_ignore_utils.py index c86146e41..f0f54ecbc 100644 --- a/tests/cli/test_ignore_utils.py +++ b/tests/cli/test_ignore_utils.py @@ -3,9 +3,12 @@ import tempfile from pathlib import Path +import pytest + from basic_memory.ignore_utils import ( DEFAULT_IGNORE_PATTERNS, get_bmignore_path, + load_bmignore_patterns, load_gitignore_patterns, should_ignore_path, filter_files, @@ -330,3 +333,72 @@ def test_filter_files_with_gitignore_loading(): assert len(filtered_files) == 2 assert set(filtered_files) == set(expected_kept) assert ignored_count == 2 # debug.log, temp_file.txt + + +def test_bmignore_escaped_hash_pattern_loads(tmp_path, monkeypatch): + """Regression for #1539: an escaped hash pattern loads without the backslash.""" + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path)) + (tmp_path / ".bmignore").write_text("# a comment\n\\#*#\n*~\n") + + patterns = load_bmignore_patterns() + + assert "#*#" in patterns + assert "\\#*#" not in patterns + assert "# a comment" not in patterns + assert should_ignore_path(tmp_path / "notes" / "#note.md#", tmp_path, patterns) is True + + +def test_bmignore_bare_hash_is_still_a_comment(tmp_path, monkeypatch): + """A bare leading hash is still a comment, so it never becomes a pattern.""" + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path)) + (tmp_path / ".bmignore").write_text("#*#\n") + + assert "#*#" not in load_bmignore_patterns() + + +def test_gitignore_escaped_hash_pattern_loads(tmp_path, monkeypatch): + """Same \\# escape fix applies to project .gitignore files.""" + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path)) + (tmp_path / ".gitignore").write_text("\\#*#\n") + + patterns = load_gitignore_patterns(tmp_path) + + assert "#*#" in patterns + assert should_ignore_path(tmp_path / "#note.md#", tmp_path, patterns) is True + + +@pytest.mark.parametrize("ignore_file", [".bmignore", ".gitignore"]) +@pytest.mark.parametrize( + ("raw_pattern", "matching_name", "nonmatching_name"), + [ + (r"\#foo", "#foo", " #foo"), + (r" \#foo", " #foo", "#foo"), + (" #foo", " #foo", "#foo"), + (" foo", " foo", "foo"), + ("\tfoo", "\tfoo", "foo"), + ("foo\t", "foo\t", "foo"), + ("\\#foo ", "#foo", "#foo "), + (" \\#foo ", " #foo", "#foo"), + ("foo\\ ", "foo ", "foo"), + ("foo\\ ", "foo ", "foo "), + ("foo\\ \\ ", "foo ", "foo "), + ], +) +def test_ignore_pattern_whitespace_and_hashes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ignore_file: str, + raw_pattern: str, + matching_name: str, + nonmatching_name: str, +) -> None: + """Both loaders preserve Git's significant whitespace through matching.""" + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(tmp_path)) + (tmp_path / ".bmignore").write_text("*~\n") + (tmp_path / ignore_file).write_text(f"# comment\n \n{raw_pattern}\r\n*~\n") + + patterns = load_gitignore_patterns(tmp_path) + + assert patterns == {matching_name, "*~"} + assert should_ignore_path(tmp_path / matching_name, tmp_path, patterns) + assert not should_ignore_path(tmp_path / nonmatching_name, tmp_path, patterns)