Skip to content

Commit e39b8e1

Browse files
groksrcclaude
andcommitted
fix(core): honor BASIC_MEMORY_CONFIG_DIR across remaining call sites
Several modules were hardcoding ``Path.home() / ".basic-memory"`` when resolving Basic Memory state, which ignored ``BASIC_MEMORY_CONFIG_DIR`` and caused split state when users isolated config and the database under a redirected data directory: - utils.setup_logging — log file - ignore_utils.get_bmignore_path — .bmignore - sync.watch_service.WatchService.status_path — watch-status.json - services.project_service.get_system_status — watch-status.json read - cli.commands.cloud.rclone_commands.get_project_bisync_state — bisync state dir Route each call site through resolve_data_dir() (or the already-threaded config, where available) so all of these follow the same override. Also simplify resolve_data_dir() to use Path.home() directly. Path.home() already reads $HOME on POSIX and %USERPROFILE% on Windows, so the explicit os.getenv("HOME", ...) dance was redundant and made tests fragile when they patched os.name to simulate Windows on non-Windows hosts. Adds regression tests for each call site that exercise the redirected BASIC_MEMORY_CONFIG_DIR path. Fixes #742 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 0d8c106 commit e39b8e1

File tree

10 files changed

+116
-8
lines changed

10 files changed

+116
-8
lines changed

src/basic_memory/cli/commands/cloud/rclone_commands.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from rich.console import Console
2121

2222
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
23+
from basic_memory.config import resolve_data_dir
2324
from basic_memory.utils import normalize_project_path
2425

2526
console = Console()
@@ -138,13 +139,16 @@ def get_bmignore_filter_path() -> Path:
138139
def get_project_bisync_state(project_name: str) -> Path:
139140
"""Get path to project's bisync state directory.
140141
142+
Honors ``BASIC_MEMORY_CONFIG_DIR`` so isolated instances each keep their
143+
own bisync state alongside their config.
144+
141145
Args:
142146
project_name: Name of the project
143147
144148
Returns:
145149
Path to bisync state directory for this project
146150
"""
147-
return Path.home() / ".basic-memory" / "bisync-state" / project_name
151+
return resolve_data_dir() / "bisync-state" / project_name
148152

149153

150154
def bisync_initialized(project_name: str) -> bool:

src/basic_memory/ignore_utils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from pathlib import Path
55
from typing import Set
66

7+
from basic_memory.config import resolve_data_dir
8+
79

810
# Common directories and patterns to ignore by default
911
# These are used as fallback if .bmignore doesn't exist
@@ -61,9 +63,11 @@ def get_bmignore_path() -> Path:
6163
"""Get path to .bmignore file.
6264
6365
Returns:
64-
Path to ~/.basic-memory/.bmignore
66+
Path to <basic-memory data dir>/.bmignore, honoring
67+
``BASIC_MEMORY_CONFIG_DIR`` so isolated instances each keep their
68+
own ignore file.
6569
"""
66-
return Path.home() / ".basic-memory" / ".bmignore"
70+
return resolve_data_dir() / ".bmignore"
6771

6872

6973
def create_default_bmignore() -> None:

src/basic_memory/services/project_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1137,7 +1137,7 @@ def get_system_status(self) -> SystemStatus:
11371137

11381138
# Get watch service status if available
11391139
watch_status = None
1140-
watch_status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
1140+
watch_status_path = self.config_manager.config.data_dir_path / WATCH_STATUS_JSON
11411141
if watch_status_path.exists():
11421142
try: # pragma: no cover
11431143
watch_status = json.loads( # pragma: no cover

src/basic_memory/sync/watch_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def __init__(
8989
self.app_config = app_config
9090
self.project_repository = project_repository
9191
self.state = WatchServiceState()
92-
self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
92+
self.status_path = app_config.data_dir_path / WATCH_STATUS_JSON
9393
self.status_path.parent.mkdir(parents=True, exist_ok=True)
9494
self._ignore_patterns_cache: dict[Path, Set[str]] = {}
9595
self._sync_service_factory = sync_service_factory

src/basic_memory/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,11 @@ def setup_logging(
281281
# Why: multiple basic-memory processes can share the same log directory at once.
282282
# Outcome: use per-process log files on Windows so log rotation stays local.
283283
log_filename = f"basic-memory-{os.getpid()}.log" if os.name == "nt" else "basic-memory.log"
284-
log_path = Path.home() / ".basic-memory" / log_filename
284+
# Deferred import: basic_memory.config imports from this module at load time,
285+
# so resolving the data dir via a top-level import would cycle.
286+
from basic_memory.config import resolve_data_dir
287+
288+
log_path = resolve_data_dir() / log_filename
285289
log_path.parent.mkdir(parents=True, exist_ok=True)
286290
if os.name == "nt":
287291
_cleanup_windows_log_files(log_path.parent, log_path.name)

tests/cli/test_ignore_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,29 @@
55

66
from basic_memory.ignore_utils import (
77
DEFAULT_IGNORE_PATTERNS,
8+
get_bmignore_path,
89
load_gitignore_patterns,
910
should_ignore_path,
1011
filter_files,
1112
)
1213

1314

15+
def test_get_bmignore_path_honors_basic_memory_config_dir(tmp_path, monkeypatch):
16+
"""Regression guard for #742: .bmignore must follow BASIC_MEMORY_CONFIG_DIR."""
17+
custom_dir = tmp_path / "instance-y" / "state"
18+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_dir))
19+
20+
assert get_bmignore_path() == custom_dir / ".bmignore"
21+
22+
23+
def test_get_bmignore_path_defaults_under_home(tmp_path, monkeypatch):
24+
"""Without BASIC_MEMORY_CONFIG_DIR, .bmignore lives under ~/.basic-memory."""
25+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
26+
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
27+
28+
assert get_bmignore_path() == tmp_path / ".basic-memory" / ".bmignore"
29+
30+
1431
def test_load_default_patterns_only():
1532
"""Test loading default patterns when no .gitignore exists."""
1633
with tempfile.TemporaryDirectory() as temp_dir:

tests/services/test_project_service.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,27 @@ async def test_get_system_status(project_service: ProjectService):
126126
assert status.database_size
127127

128128

129+
@pytest.mark.asyncio
130+
async def test_get_system_status_reads_watch_status_from_config_dir(
131+
project_service: ProjectService, tmp_path, monkeypatch
132+
):
133+
"""Regression guard for #742: watch-status.json is read from the configured
134+
data dir, not hardcoded to ~/.basic-memory."""
135+
import json as _json
136+
from basic_memory.config import WATCH_STATUS_JSON
137+
138+
custom_dir = tmp_path / "instance-v" / "state"
139+
custom_dir.mkdir(parents=True)
140+
(custom_dir / WATCH_STATUS_JSON).write_text(
141+
_json.dumps({"running": True, "error_count": 7}), encoding="utf-8"
142+
)
143+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_dir))
144+
145+
status = project_service.get_system_status()
146+
147+
assert status.watch_status == {"running": True, "error_count": 7}
148+
149+
129150
@pytest.mark.asyncio
130151
async def test_get_statistics(project_service: ProjectService, test_graph, test_project):
131152
"""Test getting statistics."""

tests/sync/test_watch_service.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import asyncio
44
import json
55
from pathlib import Path
6+
from unittest.mock import MagicMock
67

78
import pytest
89
from watchfiles import Change
910

11+
from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
1012
from basic_memory.models.project import Project
11-
from basic_memory.sync.watch_service import WatchServiceState
13+
from basic_memory.sync.watch_service import WatchService, WatchServiceState
1214

1315

1416
async def create_test_file(path: Path, content: str = "test content") -> None:
@@ -25,6 +27,23 @@ def test_watch_service_init(watch_service, project_config):
2527
assert watch_service.status_path.parent.exists()
2628

2729

30+
def test_watch_service_status_path_honors_basic_memory_config_dir(tmp_path, monkeypatch):
31+
"""Regression guard for #742: watch-status.json follows BASIC_MEMORY_CONFIG_DIR.
32+
33+
WatchService previously hardcoded ``Path.home() / ".basic-memory"`` which
34+
split state across instances running under an isolated config dir. Ensure
35+
the status path now lives under the configured data dir.
36+
"""
37+
custom_dir = tmp_path / "instance-z" / "state"
38+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_dir))
39+
40+
app_config = BasicMemoryConfig(projects={"main": {"path": str(tmp_path / "project")}})
41+
service = WatchService(app_config=app_config, project_repository=MagicMock())
42+
43+
assert service.status_path == custom_dir / WATCH_STATUS_JSON
44+
assert service.status_path.parent.exists()
45+
46+
2847
def test_state_add_event():
2948
"""Test adding events to state."""
3049
state = WatchServiceState()

tests/test_rclone_commands.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,21 @@ def test_get_project_remote_strips_app_data_prefix():
8383
assert get_project_remote(project, "my-bucket") == "basic-memory-cloud:my-bucket/research"
8484

8585

86-
def test_get_project_bisync_state():
86+
def test_get_project_bisync_state(monkeypatch):
87+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
8788
state_path = get_project_bisync_state("research")
8889
expected = Path.home() / ".basic-memory" / "bisync-state" / "research"
8990
assert state_path == expected
9091

9192

93+
def test_get_project_bisync_state_honors_basic_memory_config_dir(tmp_path, monkeypatch):
94+
"""Regression guard for #742: bisync state dir follows BASIC_MEMORY_CONFIG_DIR."""
95+
custom_dir = tmp_path / "instance-w" / "state"
96+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_dir))
97+
98+
assert get_project_bisync_state("research") == custom_dir / "bisync-state" / "research"
99+
100+
92101
def test_bisync_initialized_false_when_not_exists(tmp_path, monkeypatch):
93102
monkeypatch.setattr(
94103
"basic_memory.cli.commands.cloud.rclone_commands.get_project_bisync_state",

tests/utils/test_setup_logging.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ def test_setup_logging_uses_shared_log_file_off_windows(monkeypatch, tmp_path) -
1111
added_sinks: list[str] = []
1212

1313
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
14+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
1415
monkeypatch.setattr(utils.os, "name", "posix")
1516
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
1617
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
@@ -32,6 +33,7 @@ def test_setup_logging_uses_per_process_log_file_on_windows(monkeypatch, tmp_pat
3233
added_sinks: list[str] = []
3334

3435
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
36+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
3537
monkeypatch.setattr(utils.os, "name", "nt")
3638
monkeypatch.setattr(utils.os, "getpid", lambda: 4242)
3739
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
@@ -63,6 +65,7 @@ def test_setup_logging_trims_stale_windows_pid_logs(monkeypatch, tmp_path) -> No
6365
stale_logs.append(log_path)
6466

6567
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
68+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
6669
monkeypatch.setattr(utils.os, "name", "nt")
6770
monkeypatch.setattr(utils.os, "getpid", lambda: 4242)
6871
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
@@ -82,6 +85,33 @@ def test_setup_logging_trims_stale_windows_pid_logs(monkeypatch, tmp_path) -> No
8285
]
8386

8487

88+
def test_setup_logging_honors_basic_memory_config_dir(monkeypatch, tmp_path) -> None:
89+
"""Regression guard for #742: log path must follow BASIC_MEMORY_CONFIG_DIR.
90+
91+
Prior to #742 the log path was hardcoded to ``~/.basic-memory/``, which
92+
split state across instances when users set BASIC_MEMORY_CONFIG_DIR to
93+
isolate config and the database elsewhere.
94+
"""
95+
added_sinks: list[str] = []
96+
97+
custom_dir = tmp_path / "instance-x" / "state"
98+
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
99+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_dir))
100+
monkeypatch.setattr(utils.os, "name", "posix")
101+
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
102+
monkeypatch.setattr(
103+
utils.logger,
104+
"add",
105+
lambda sink, **kwargs: added_sinks.append(str(sink)),
106+
)
107+
monkeypatch.setattr(utils.telemetry, "get_logfire_handler", lambda: None)
108+
monkeypatch.setattr(utils.telemetry, "pop_telemetry_warnings", lambda: [])
109+
110+
utils.setup_logging(log_to_file=True)
111+
112+
assert added_sinks == [str(custom_dir / "basic-memory.log")]
113+
114+
85115
def test_setup_logging_test_env_uses_stderr_only(monkeypatch) -> None:
86116
"""Test mode should add one stderr sink and return before other branches run."""
87117
added_sinks: list[object] = []

0 commit comments

Comments
 (0)