diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 97ac5237..e718a8aa 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -231,6 +231,11 @@ class BasicMemoryConfig(BaseSettings): description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.", ) + ensure_frontmatter_on_sync: bool = Field( + default=False, + description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.", + ) + permalinks_include_project: bool = Field( default=True, description="When True, generated permalinks are prefixed with the project slug (e.g., 'specs/search'). Existing permalinks remain unchanged unless explicitly updated.", diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index 059eaa6f..54bef2d4 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -174,6 +174,16 @@ async def initialize_app( Args: app_config: The Basic Memory project configuration """ + # Trigger: frontmatter enforcement is enabled while permalink generation is disabled + # Why: missing-frontmatter sync path needs canonical permalinks for deterministic indexing + # Outcome: log startup precedence so behavior is explicit to operators + if app_config.ensure_frontmatter_on_sync and app_config.disable_permalinks: + logger.warning( + "Config precedence: ensure_frontmatter_on_sync=True overrides " + "disable_permalinks=True for markdown files missing frontmatter during sync; " + "permalinks will be written." + ) + # Trigger: database backend is Postgres (cloud deployment) # Why: cloud deployments manage their own projects and migrations via the cloud platform. # The local MCP server always uses SQLite and needs initialization even when diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index 0287650a..faa75982 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -669,6 +669,21 @@ class SyncService: ctime=file_metadata.created_at.timestamp(), ) + # Trigger: markdown file has no frontmatter and frontmatter enforcement is enabled + # Why: watch/sync consumers rely on normalized metadata and stable permalinks + # Outcome: file is updated in-place with derived title/type/permalink metadata + if not file_contains_frontmatter and self.app_config.ensure_frontmatter_on_sync: + permalink = await self.entity_service.resolve_permalink( + path, markdown=entity_markdown, skip_conflict_check=True + ) + frontmatter_updates = { + "title": entity_markdown.frontmatter.title, + "type": entity_markdown.frontmatter.type, + "permalink": permalink, + } + await self.file_service.update_frontmatter(path, frontmatter_updates) + entity_markdown.frontmatter.metadata.update(frontmatter_updates) + # if the file contains frontmatter, resolve a permalink (unless disabled) if file_contains_frontmatter and not self.app_config.disable_permalinks: # Resolve permalink - skip conflict checks during bulk sync for performance diff --git a/test-int/cli/test_project_commands_integration.py b/test-int/cli/test_project_commands_integration.py index 7d4efbe4..1317b55d 100644 --- a/test-int/cli/test_project_commands_integration.py +++ b/test-int/cli/test_project_commands_integration.py @@ -7,11 +7,13 @@ from typer.testing import CliRunner from basic_memory.cli.main import app as cli_app +WIDE_TERMINAL_ENV = {"COLUMNS": "240", "LINES": "60"} + def test_project_list(app, app_config, test_project, config_manager): """Test 'bm project list' command shows projects.""" runner = CliRunner() - result = runner.invoke(cli_app, ["project", "list"]) + result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV) if result.exit_code != 0: print(f"STDOUT: {result.stdout}") @@ -77,7 +79,7 @@ def test_project_add_and_remove(app, app_config, config_manager): ) # Verify it shows up in list - result = runner.invoke(cli_app, ["project", "list"]) + result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV) assert result.exit_code == 0 assert "new-project" in result.stdout @@ -114,7 +116,7 @@ def test_project_set_default(app, app_config, config_manager): assert "default" in result.stdout.lower() # Verify in list - result = runner.invoke(cli_app, ["project", "list"]) + result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV) assert result.exit_code == 0 # The new project should have the [X] marker now lines = result.stdout.split("\n") @@ -136,14 +138,14 @@ def test_remove_main_project(app, app_config, config_manager): new_default_path = Path(new_default_dir) # Ensure main exists - result = runner.invoke(cli_app, ["project", "list"]) + result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV) if "main" not in result.stdout: result = runner.invoke(cli_app, ["project", "add", "main", str(main_path)]) print(result.stdout) assert result.exit_code == 0 # Confirm main is present - result = runner.invoke(cli_app, ["project", "list"]) + result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV) assert "main" in result.stdout # Add a second project @@ -159,7 +161,7 @@ def test_remove_main_project(app, app_config, config_manager): assert result.exit_code == 0 # Confirm only new_default exists and main does not - result = runner.invoke(cli_app, ["project", "list"]) + result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV) assert result.exit_code == 0 assert "main" not in result.stdout assert "new_default" in result.stdout diff --git a/tests/services/test_initialization.py b/tests/services/test_initialization.py index baba9531..142775e8 100644 --- a/tests/services/test_initialization.py +++ b/tests/services/test_initialization.py @@ -6,6 +6,8 @@ test config + dual-backend fixtures. from __future__ import annotations +from unittest.mock import AsyncMock + import pytest from basic_memory import db @@ -13,6 +15,7 @@ from basic_memory.config import BasicMemoryConfig, DatabaseBackend from basic_memory.repository.project_repository import ProjectRepository from basic_memory.services.initialization import ( ensure_initialization, + initialize_app, initialize_database, reconcile_projects_with_config, ) @@ -129,3 +132,66 @@ def test_ensure_initialization_runs_and_cleans_up(app_config: BasicMemoryConfig, # Must be cleaned up to avoid hanging processes. assert db._engine is None # pyright: ignore [reportPrivateUsage] assert db._session_maker is None # pyright: ignore [reportPrivateUsage] + + +@pytest.mark.asyncio +async def test_initialize_app_warns_on_frontmatter_permalink_precedence( + app_config: BasicMemoryConfig, monkeypatch +): + app_config.ensure_frontmatter_on_sync = True + app_config.disable_permalinks = True + + init_db_mock = AsyncMock() + reconcile_mock = AsyncMock() + monkeypatch.setattr("basic_memory.services.initialization.initialize_database", init_db_mock) + monkeypatch.setattr( + "basic_memory.services.initialization.reconcile_projects_with_config", + reconcile_mock, + ) + + warnings: list[str] = [] + + def capture_warning(message: str) -> None: + warnings.append(message) + + monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning) + + await initialize_app(app_config) + + assert init_db_mock.await_count == 1 + assert reconcile_mock.await_count == 1 + assert any( + "ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message + for message in warnings + ) + + +@pytest.mark.asyncio +async def test_initialize_app_no_precedence_warning_when_not_conflicting( + app_config: BasicMemoryConfig, monkeypatch +): + app_config.ensure_frontmatter_on_sync = False + app_config.disable_permalinks = True + + monkeypatch.setattr( + "basic_memory.services.initialization.initialize_database", + AsyncMock(), + ) + monkeypatch.setattr( + "basic_memory.services.initialization.reconcile_projects_with_config", + AsyncMock(), + ) + + warnings: list[str] = [] + + def capture_warning(message: str) -> None: + warnings.append(message) + + monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning) + + await initialize_app(app_config) + + assert not any( + "ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message + for message in warnings + ) diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index 807d15bf..389f0919 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -1116,6 +1116,59 @@ async def test_sync_permalink_not_created_if_no_frontmatter( assert "permalink:" not in file_content +@pytest.mark.asyncio +async def test_sync_frontmatter_created_if_missing_when_enabled( + sync_service: SyncService, + project_config: ProjectConfig, + file_service: FileService, + app_config: BasicMemoryConfig, +): + """Sync should add derived frontmatter when configured for missing-frontmatter files.""" + app_config.ensure_frontmatter_on_sync = True + + project_dir = project_config.home + file = project_dir / "one.md" + await create_test_file(file, "# One\n") + + await sync_service.sync(project_config.home) + + file_content, _ = await file_service.read_file(file) + project_prefix = generate_permalink(project_config.name) + assert "title: one" in file_content + assert "type: note" in file_content + assert f"permalink: {project_prefix}/one" in file_content + + entity = await sync_service.entity_repository.get_by_file_path("one.md") + assert entity is not None + assert entity.permalink == f"{project_prefix}/one" + + +@pytest.mark.asyncio +async def test_sync_frontmatter_created_if_missing_overrides_disable_permalinks( + sync_service: SyncService, + project_config: ProjectConfig, + file_service: FileService, + app_config: BasicMemoryConfig, +): + """Missing-frontmatter sync path should write permalink even when disable_permalinks is true.""" + app_config.ensure_frontmatter_on_sync = True + app_config.disable_permalinks = True + + project_dir = project_config.home + file = project_dir / "override.md" + await create_test_file(file, "# Override\n") + + await sync_service.sync(project_config.home) + + file_content, _ = await file_service.read_file(file) + project_prefix = generate_permalink(project_config.name) + assert f"permalink: {project_prefix}/override" in file_content + + entity = await sync_service.entity_repository.get_by_file_path("override.md") + assert entity is not None + assert entity.permalink == f"{project_prefix}/override" + + @pytest.fixture def test_config_update_permamlinks_on_move(app_config) -> BasicMemoryConfig: """Test configuration using in-memory DB.""" diff --git a/tests/test_config.py b/tests/test_config.py index 011d0328..781cd32c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -220,6 +220,16 @@ class TestConfigManager: config = BasicMemoryConfig(disable_permalinks=True) assert config.disable_permalinks is True + def test_ensure_frontmatter_on_sync_flag_default(self): + """Test that ensure_frontmatter_on_sync defaults to False.""" + config = BasicMemoryConfig() + assert config.ensure_frontmatter_on_sync is False + + def test_ensure_frontmatter_on_sync_flag_can_be_enabled(self): + """Test that ensure_frontmatter_on_sync can be set to True.""" + config = BasicMemoryConfig(ensure_frontmatter_on_sync=True) + assert config.ensure_frontmatter_on_sync is True + def test_permalinks_include_project_flag_default(self): """Test that permalinks_include_project defaults to True.""" config = BasicMemoryConfig()