Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions src/basic_memory/ignore_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Utilities for handling .gitignore patterns and file filtering."""

import fnmatch
import re
from pathlib import Path
from typing import Set

Expand Down Expand Up @@ -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
Comment thread
phernandez marked this conversation as resolved.


def get_bmignore_path() -> Path:
"""Get path to .bmignore file.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions tests/cli/test_ignore_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Loading