Skip to content

Commit e2e6557

Browse files
groksrcclaude
andauthored
fix(core): honor BASIC_MEMORY_CONFIG_DIR across remaining call sites (#744)
Signed-off-by: Drew Cain <groksrc@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bf9a6b4 commit e2e6557

File tree

10 files changed

+134
-14
lines changed

10 files changed

+134
-14
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: 8 additions & 3 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:
@@ -176,7 +180,8 @@ def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[
176180
"""Load gitignore patterns from .gitignore file and .bmignore.
177181
178182
Combines patterns from:
179-
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
183+
1. <basic-memory data dir>/.bmignore (user's global ignore patterns, honors
184+
BASIC_MEMORY_CONFIG_DIR)
180185
2. {base_path}/.gitignore (project-specific patterns, if use_gitignore=True)
181186
182187
Args:

src/basic_memory/services/project_service.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,12 +1137,10 @@ 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():
1142-
try: # pragma: no cover
1143-
watch_status = json.loads( # pragma: no cover
1144-
watch_status_path.read_text(encoding="utf-8")
1145-
)
1142+
try:
1143+
watch_status = json.loads(watch_status_path.read_text(encoding="utf-8"))
11461144
except Exception: # pragma: no cover
11471145
pass
11481146

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: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ def setup_logging(
262262
263263
Args:
264264
log_level: DEBUG, INFO, WARNING, ERROR
265-
log_to_file: Write to ~/.basic-memory/basic-memory.log with rotation
265+
log_to_file: Write to <basic-memory data dir>/basic-memory.log with rotation
266+
(honors BASIC_MEMORY_CONFIG_DIR)
266267
log_to_stdout: Write to stderr (for Docker/cloud deployments)
267268
structured_context: Bind tenant_id, fly_region, etc. for cloud observability
268269
"""
@@ -281,7 +282,11 @@ def setup_logging(
281282
# Why: multiple basic-memory processes can share the same log directory at once.
282283
# Outcome: use per-process log files on Windows so log rotation stays local.
283284
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
285+
# Deferred import: basic_memory.config imports from this module at load time,
286+
# so resolving the data dir via a top-level import would cycle.
287+
from basic_memory.config import resolve_data_dir
288+
289+
log_path = resolve_data_dir() / log_filename
285290
log_path.parent.mkdir(parents=True, exist_ok=True)
286291
if os.name == "nt":
287292
_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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import sys
5+
from pathlib import Path
56

67
from basic_memory import utils
78

@@ -11,6 +12,7 @@ def test_setup_logging_uses_shared_log_file_off_windows(monkeypatch, tmp_path) -
1112
added_sinks: list[str] = []
1213

1314
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
15+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
1416
monkeypatch.setattr(utils.os, "name", "posix")
1517
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
1618
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
@@ -32,6 +34,7 @@ def test_setup_logging_uses_per_process_log_file_on_windows(monkeypatch, tmp_pat
3234
added_sinks: list[str] = []
3335

3436
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
37+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
3538
monkeypatch.setattr(utils.os, "name", "nt")
3639
monkeypatch.setattr(utils.os, "getpid", lambda: 4242)
3740
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
@@ -63,6 +66,7 @@ def test_setup_logging_trims_stale_windows_pid_logs(monkeypatch, tmp_path) -> No
6366
stale_logs.append(log_path)
6467

6568
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
69+
monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False)
6670
monkeypatch.setattr(utils.os, "name", "nt")
6771
monkeypatch.setattr(utils.os, "getpid", lambda: 4242)
6872
monkeypatch.setattr(utils.Path, "home", lambda: tmp_path)
@@ -82,6 +86,44 @@ def test_setup_logging_trims_stale_windows_pid_logs(monkeypatch, tmp_path) -> No
8286
]
8387

8488

89+
def test_setup_logging_honors_basic_memory_config_dir(monkeypatch, tmp_path) -> None:
90+
"""Regression guard for #742: log path must follow BASIC_MEMORY_CONFIG_DIR.
91+
92+
Prior to #742 the log path was hardcoded to ``~/.basic-memory/``, which
93+
split state across instances when users set BASIC_MEMORY_CONFIG_DIR to
94+
isolate config and the database elsewhere.
95+
96+
Asserts on the log *directory* rather than the exact filename because
97+
Windows uses a per-process ``basic-memory-<pid>.log`` while POSIX
98+
shares a single ``basic-memory.log``. The thing this regression guard
99+
cares about is that the log lives under the redirected config dir,
100+
not at ``Path.home() / ".basic-memory"``. Patching ``utils.os.name``
101+
to force one branch would break ``Path(str)`` dispatch on the other
102+
platform, so we stay platform-agnostic.
103+
"""
104+
added_sinks: list[str] = []
105+
106+
custom_dir = tmp_path / "instance-x" / "state"
107+
monkeypatch.setenv("BASIC_MEMORY_ENV", "dev")
108+
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_dir))
109+
monkeypatch.setattr(utils.logger, "remove", lambda *args, **kwargs: None)
110+
monkeypatch.setattr(
111+
utils.logger,
112+
"add",
113+
lambda sink, **kwargs: added_sinks.append(str(sink)),
114+
)
115+
monkeypatch.setattr(utils.telemetry, "get_logfire_handler", lambda: None)
116+
monkeypatch.setattr(utils.telemetry, "pop_telemetry_warnings", lambda: [])
117+
118+
utils.setup_logging(log_to_file=True)
119+
120+
assert len(added_sinks) == 1
121+
log_path = Path(added_sinks[0])
122+
assert log_path.parent == custom_dir
123+
assert log_path.name.startswith("basic-memory")
124+
assert log_path.suffix == ".log"
125+
126+
85127
def test_setup_logging_test_env_uses_stderr_only(monkeypatch) -> None:
86128
"""Test mode should add one stderr sink and return before other branches run."""
87129
added_sinks: list[object] = []

0 commit comments

Comments
 (0)