Skip to content

Commit 4e38727

Browse files
committed
fix(cli): abort OKF export on incomplete file scans
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent d0b9b4c commit 4e38727

4 files changed

Lines changed: 55 additions & 9 deletions

File tree

src/basic_memory/index/local_project.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import os
7+
import stat
78
from collections.abc import Mapping, Sequence
89
from contextlib import nullcontext
910
from dataclasses import dataclass
@@ -223,6 +224,7 @@ def scan_local_project_index_files(
223224
project_root: Path,
224225
*,
225226
ignore_patterns: LocalProjectIndexIgnorePatterns | None = None,
227+
strict: bool = False,
226228
) -> LocalProjectIndexScan:
227229
"""Walk one local project and report eligible files plus unreadable subtrees."""
228230
project_root = project_root.expanduser().resolve()
@@ -254,7 +256,7 @@ def _scan_error(error: OSError) -> None:
254256
# The root scan failed (onerror re-raised). Never return an empty,
255257
# delete-everything snapshot; files discovered before a deeper traversal
256258
# error are kept.
257-
if not file_paths:
259+
if strict or not file_paths:
258260
raise
259261
break
260262

@@ -269,16 +271,23 @@ def _scan_error(error: OSError) -> None:
269271

270272
for name in filenames:
271273
path = root_path / name
272-
try:
273-
if path.is_symlink() or not path.is_file():
274-
continue
275-
except OSError:
276-
continue
277274
relative_path = path.relative_to(project_root).as_posix()
278275
if local_relative_path_is_filtered(relative_path):
279276
continue
280277
if should_ignore_path(path, project_root, active_ignore_patterns):
281278
continue
279+
try:
280+
# Export requires every eligible file: lstat propagates errors that
281+
# pathlib predicates may suppress, without following symlinks.
282+
if strict:
283+
if not stat.S_ISREG(path.lstat().st_mode):
284+
continue
285+
elif path.is_symlink() or not path.is_file():
286+
continue
287+
except OSError:
288+
if strict:
289+
raise
290+
continue
282291
file_paths.append(relative_path)
283292

284293
return LocalProjectIndexScan(

src/basic_memory/okf/export.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def snapshot_files(root: Path) -> tuple[ExportFile, ...]:
8282
from basic_memory.index.local_wiki_projection import _is_projector_owned
8383
from basic_memory.runtime.storage import runtime_file_path_is_markdown_note
8484

85-
scan = scan_local_project_index_files(root)
85+
scan = scan_local_project_index_files(root, strict=True)
8686
if scan.unreadable_directories:
8787
raise OSError("Incomplete project scan: " + ", ".join(scan.unreadable_directories))
8888
files = []

tests/index/test_local_project_scan_parity.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ def test_local_project_index_file_paths_aborts_when_root_unreadable(tmp_path: Pa
159159
local_project_index_file_paths(missing_root, ignore_patterns=set())
160160

161161

162-
def test_local_project_index_file_paths_skips_symlinked_files(tmp_path: Path) -> None:
162+
@pytest.mark.parametrize("strict", [False, True])
163+
def test_local_project_index_file_paths_skips_symlinked_files(tmp_path: Path, strict: bool) -> None:
163164
"""Symlinked files must not be indexed (their target may be outside the project)."""
164165
project_root = (tmp_path / "project").resolve()
165166
project_root.mkdir()
@@ -171,7 +172,21 @@ def test_local_project_index_file_paths_skips_symlinked_files(tmp_path: Path) ->
171172
except (OSError, NotImplementedError):
172173
pytest.skip("symlinks not supported on this platform")
173174

174-
assert local_project_index_file_paths(project_root, ignore_patterns=set()) == ("keep.md",)
175+
assert scan_local_project_index_files(
176+
project_root, ignore_patterns=set(), strict=strict
177+
).file_paths == ("keep.md",)
178+
179+
180+
def test_strict_scan_rejects_partial_walk(tmp_path: Path, monkeypatch) -> None:
181+
(tmp_path / "a.md").write_text("# A", encoding="utf-8")
182+
183+
def partial_walk(*args, **kwargs):
184+
yield str(tmp_path), [], ["a.md"]
185+
raise PermissionError("walk failed after a file")
186+
187+
monkeypatch.setattr(local_project.os, "walk", partial_walk)
188+
with pytest.raises(PermissionError, match="walk failed after a file"):
189+
scan_local_project_index_files(tmp_path, ignore_patterns=set(), strict=True)
175190

176191

177192
@pytest.mark.asyncio

tests/okf/test_export_failures.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,25 @@ def test_reserved_directory_is_rejected_before_staging(source_config, directory)
279279
(parent / "a.md").write_text("---\ntype: note\n---\n# A")
280280
with pytest.raises(ValueError, match="reserved OKF directory name; rename it first"):
281281
snapshot_files(root)
282+
283+
284+
@pytest.mark.asyncio
285+
async def test_file_stat_failure_preserves_replacement_destination(
286+
source_config, tmp_path, monkeypatch
287+
):
288+
root = Path(source_config.projects["export"].path)
289+
source = root / "a.md"
290+
destination = tmp_path / "bundle"
291+
destination.mkdir()
292+
(destination / "keep").write_bytes(b"previous bundle")
293+
original_lstat = Path.lstat
294+
295+
def failing_lstat(path, *args, **kwargs):
296+
if path == source:
297+
raise PermissionError(13, "stat unavailable", str(path))
298+
return original_lstat(path, *args, **kwargs)
299+
300+
monkeypatch.setattr(Path, "lstat", failing_lstat)
301+
with pytest.raises(PermissionError, match="stat unavailable"):
302+
await export_project(source_config, "export", destination, replace=True)
303+
assert (destination / "keep").read_bytes() == b"previous bundle"

0 commit comments

Comments
 (0)