From 6d47871a6a8625c53da5705e6f8a2fffe5a03a16 Mon Sep 17 00:00:00 2001 From: wangzhengzhuo05 <175673456+wangzhengzhuo05@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:09:34 +0800 Subject: [PATCH 1/2] fix(core): honor the gitignore \# escape when loading ignore patterns .bmignore documents gitignore-style syntax, but the loader skipped any line beginning with '#' and never unescaped a leading backslash, so no pattern beginning with a hash could take effect: '#*#' was dropped as a comment and '\#*#' was stored literally and never matched. Strip one leading backslash when a line starts with '\#', for both .bmignore and project .gitignore, so e.g. '\#*#' loads as '#*#' (Emacs autosave files). Fixes #1539 Signed-off-by: wangzhengzhuo05 <175673456+wangzhengzhuo05@users.noreply.github.com> --- src/basic_memory/ignore_utils.py | 29 ++++++++++++++++++++-------- tests/cli/test_ignore_utils.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) 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 From ee2f8545bc57f49ae3278bef815dc3a9b019cf9f Mon Sep 17 00:00:00 2001 From: phernandez Date: Sun, 13 Sep 2026 22:30:03 -0500 Subject: [PATCH 2/2] fix(core): preserve significant whitespace in ignore patterns Signed-off-by: phernandez --- src/basic_memory/ignore_utils.py | 10 ++++---- tests/cli/test_ignore_utils.py | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/ignore_utils.py b/src/basic_memory/ignore_utils.py index 8590d18fb..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 @@ -75,12 +76,13 @@ def _parse_ignore_pattern_line(raw_line: str) -> str | None: escapes a pattern that itself begins with ``#`` (gitignore rule), so ``\\#*#`` yields the pattern ``#*#``. """ - line = raw_line.strip() + line = raw_line.rstrip("\r\n") if not line or line.startswith("#"): return None - if line.startswith("\\#"): - return line[1:] - return line + # 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: diff --git a/tests/cli/test_ignore_utils.py b/tests/cli/test_ignore_utils.py index 15fd0d138..f0f54ecbc 100644 --- a/tests/cli/test_ignore_utils.py +++ b/tests/cli/test_ignore_utils.py @@ -3,6 +3,8 @@ import tempfile from pathlib import Path +import pytest + from basic_memory.ignore_utils import ( DEFAULT_IGNORE_PATTERNS, get_bmignore_path, @@ -363,3 +365,40 @@ def test_gitignore_escaped_hash_pattern_loads(tmp_path, monkeypatch): 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)