Skip to content

Commit 0a93d07

Browse files
phernandezclaude
andcommitted
fix(sync): carry indexed files under unreadable directories through scan reconciliation
os.walk's onerror only aborted the scan when the project root itself was unreadable; a deeper unreadable subdirectory was pruned from the observed snapshot with a warning. Delete reconciliation plans all_db_paths - storage_paths, so every indexed row under the vanished subtree was destroyed even though the files still exist. Following the unobservable-file carry-through pattern (46d0011), the walk now reports unreadable non-root directories in a LocalProjectIndexScan result, and the observed-file source carries every indexed path under them through the snapshot as present-with-unknown-checksum (RuntimeObservedIndexFile with checksum None). Those paths classify as modified, never deleted, and the batch planner re-checks them next pass. The indexed-path source is the already-loaded indexed-stat snapshot; the bare observed source (no stat source) has nothing to carry. A walk error without a directory attribution now fails the scan outright: it cannot be carried, and finishing the walk could still plan a mass delete for an unknown subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 25337f9 commit 0a93d07

3 files changed

Lines changed: 228 additions & 14 deletions

File tree

src/basic_memory/index/local_project.py

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -142,30 +142,83 @@ async def load_indexed_file_stats(self) -> dict[str, IndexedFileStat]:
142142
}
143143

144144

145+
@dataclass(frozen=True, slots=True)
146+
class LocalProjectIndexScan:
147+
"""Walk result for one local project scan.
148+
149+
``unreadable_directories`` names project-relative directories whose listing
150+
failed mid-walk; their contents are absent from ``file_paths`` even though
151+
the files may still exist, so callers must never treat that absence as a
152+
delete signal.
153+
"""
154+
155+
file_paths: tuple[str, ...]
156+
unreadable_directories: tuple[str, ...]
157+
158+
159+
def record_local_project_scan_walk_error(
160+
error: OSError,
161+
*,
162+
project_root: Path,
163+
unreadable_directories: list[str],
164+
) -> None:
165+
"""Classify one os.walk error raised during a local project scan."""
166+
# Trigger: the walk error carries no directory attribution.
167+
# Why: without a directory we cannot carry its indexed rows through the
168+
# snapshot, so finishing the scan could still plan a mass delete for an
169+
# unknown subtree.
170+
# Outcome: fail the whole scan (RuntimeError bypasses the walk loop's
171+
# partial-snapshot OSError handling); the next scan retries.
172+
if error.filename is None:
173+
raise RuntimeError("Local project scan failed without a directory attribution") from error
174+
# Trigger: the project root itself is unreadable (missing/unmounted).
175+
# Why: an empty snapshot would make the coordinator treat every indexed
176+
# entity as deleted.
177+
# Outcome: re-raise so the scan aborts instead of returning empty.
178+
if Path(error.filename) == project_root:
179+
raise error
180+
unreadable_directory = Path(error.filename).relative_to(project_root).as_posix()
181+
logger.warning(
182+
"Recording unreadable directory during project scan",
183+
path=unreadable_directory,
184+
error=str(error),
185+
)
186+
unreadable_directories.append(unreadable_directory)
187+
188+
145189
def local_project_index_file_paths(
146190
project_root: Path,
147191
*,
148192
ignore_patterns: LocalProjectIndexIgnorePatterns | None = None,
149193
) -> tuple[str, ...]:
150194
"""Return sorted project-relative files eligible for local project indexing."""
195+
return scan_local_project_index_files(project_root, ignore_patterns=ignore_patterns).file_paths
196+
197+
198+
def scan_local_project_index_files(
199+
project_root: Path,
200+
*,
201+
ignore_patterns: LocalProjectIndexIgnorePatterns | None = None,
202+
) -> LocalProjectIndexScan:
203+
"""Walk one local project and report eligible files plus unreadable subtrees."""
151204
project_root = project_root.expanduser().resolve()
152205
active_ignore_patterns = (
153206
ignore_patterns if ignore_patterns is not None else load_gitignore_patterns(project_root)
154207
)
155208
file_paths: list[str] = []
209+
unreadable_directories: list[str] = []
156210

157211
# os.walk lets us prune ignored/hidden directories *before* descending —
158212
# rglob("*") would stat every file inside node_modules/.venv/.git first — and
159213
# does not follow symlinked directories (followlinks=False). Symlinked files
160214
# are skipped explicitly so the batch reader never reads outside the project
161215
# boundary. This restores the pre-refactor scanner's no-follow + dir-pruning.
162216
def _scan_error(error: OSError) -> None:
163-
# Abort only if the project root itself is unreadable (missing/unmounted):
164-
# returning an empty snapshot would make the coordinator treat every indexed
165-
# entity as deleted. A failure deeper in the tree just prunes that subtree.
166-
if error.filename is not None and Path(error.filename) == project_root:
167-
raise error
168-
logger.warning("Skipping unreadable directory during project scan", path=error.filename)
217+
record_local_project_scan_walk_error(
218+
error,
219+
project_root=project_root,
220+
unreadable_directories=unreadable_directories,
221+
)
169222

170223
walker = os.walk(project_root, followlinks=False, onerror=_scan_error)
171224
while True:
@@ -204,7 +257,10 @@ def _scan_error(error: OSError) -> None:
204257
continue
205258
file_paths.append(relative_path)
206259

207-
return tuple(sorted(file_paths))
260+
return LocalProjectIndexScan(
261+
file_paths=tuple(sorted(file_paths)),
262+
unreadable_directories=tuple(unreadable_directories),
263+
)
208264

209265

210266
@dataclass(frozen=True, slots=True)
@@ -216,11 +272,12 @@ class LocalProjectIndexObservedFileSource(ProjectIndexObservedFileSource):
216272
indexed_stat_source: LocalProjectIndexedFileStatSource | None = None
217273

218274
async def list_observed_index_files(self) -> tuple[RuntimeObservedIndexFile, ...]:
219-
file_paths = await asyncio.to_thread(
220-
local_project_index_file_paths,
275+
scan = await asyncio.to_thread(
276+
scan_local_project_index_files,
221277
self.file_service.base_path,
222278
ignore_patterns=self.ignore_patterns,
223279
)
280+
file_paths = scan.file_paths
224281
# Trigger: a stat source is wired (local runtime); it is absent in
225282
# cloud/tests that observe without a database.
226283
# Why: hashing every file on every startup is O(project bytes) and
@@ -267,6 +324,35 @@ async def list_observed_index_files(self) -> tuple[RuntimeObservedIndexFile, ...
267324
size=metadata.size,
268325
)
269326
)
327+
328+
# Trigger: the walk could not list a directory below the project root,
329+
# so every file under it is missing from the scan snapshot.
330+
# Why: delete reconciliation plans all_db_paths - storage_paths; letting
331+
# an unreadable subtree fall out of the snapshot would destroy the
332+
# entity/search rows for every indexed file it contains (same hazard as
333+
# a single unobservable file, carried above). Only the DB-backed stat
334+
# source can enumerate the affected rows; the bare observed source
335+
# (no indexed_stat_source) has nothing to carry.
336+
# Outcome: indexed paths under the unreadable directory re-enter the
337+
# snapshot with an unknown checksum, classifying as modified — the
338+
# batch planner re-checks them next pass instead of deleting them.
339+
for unreadable_directory in scan.unreadable_directories:
340+
directory_prefix = f"{unreadable_directory}/"
341+
carried_paths = sorted(
342+
indexed_path
343+
for indexed_path in indexed_stats
344+
if indexed_path.startswith(directory_prefix)
345+
)
346+
if not carried_paths:
347+
continue
348+
logger.warning(
349+
"Carrying indexed files under unreadable directory through change detection",
350+
directory=unreadable_directory,
351+
file_count=len(carried_paths),
352+
)
353+
observed_files.extend(
354+
RuntimeObservedIndexFile(path=carried_path) for carried_path in carried_paths
355+
)
270356
return tuple(observed_files)
271357

272358
async def _observed_file_confirmed_missing(self, file_path: RuntimeFilePath) -> bool:

tests/index/test_local_project_index.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@
2121
LocalProjectIndexObservedFileSource,
2222
LocalProjectIndexRuntime,
2323
LocalProjectIndexRuntimeFactory,
24+
LocalProjectIndexScan,
2425
RepositoryLocalProjectIndexedFileStatSource,
2526
local_project_index_file_paths,
2627
run_local_project_index,
2728
run_local_project_index_for_project,
2829
)
2930
from basic_memory.indexing.change_detector import ChangeDetector
30-
from basic_memory.indexing.change_planning import ChangeReport
31+
from basic_memory.indexing.change_planning import ChangeReport, plan_file_changes
3132
from basic_memory.indexing.embedding_index_planning import EmbeddingIndexTarget
3233
from basic_memory.indexing.file_batch_runner import IndexFileBatchReadResult
3334
from basic_memory.indexing.file_index_checking import IndexedFileChecksumRow
@@ -2381,6 +2382,63 @@ async def tracking_checksum(path):
23812382
assert set(hashed_paths) == {"notes/remtimed.md", "notes/resized.md", "notes/new.md"}
23822383

23832384

2385+
async def test_local_project_index_observed_source_carries_indexed_files_under_unreadable_directory(
2386+
tmp_path: Path,
2387+
monkeypatch,
2388+
) -> None:
2389+
"""Indexed rows under a directory the walk cannot read must not plan as deletes."""
2390+
keep = tmp_path / "keep.md"
2391+
keep_content = b"# Keep\n"
2392+
keep.write_bytes(keep_content)
2393+
2394+
indexed_stats = {
2395+
"keep.md": IndexedFileStat(mtime=None, size=None, checksum=None),
2396+
"locked/a.md": IndexedFileStat(mtime=1.0, size=10, checksum="indexed-a"),
2397+
"locked/deep/b.md": IndexedFileStat(mtime=1.0, size=11, checksum="indexed-b"),
2398+
# Indexed elsewhere and genuinely gone from storage — must not be carried.
2399+
"other/gone.md": IndexedFileStat(mtime=1.0, size=12, checksum="indexed-gone"),
2400+
}
2401+
2402+
def unreadable_scan(*args, **kwargs) -> LocalProjectIndexScan:
2403+
return LocalProjectIndexScan(
2404+
file_paths=("keep.md",),
2405+
unreadable_directories=("locked",),
2406+
)
2407+
2408+
monkeypatch.setattr(
2409+
"basic_memory.index.local_project.scan_local_project_index_files",
2410+
unreadable_scan,
2411+
)
2412+
2413+
observed = await LocalProjectIndexObservedFileSource(
2414+
FileService(tmp_path),
2415+
ignore_patterns=set(),
2416+
indexed_stat_source=_StaticIndexedFileStatSource(indexed_stats),
2417+
).list_observed_index_files()
2418+
2419+
assert [target.path for target in observed] == ["keep.md", "locked/a.md", "locked/deep/b.md"]
2420+
carried = {target.path: target for target in observed[1:]}
2421+
assert carried["locked/a.md"].checksum is None
2422+
assert carried["locked/deep/b.md"].checksum is None
2423+
2424+
keep_checksum = sha256(keep_content).hexdigest()
2425+
report = plan_file_changes(
2426+
storage_checksum_by_path={target.path: target.checksum for target in observed},
2427+
db_checksum_by_path={
2428+
"keep.md": keep_checksum,
2429+
"locked/a.md": "indexed-a",
2430+
"locked/deep/b.md": "indexed-b",
2431+
},
2432+
all_db_paths=("keep.md", "locked/a.md", "locked/deep/b.md", "other/gone.md"),
2433+
move_candidates=(),
2434+
)
2435+
2436+
# The unreadable subtree re-enters change detection as modified, never deleted;
2437+
# only the path that is genuinely gone from storage is planned as a delete.
2438+
assert report.deleted_files == ["other/gone.md"]
2439+
assert sorted(report.modified_files) == ["locked/a.md", "locked/deep/b.md"]
2440+
2441+
23842442
async def test_local_project_index_observed_source_hashes_without_stat_source(
23852443
tmp_path: Path,
23862444
monkeypatch,

tests/index/test_local_project_scan_parity.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
"""Local project scan parity with the legacy sync oracle."""
22

3+
import os
34
from pathlib import Path
45

56
import pytest
67

78
import basic_memory.index.local_project as local_project
89
from basic_memory.index.local_project import (
910
LocalProjectIndexObservedFileSource,
11+
LocalProjectIndexScan,
1012
local_project_index_file_paths,
13+
record_local_project_scan_walk_error,
14+
scan_local_project_index_files,
1115
)
1216
from basic_memory.services import FileService
1317

@@ -83,6 +87,69 @@ def recording_is_file(path: Path) -> bool:
8387
assert not any(".hidden" in path for path in visited)
8488

8589

90+
def test_scan_local_project_index_files_records_unreadable_subdirectory(
91+
monkeypatch,
92+
tmp_path: Path,
93+
) -> None:
94+
"""An unreadable subdirectory is reported, not silently pruned from the scan."""
95+
project_root = tmp_path.resolve()
96+
(project_root / "keep.md").write_text("# keep\n", encoding="utf-8")
97+
locked_dir = project_root / "locked"
98+
locked_dir.mkdir()
99+
(locked_dir / "note.md").write_text("# locked\n", encoding="utf-8")
100+
101+
real_walk = os.walk
102+
103+
def walk_with_locked_scandir_error(top, *, followlinks, onerror):
104+
# Emulate exactly what os.walk does when scandir(locked) fails: report
105+
# the directory through onerror and never yield its listing.
106+
for dirpath, dirnames, filenames in real_walk(
107+
top, followlinks=followlinks, onerror=onerror
108+
):
109+
if Path(dirpath) == locked_dir:
110+
onerror(PermissionError(13, "Permission denied", str(locked_dir)))
111+
continue
112+
yield dirpath, dirnames, filenames
113+
114+
monkeypatch.setattr(local_project.os, "walk", walk_with_locked_scandir_error)
115+
116+
scan = scan_local_project_index_files(project_root, ignore_patterns=set())
117+
118+
assert scan.file_paths == ("keep.md",)
119+
assert scan.unreadable_directories == ("locked",)
120+
121+
122+
def test_record_local_project_scan_walk_error_classifies_root_none_and_subtree(
123+
tmp_path: Path,
124+
) -> None:
125+
"""Root failures abort, unattributed failures fail the scan, subtrees are recorded."""
126+
project_root = tmp_path.resolve()
127+
unreadable_directories: list[str] = []
128+
129+
root_error = PermissionError(13, "Permission denied", str(project_root))
130+
with pytest.raises(PermissionError):
131+
record_local_project_scan_walk_error(
132+
root_error,
133+
project_root=project_root,
134+
unreadable_directories=unreadable_directories,
135+
)
136+
137+
with pytest.raises(RuntimeError, match="without a directory attribution"):
138+
record_local_project_scan_walk_error(
139+
PermissionError(13, "Permission denied"),
140+
project_root=project_root,
141+
unreadable_directories=unreadable_directories,
142+
)
143+
assert unreadable_directories == []
144+
145+
record_local_project_scan_walk_error(
146+
PermissionError(13, "Permission denied", str(project_root / "locked" / "deep")),
147+
project_root=project_root,
148+
unreadable_directories=unreadable_directories,
149+
)
150+
assert unreadable_directories == ["locked/deep"]
151+
152+
86153
def test_local_project_index_file_paths_aborts_when_root_unreadable(tmp_path: Path) -> None:
87154
"""A missing/unmounted project root must raise, not return an empty snapshot —
88155
the coordinator would otherwise classify every indexed entity as deleted."""
@@ -117,15 +184,18 @@ async def test_local_project_index_observed_file_source_skips_files_missing_afte
117184
keep_path.write_text("# Keep\n", encoding="utf-8")
118185
warnings: list[tuple[str, dict[str, object]]] = []
119186

120-
def discovered_paths(*args, **kwargs) -> tuple[str, ...]:
121-
return ("missing.md", "keep.md")
187+
def discovered_scan(*args, **kwargs) -> LocalProjectIndexScan:
188+
return LocalProjectIndexScan(
189+
file_paths=("missing.md", "keep.md"),
190+
unreadable_directories=(),
191+
)
122192

123193
def record_warning(message: str, **kwargs: object) -> None:
124194
warnings.append((message, kwargs))
125195

126196
monkeypatch.setattr(
127-
"basic_memory.index.local_project.local_project_index_file_paths",
128-
discovered_paths,
197+
"basic_memory.index.local_project.scan_local_project_index_files",
198+
discovered_scan,
129199
)
130200
monkeypatch.setattr(local_project.logger, "warning", record_warning)
131201

0 commit comments

Comments
 (0)