Skip to content

Commit a6d8d4c

Browse files
committed
Add ensure_frontmatter_on_sync with precedence warning
Add a new config option to enforce frontmatter on markdown sync when missing, writing derived title/type/permalink and updating in-memory metadata before upsert. Add startup warning when this option is combined with disable_permalinks to make precedence explicit. Add config/sync/initialization tests for the new behavior, and stabilize project list CLI integration assertions by forcing a wide terminal in tests to avoid Rich truncation. Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 0239f4a commit a6d8d4c

7 files changed

Lines changed: 167 additions & 6 deletions

File tree

src/basic_memory/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@ class BasicMemoryConfig(BaseSettings):
231231
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.",
232232
)
233233

234+
ensure_frontmatter_on_sync: bool = Field(
235+
default=False,
236+
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.",
237+
)
238+
234239
permalinks_include_project: bool = Field(
235240
default=True,
236241
description="When True, generated permalinks are prefixed with the project slug (e.g., 'specs/search'). Existing permalinks remain unchanged unless explicitly updated.",

src/basic_memory/services/initialization.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,16 @@ async def initialize_app(
174174
Args:
175175
app_config: The Basic Memory project configuration
176176
"""
177+
# Trigger: frontmatter enforcement is enabled while permalink generation is disabled
178+
# Why: missing-frontmatter sync path needs canonical permalinks for deterministic indexing
179+
# Outcome: log startup precedence so behavior is explicit to operators
180+
if app_config.ensure_frontmatter_on_sync and app_config.disable_permalinks:
181+
logger.warning(
182+
"Config precedence: ensure_frontmatter_on_sync=True overrides "
183+
"disable_permalinks=True for markdown files missing frontmatter during sync; "
184+
"permalinks will be written."
185+
)
186+
177187
# Trigger: database backend is Postgres (cloud deployment)
178188
# Why: cloud deployments manage their own projects and migrations via the cloud platform.
179189
# The local MCP server always uses SQLite and needs initialization even when

src/basic_memory/sync/sync_service.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,21 @@ async def sync_markdown_file(self, path: str, new: bool = True) -> Tuple[Optiona
669669
ctime=file_metadata.created_at.timestamp(),
670670
)
671671

672+
# Trigger: markdown file has no frontmatter and frontmatter enforcement is enabled
673+
# Why: watch/sync consumers rely on normalized metadata and stable permalinks
674+
# Outcome: file is updated in-place with derived title/type/permalink metadata
675+
if not file_contains_frontmatter and self.app_config.ensure_frontmatter_on_sync:
676+
permalink = await self.entity_service.resolve_permalink(
677+
path, markdown=entity_markdown, skip_conflict_check=True
678+
)
679+
frontmatter_updates = {
680+
"title": entity_markdown.frontmatter.title,
681+
"type": entity_markdown.frontmatter.type,
682+
"permalink": permalink,
683+
}
684+
await self.file_service.update_frontmatter(path, frontmatter_updates)
685+
entity_markdown.frontmatter.metadata.update(frontmatter_updates)
686+
672687
# if the file contains frontmatter, resolve a permalink (unless disabled)
673688
if file_contains_frontmatter and not self.app_config.disable_permalinks:
674689
# Resolve permalink - skip conflict checks during bulk sync for performance

test-int/cli/test_project_commands_integration.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
from basic_memory.cli.main import app as cli_app
99

10+
WIDE_TERMINAL_ENV = {"COLUMNS": "240", "LINES": "60"}
11+
1012

1113
def test_project_list(app, app_config, test_project, config_manager):
1214
"""Test 'bm project list' command shows projects."""
1315
runner = CliRunner()
14-
result = runner.invoke(cli_app, ["project", "list"])
16+
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
1517

1618
if result.exit_code != 0:
1719
print(f"STDOUT: {result.stdout}")
@@ -77,7 +79,7 @@ def test_project_add_and_remove(app, app_config, config_manager):
7779
)
7880

7981
# Verify it shows up in list
80-
result = runner.invoke(cli_app, ["project", "list"])
82+
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
8183
assert result.exit_code == 0
8284
assert "new-project" in result.stdout
8385

@@ -114,7 +116,7 @@ def test_project_set_default(app, app_config, config_manager):
114116
assert "default" in result.stdout.lower()
115117

116118
# Verify in list
117-
result = runner.invoke(cli_app, ["project", "list"])
119+
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
118120
assert result.exit_code == 0
119121
# The new project should have the [X] marker now
120122
lines = result.stdout.split("\n")
@@ -136,14 +138,14 @@ def test_remove_main_project(app, app_config, config_manager):
136138
new_default_path = Path(new_default_dir)
137139

138140
# Ensure main exists
139-
result = runner.invoke(cli_app, ["project", "list"])
141+
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
140142
if "main" not in result.stdout:
141143
result = runner.invoke(cli_app, ["project", "add", "main", str(main_path)])
142144
print(result.stdout)
143145
assert result.exit_code == 0
144146

145147
# Confirm main is present
146-
result = runner.invoke(cli_app, ["project", "list"])
148+
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
147149
assert "main" in result.stdout
148150

149151
# Add a second project
@@ -159,7 +161,7 @@ def test_remove_main_project(app, app_config, config_manager):
159161
assert result.exit_code == 0
160162

161163
# Confirm only new_default exists and main does not
162-
result = runner.invoke(cli_app, ["project", "list"])
164+
result = runner.invoke(cli_app, ["project", "list"], env=WIDE_TERMINAL_ENV)
163165
assert result.exit_code == 0
164166
assert "main" not in result.stdout
165167
assert "new_default" in result.stdout

tests/services/test_initialization.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66

77
from __future__ import annotations
88

9+
from unittest.mock import AsyncMock
10+
911
import pytest
1012

1113
from basic_memory import db
1214
from basic_memory.config import BasicMemoryConfig, DatabaseBackend
1315
from basic_memory.repository.project_repository import ProjectRepository
1416
from basic_memory.services.initialization import (
1517
ensure_initialization,
18+
initialize_app,
1619
initialize_database,
1720
reconcile_projects_with_config,
1821
)
@@ -129,3 +132,66 @@ def test_ensure_initialization_runs_and_cleans_up(app_config: BasicMemoryConfig,
129132
# Must be cleaned up to avoid hanging processes.
130133
assert db._engine is None # pyright: ignore [reportPrivateUsage]
131134
assert db._session_maker is None # pyright: ignore [reportPrivateUsage]
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_initialize_app_warns_on_frontmatter_permalink_precedence(
139+
app_config: BasicMemoryConfig, monkeypatch
140+
):
141+
app_config.ensure_frontmatter_on_sync = True
142+
app_config.disable_permalinks = True
143+
144+
init_db_mock = AsyncMock()
145+
reconcile_mock = AsyncMock()
146+
monkeypatch.setattr("basic_memory.services.initialization.initialize_database", init_db_mock)
147+
monkeypatch.setattr(
148+
"basic_memory.services.initialization.reconcile_projects_with_config",
149+
reconcile_mock,
150+
)
151+
152+
warnings: list[str] = []
153+
154+
def capture_warning(message: str) -> None:
155+
warnings.append(message)
156+
157+
monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning)
158+
159+
await initialize_app(app_config)
160+
161+
assert init_db_mock.await_count == 1
162+
assert reconcile_mock.await_count == 1
163+
assert any(
164+
"ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message
165+
for message in warnings
166+
)
167+
168+
169+
@pytest.mark.asyncio
170+
async def test_initialize_app_no_precedence_warning_when_not_conflicting(
171+
app_config: BasicMemoryConfig, monkeypatch
172+
):
173+
app_config.ensure_frontmatter_on_sync = False
174+
app_config.disable_permalinks = True
175+
176+
monkeypatch.setattr(
177+
"basic_memory.services.initialization.initialize_database",
178+
AsyncMock(),
179+
)
180+
monkeypatch.setattr(
181+
"basic_memory.services.initialization.reconcile_projects_with_config",
182+
AsyncMock(),
183+
)
184+
185+
warnings: list[str] = []
186+
187+
def capture_warning(message: str) -> None:
188+
warnings.append(message)
189+
190+
monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning)
191+
192+
await initialize_app(app_config)
193+
194+
assert not any(
195+
"ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message
196+
for message in warnings
197+
)

tests/sync/test_sync_service.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,59 @@ async def test_sync_permalink_not_created_if_no_frontmatter(
11161116
assert "permalink:" not in file_content
11171117

11181118

1119+
@pytest.mark.asyncio
1120+
async def test_sync_frontmatter_created_if_missing_when_enabled(
1121+
sync_service: SyncService,
1122+
project_config: ProjectConfig,
1123+
file_service: FileService,
1124+
app_config: BasicMemoryConfig,
1125+
):
1126+
"""Sync should add derived frontmatter when configured for missing-frontmatter files."""
1127+
app_config.ensure_frontmatter_on_sync = True
1128+
1129+
project_dir = project_config.home
1130+
file = project_dir / "one.md"
1131+
await create_test_file(file, "# One\n")
1132+
1133+
await sync_service.sync(project_config.home)
1134+
1135+
file_content, _ = await file_service.read_file(file)
1136+
project_prefix = generate_permalink(project_config.name)
1137+
assert "title: one" in file_content
1138+
assert "type: note" in file_content
1139+
assert f"permalink: {project_prefix}/one" in file_content
1140+
1141+
entity = await sync_service.entity_repository.get_by_file_path("one.md")
1142+
assert entity is not None
1143+
assert entity.permalink == f"{project_prefix}/one"
1144+
1145+
1146+
@pytest.mark.asyncio
1147+
async def test_sync_frontmatter_created_if_missing_overrides_disable_permalinks(
1148+
sync_service: SyncService,
1149+
project_config: ProjectConfig,
1150+
file_service: FileService,
1151+
app_config: BasicMemoryConfig,
1152+
):
1153+
"""Missing-frontmatter sync path should write permalink even when disable_permalinks is true."""
1154+
app_config.ensure_frontmatter_on_sync = True
1155+
app_config.disable_permalinks = True
1156+
1157+
project_dir = project_config.home
1158+
file = project_dir / "override.md"
1159+
await create_test_file(file, "# Override\n")
1160+
1161+
await sync_service.sync(project_config.home)
1162+
1163+
file_content, _ = await file_service.read_file(file)
1164+
project_prefix = generate_permalink(project_config.name)
1165+
assert f"permalink: {project_prefix}/override" in file_content
1166+
1167+
entity = await sync_service.entity_repository.get_by_file_path("override.md")
1168+
assert entity is not None
1169+
assert entity.permalink == f"{project_prefix}/override"
1170+
1171+
11191172
@pytest.fixture
11201173
def test_config_update_permamlinks_on_move(app_config) -> BasicMemoryConfig:
11211174
"""Test configuration using in-memory DB."""

tests/test_config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,16 @@ def test_disable_permalinks_flag_can_be_enabled(self):
220220
config = BasicMemoryConfig(disable_permalinks=True)
221221
assert config.disable_permalinks is True
222222

223+
def test_ensure_frontmatter_on_sync_flag_default(self):
224+
"""Test that ensure_frontmatter_on_sync defaults to False."""
225+
config = BasicMemoryConfig()
226+
assert config.ensure_frontmatter_on_sync is False
227+
228+
def test_ensure_frontmatter_on_sync_flag_can_be_enabled(self):
229+
"""Test that ensure_frontmatter_on_sync can be set to True."""
230+
config = BasicMemoryConfig(ensure_frontmatter_on_sync=True)
231+
assert config.ensure_frontmatter_on_sync is True
232+
223233
def test_permalinks_include_project_flag_default(self):
224234
"""Test that permalinks_include_project defaults to True."""
225235
config = BasicMemoryConfig()

0 commit comments

Comments
 (0)