Skip to content

Commit c5dc3f5

Browse files
committed
fix(cli): honor strict ignore rules and resource title links
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent b71487e commit c5dc3f5

5 files changed

Lines changed: 110 additions & 6 deletions

File tree

src/basic_memory/ignore_utils.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ def load_bmignore_patterns() -> Set[str]:
201201
return patterns
202202

203203

204-
def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[str]:
204+
def load_gitignore_patterns(
205+
base_path: Path, use_gitignore: bool = True, *, strict: bool = False
206+
) -> Set[str]:
205207
"""Load gitignore patterns from .gitignore file and .bmignore.
206208
207209
Combines patterns from:
@@ -212,10 +214,30 @@ def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[
212214
Args:
213215
base_path: The base directory to search for .gitignore file
214216
use_gitignore: If False, only load patterns from .bmignore (default: True)
217+
strict: Read without creating files; only missing ignore files permit defaults.
215218
216219
Returns:
217220
Set of patterns to ignore
218221
"""
222+
if strict:
223+
# Publishing a snapshot must never silently include files excluded by unreadable rules.
224+
patterns: set[str] = set()
225+
bmignore_path = get_bmignore_path()
226+
paths = [bmignore_path, base_path / ".gitignore"] if use_gitignore else [bmignore_path]
227+
for path in paths:
228+
try:
229+
content = path.read_text(encoding="utf-8")
230+
except FileNotFoundError:
231+
content = ""
232+
patterns.update(
233+
pattern
234+
for line in content.splitlines()
235+
if (pattern := _parse_ignore_pattern_line(line)) is not None
236+
)
237+
if path == bmignore_path and not patterns:
238+
patterns.update(DEFAULT_IGNORE_PATTERNS)
239+
return patterns
240+
219241
# Start with patterns from .bmignore
220242
patterns = load_bmignore_patterns()
221243

src/basic_memory/okf/export.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,14 @@ async def recorded_history(
7878

7979

8080
def snapshot_files(root: Path) -> tuple[ExportFile, ...]:
81+
from basic_memory.ignore_utils import load_gitignore_patterns
8182
from basic_memory.index.local_project import scan_local_project_index_files
8283
from basic_memory.index.local_wiki_projection import _is_projector_owned
8384
from basic_memory.runtime.storage import runtime_file_path_is_markdown_note
8485

85-
scan = scan_local_project_index_files(root, strict=True)
86+
scan = scan_local_project_index_files(
87+
root, ignore_patterns=load_gitignore_patterns(root, strict=True), strict=True
88+
)
8689
if scan.unreadable_directories:
8790
raise OSError("Incomplete project scan: " + ", ".join(scan.unreadable_directories))
8891
files = []

src/basic_memory/okf/render.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,6 @@ def render_bundle(snapshot: ExportSnapshot) -> tuple[ExportFile, ...]:
286286
if note_type is not None
287287
else "note"
288288
)
289-
if title in title_targets and title_targets[title] != file.path:
290-
ambiguous.add(title)
291-
else:
292-
title_targets[title] = file.path
293289
permalink = normalize_frontmatter_value(source_metadata.get("permalink"))
294290
if not isinstance(permalink, str) or not permalink:
295291
permalink = build_canonical_permalink(
@@ -303,6 +299,13 @@ def render_bundle(snapshot: ExportSnapshot) -> tuple[ExportFile, ...]:
303299
f"{permalinks[permalink]}"
304300
)
305301
permalinks[permalink] = file.path
302+
# Resources use their full filename as the canonical indexed title.
303+
for file in snapshot.files:
304+
title = titles.get(file.path, PurePosixPath(file.path).name)
305+
if title in title_targets and title_targets[title] != file.path:
306+
ambiguous.add(title)
307+
else:
308+
title_targets[title] = file.path
306309
for alias in ambiguous:
307310
title_targets.pop(alias)
308311
# Relative path resolution uses exact file identities, separate from semantic names.

tests/okf/test_export_failures.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,55 @@ async def test_exact_project_name_precedes_normalized_alias(source_config, tmp_p
382382
assert report.success and report.concepts == 1
383383
assert (destination / "chosen.md").is_file()
384384
assert not (destination / "a.md").exists()
385+
386+
387+
@pytest.mark.asyncio
388+
@pytest.mark.parametrize("ignore_name", [".gitignore", ".bmignore"])
389+
async def test_unreadable_ignore_file_aborts_publication(
390+
source_config, tmp_path, monkeypatch, ignore_name
391+
):
392+
from basic_memory.ignore_utils import get_bmignore_path
393+
394+
root = Path(source_config.projects["export"].path)
395+
ignore = root / ignore_name if ignore_name == ".gitignore" else get_bmignore_path()
396+
ignore.parent.mkdir(parents=True, exist_ok=True)
397+
ignore.write_text("credentials.json\n", encoding="utf-8")
398+
(root / "credentials.json").write_text("excluded bytes", encoding="utf-8")
399+
destination = tmp_path / "bundle"
400+
destination.mkdir()
401+
(destination / "keep").write_bytes(b"previous bundle")
402+
original_read = Path.read_text
403+
404+
def denied_read(path, *args, **kwargs):
405+
if path == ignore:
406+
raise PermissionError(13, "ignore rules unreadable", str(path))
407+
return original_read(path, *args, **kwargs)
408+
409+
monkeypatch.setattr(Path, "read_text", denied_read)
410+
with pytest.raises(PermissionError, match="ignore rules unreadable"):
411+
await export_project(source_config, "export", destination, replace=True)
412+
assert (destination / "keep").read_bytes() == b"previous bundle"
413+
monkeypatch.setattr(Path, "read_text", original_read)
414+
assert (await export_project(source_config, "export", destination, replace=True)).success
415+
assert not (destination / "credentials.json").exists()
416+
417+
418+
@pytest.mark.parametrize("empty_bmignore", [False, True])
419+
def test_strict_ignore_defaults_do_not_create_files(tmp_path, monkeypatch, empty_bmignore):
420+
from basic_memory.ignore_utils import (
421+
DEFAULT_IGNORE_PATTERNS,
422+
create_default_bmignore,
423+
load_gitignore_patterns,
424+
)
425+
426+
bmignore = tmp_path / ".bmignore"
427+
monkeypatch.setattr("basic_memory.ignore_utils.get_bmignore_path", lambda: bmignore)
428+
if empty_bmignore:
429+
bmignore.write_text("# no custom rules\n", encoding="utf-8")
430+
assert load_gitignore_patterns(tmp_path, use_gitignore=False, strict=True) == (
431+
DEFAULT_IGNORE_PATTERNS
432+
)
433+
assert bmignore.exists() is empty_bmignore
434+
if empty_bmignore:
435+
create_default_bmignore()
436+
assert bmignore.read_text(encoding="utf-8") == "# no custom rules\n"

tests/okf/test_okf.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,3 +861,27 @@ def test_network_path_wikilinks_remain_literal():
861861
assert convert_wikilinks(body, "source.md", {"a.md": "a.md"}, "p") == (
862862
"[[//example.com/a]] [[//example.com/a|alias]] [/a.md](/a.md)"
863863
)
864+
865+
866+
@pytest.mark.parametrize("duplicate", [False, True])
867+
def test_nested_resource_titles_resolve_with_ambiguity_handling(test_project, duplicate):
868+
from basic_memory.models import Entity
869+
from basic_memory.services.bulk_link_resolver import ProjectEntityIdentityIndex
870+
871+
owner = Entity(title="paper.pdf", file_path="refs/paper.pdf")
872+
entities = [owner]
873+
files = [ExportFile("refs/paper.pdf", b"pdf"), ExportFile("source.md", b"[[paper.pdf]]")]
874+
if duplicate:
875+
entities.append(Entity(title="paper.pdf", file_path="other/paper.pdf"))
876+
files.append(ExportFile("other/paper.pdf", b"other pdf"))
877+
index = ProjectEntityIdentityIndex.from_entities(test_project, entities)
878+
resolved = index.resolve_strict(
879+
"paper.pdf", include_project_permalinks=True, workspace_permalink=None
880+
).entity
881+
assert resolved is (None if duplicate else owner)
882+
source = next(
883+
file.content
884+
for file in render_bundle(ExportSnapshot("p", tuple(files)))
885+
if file.path == "source.md"
886+
)
887+
assert (b"[paper.pdf](/paper.pdf)" if duplicate else b"[paper.pdf](/refs/paper.pdf)") in source

0 commit comments

Comments
 (0)