Skip to content

Commit d97b053

Browse files
committed
fix(cli): include generated OKF identities and reject stage collisions
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 4e38727 commit d97b053

4 files changed

Lines changed: 54 additions & 4 deletions

File tree

src/basic_memory/okf/export.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ async def export_project(
154154
for file in rendered:
155155
target = staging / file.path
156156
target.parent.mkdir(parents=True, exist_ok=True)
157-
target.write_bytes(file.content)
157+
# Destination filesystems may collapse distinct source spellings.
158+
# Exclusive creation detects collisions before any bundle is published.
159+
with target.open("xb") as output:
160+
output.write(file.content)
158161
report = check_bundle(staging)
159162
if not report.success:
160163
return report

src/basic_memory/okf/render.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
from basic_memory.repository.entity_repository import file_path_alias
2121
from basic_memory.services.bulk_link_resolver import RelationTargetReference
2222
from basic_memory.services.link_resolver import normalize_link_text
23-
from basic_memory.utils import build_permalink_resolution_candidates, generate_permalink
23+
from basic_memory.utils import (
24+
build_canonical_permalink,
25+
build_permalink_resolution_candidates,
26+
generate_permalink,
27+
)
2428

2529
from basic_memory.okf.validation import Document, parse_document
2630

@@ -279,6 +283,10 @@ def render_bundle(snapshot: ExportSnapshot) -> tuple[ExportFile, ...]:
279283
else:
280284
title_targets[title] = file.path
281285
permalink = normalize_frontmatter_value(source_metadata.get("permalink"))
286+
if not isinstance(permalink, str) or not permalink:
287+
permalink = build_canonical_permalink(
288+
snapshot.project, file.path, include_project=snapshot.permalinks_include_project
289+
)
282290
if isinstance(permalink, str) and permalink:
283291
# Offline files can violate the indexed uniqueness rule; never choose a winner.
284292
if permalink in permalinks:

tests/okf/test_export_failures.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,28 @@ def failing_lstat(path, *args, **kwargs):
301301
with pytest.raises(PermissionError, match="stat unavailable"):
302302
await export_project(source_config, "export", destination, replace=True)
303303
assert (destination / "keep").read_bytes() == b"previous bundle"
304+
305+
306+
@pytest.mark.asyncio
307+
async def test_staging_path_collision_preserves_destination(source_config, tmp_path, monkeypatch):
308+
import basic_memory.okf.export as exporting
309+
310+
destination = tmp_path / "bundle"
311+
destination.mkdir()
312+
(destination / "keep").write_bytes(b"previous bundle")
313+
original_open = Path.open
314+
315+
def case_insensitive_staging_open(path, mode="r", *args, **kwargs):
316+
if mode == "xb":
317+
path = path.with_name(path.name.lower())
318+
return original_open(path, mode, *args, **kwargs)
319+
320+
monkeypatch.setattr(Path, "open", case_insensitive_staging_open)
321+
monkeypatch.setattr(
322+
exporting,
323+
"render_bundle",
324+
lambda snapshot: (ExportFile("A.md", b"first"), ExportFile("a.md", b"second")),
325+
)
326+
with pytest.raises(FileExistsError):
327+
await export_project(source_config, "export", destination, replace=True)
328+
assert (destination / "keep").read_bytes() == b"previous bundle"

tests/okf/test_okf.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ def test_unique_title_precedes_filename_and_rooted_links_remain_literal(test_pro
693693

694694
owner = Entity(title="foo.md", file_path="owner.md")
695695
index = ProjectEntityIdentityIndex.from_entities(
696-
test_project, [Entity(title="foo", file_path="foo.md"), owner]
696+
test_project, [Entity(title="foo", file_path="foo.md", permalink="custom"), owner]
697697
)
698698
assert (
699699
index.resolve_strict(
@@ -704,7 +704,7 @@ def test_unique_title_precedes_filename_and_rooted_links_remain_literal(test_pro
704704
snapshot = ExportSnapshot(
705705
"p",
706706
(
707-
ExportFile("foo.md", b"# File"),
707+
ExportFile("foo.md", b"---\npermalink: custom\n---\n# File"),
708708
ExportFile("owner.md", b"---\ntitle: foo.md\n---\n# Title owner"),
709709
ExportFile("source.md", b"[[foo.md]] [[/foo]] [[/foo.md]]"),
710710
),
@@ -824,3 +824,17 @@ def test_unordered_identity_metadata_fails_explicitly(field, value):
824824
snapshot = ExportSnapshot("p", (ExportFile("a.md", f"---\n{field}: {value}\n---\n".encode()),))
825825
with pytest.raises(ValueError, match=f"a.md: {field} cannot contain an unordered YAML set"):
826826
render_bundle(snapshot)
827+
828+
829+
@pytest.mark.parametrize("include_project", [False, True])
830+
def test_missing_authored_permalink_uses_canonical_generated_address(include_project):
831+
snapshot = ExportSnapshot(
832+
"p",
833+
(
834+
ExportFile("folder/foo.md", b"# Foo"),
835+
ExportFile("source.md", b"[[p/folder/foo]]"),
836+
),
837+
permalinks_include_project=include_project,
838+
)
839+
source = next(file.content for file in render_bundle(snapshot) if file.path == "source.md")
840+
assert b"[p/folder/foo](/folder/foo.md)" in source

0 commit comments

Comments
 (0)