diff --git a/src/basic_memory/ignore_utils.py b/src/basic_memory/ignore_utils.py index b06250163..8590d18fb 100644 --- a/src/basic_memory/ignore_utils.py +++ b/src/basic_memory/ignore_utils.py @@ -68,6 +68,21 @@ } +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.strip() + if not line or line.startswith("#"): + return None + if line.startswith("\\#"): + return line[1:] + return line + + def get_bmignore_path() -> Path: """Get path to .bmignore file. @@ -170,10 +185,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 +223,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..15fd0d138 100644 --- a/tests/cli/test_ignore_utils.py +++ b/tests/cli/test_ignore_utils.py @@ -6,6 +6,7 @@ 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 +331,35 @@ 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