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
1 change: 1 addition & 0 deletions src/basic_memory/index/local_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ async def resolve_permalink(
file_path: Path | str,
markdown: EntityMarkdown | None = None,
skip_conflict_check: bool = False,
current_file_path: str | None = None,
session: AsyncSession | None = None,
) -> str: ...

Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/index/local_moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ async def resolve_permalink(
file_path: Path | str,
markdown: EntityMarkdown | None = None,
skip_conflict_check: bool = False,
current_file_path: str | None = None,
session: AsyncSession | None = None,
) -> str: ...

Expand Down Expand Up @@ -125,6 +126,7 @@ async def plan_moved_file_content(
permalink = await self.entity_service.resolve_permalink(
Path(moved_file.new_path),
skip_conflict_check=True,
current_file_path=moved_file.old_path,
session=session,
)
if permalink == moved_file.old_permalink:
Expand Down
11 changes: 11 additions & 0 deletions src/basic_memory/index/note_content_materialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,17 @@ async def enqueue_note_file_delete(self, request: RuntimeNoteFileDeleteJobReques
file_path=request.file_path,
live_file_path=request.live_file_path,
)
# Trigger: the two spellings differ only by case.
# Why: the atomic write replaced the file's bytes through the existing
# directory entry, so the entry still carries the old casing; the
# next scan would read that as a move back and undo the rename (#1281).
# Outcome: rename the entry to the accepted casing so disk agrees with the
# row. On a case-sensitive filesystem the alias check above only
# passes for hard links, where a rename onto itself is a no-op.
if request.file_path != request.live_file_path and (
request.file_path.casefold() == request.live_file_path.casefold()
):
await self.storage.file_service.move_file(request.file_path, request.live_file_path)
# No separate source object exists: the old path aliases the live destination, so the
# move is already physically complete and its suppression marker must not outlive it.
if self.vacate_clearer is not None:
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/services/entity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,13 +298,15 @@ async def resolve_permalink(
file_path: Permalink | Path,
markdown: Optional[EntityMarkdown] = None,
skip_conflict_check: bool = False,
current_file_path: str | None = None,
session: AsyncSession | None = None,
) -> str:
"""Delegate permalink resolution to the shared preparation capability."""
return await self._note_preparation.resolve_permalink(
file_path,
markdown,
skip_conflict_check=skip_conflict_check,
current_file_path=current_file_path,
session=session,
)

Expand Down
22 changes: 19 additions & 3 deletions src/basic_memory/services/note_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,14 @@ async def resolve_permalink(
markdown: EntityMarkdown | None = None,
*,
skip_conflict_check: bool = False,
current_file_path: str | None = None,
session: AsyncSession | None = None,
) -> str:
"""Resolve the unique canonical permalink for one prepared note."""
"""Resolve the unique canonical permalink for one prepared note.

``current_file_path`` names the row being re-resolved (a move or rename), so a
permalink that row already owns is not treated as a collision with itself.
"""
file_path_str = Path(file_path).as_posix()
async with db.scoped_session(dependencies.session_maker, session) as active_session:
conflicts = await detect_file_path_conflicts(
Expand Down Expand Up @@ -240,7 +245,14 @@ async def resolve_permalink(

permalink = desired_permalink
suffix = 1
while await dependencies.entity_repository.permalink_exists(active_session, permalink):
while True:
owner = await dependencies.entity_repository.get_file_path_for_permalink(
active_session, permalink
)
# A case-only rename resolves to the slug the entity already holds;
# suffixing it would churn `config` -> `config-1` on every move (#1281).
if owner is None or owner == current_file_path:
break
permalink = f"{desired_permalink}-{suffix}"
suffix += 1
return permalink
Expand Down Expand Up @@ -815,7 +827,9 @@ async def prepare_move_entity_content(
update_permalinks_on_move or entity.permalink is None
)
if update_permalink:
permalink = await resolve_permalink(dependencies, file_path, session=session)
permalink = await resolve_permalink(
dependencies, file_path, current_file_path=entity.file_path, session=session
)
post = frontmatter.loads(markdown_content)
post.metadata["permalink"] = permalink
markdown_content = dump_frontmatter(post)
Expand Down Expand Up @@ -923,13 +937,15 @@ async def resolve_permalink(
file_path: Permalink | Path,
markdown: EntityMarkdown | None = None,
skip_conflict_check: bool = False,
current_file_path: str | None = None,
session: AsyncSession | None = None,
) -> str:
return await resolve_permalink(
self.dependencies,
file_path,
markdown,
skip_conflict_check=skip_conflict_check,
current_file_path=current_file_path,
session=session,
)

Expand Down
38 changes: 38 additions & 0 deletions tests/cloud/test_note_content_materialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -886,3 +886,41 @@ async def test_run_recovery_materialization_does_not_revert_newer_accepted_versi
assert row.db_version == 2
assert row.markdown_content == "# Newer accepted v2\n"
assert row.file_write_status == "writing"


def _filesystem_is_case_insensitive(directory) -> bool:
probe = directory / "CaseProbe.md"
probe.write_text("probe")
try:
return (directory / "caseprobe.md").exists()
finally:
probe.unlink()


@pytest.mark.asyncio
async def test_inline_delete_adopts_accepted_casing_for_case_only_rename(tmp_path) -> None:
"""A case-only rename must leave the directory entry spelled the accepted way (#1281).

The atomic write replaces bytes through the existing entry, so the entry keeps
the old casing; without an explicit rename the next scan reads it as a move
back and the rename silently never happens.
"""
if not _filesystem_is_case_insensitive(tmp_path):
pytest.skip("requires a case-insensitive filesystem")
content = b"# Config\n"
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "config.md").write_bytes(content)

enqueuer = InlineNoteFileDeleteEnqueuer(LocalNoteContentStorage(FileService(tmp_path)))
await enqueuer.enqueue_note_file_delete(
RuntimeNoteFileDeleteJobRequest(
project_id=1,
entity_id=7,
file_path="docs/config.md",
file_checksum=sha256(content).hexdigest(),
live_file_path="docs/Config.md",
)
)

assert [p.name for p in (tmp_path / "docs").iterdir()] == ["Config.md"]
assert (tmp_path / "docs" / "Config.md").read_bytes() == content
1 change: 1 addition & 0 deletions tests/index/test_local_move_content_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async def resolve_permalink(
file_path: Path | str,
markdown: EntityMarkdown | None = None,
skip_conflict_check: bool = False,
current_file_path: str | None = None,
session: AsyncSession | None = None,
) -> str:
return self.permalink
Expand Down
29 changes: 29 additions & 0 deletions tests/utils/test_permalink_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,32 @@ async def test_entity_service_workspace_permalink_uses_project_when_prefixes_dis

project_permalink = generate_permalink(project_config.name)
assert permalink == f"team-paul/{project_permalink}/team/no-project-prefix-service"


@pytest.mark.asyncio
async def test_resolve_permalink_keeps_the_permalink_the_entity_already_owns(
entity_service: EntityService,
project_config: ProjectConfig,
):
"""Re-resolving a row to its own slug (a case-only rename) must not suffix it (#1281)."""
from basic_memory.models import Entity
from basic_memory import db

owned = f"{generate_permalink(project_config.name)}/docs/config"
async with db.scoped_session(entity_service.session_maker) as session:
session.add(
Entity(
title="Config",
note_type="note",
file_path="docs/config.md",
permalink=owned,
content_type="text/markdown",
project_id=entity_service.repository.project_id,
)
)

assert await entity_service.resolve_permalink("docs/Config.md") == f"{owned}-1"
assert (
await entity_service.resolve_permalink("docs/Config.md", current_file_path="docs/config.md")
== owned
)
Loading