diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cae84..4d9e447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ here. Native format compatibility is documented separately in ## Unreleased +- Fix `catalog refresh` reporting every root as `scan_failed` on Windows + volumes whose serial number (or file ID) exceeds `2**63 - 1`. Python reports + `st_dev`/`st_ino` there as unsigned 64-bit values, which SQLite rejected with + `OverflowError`; they are now folded into the signed INTEGER range before + being stored or compared. + ## 0.12.0 - 2026-10-19 - Fix atomic writes on Windows in `write_private_atomic`: skip the Unix-only diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 95269ec..b6b328d 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -1175,8 +1175,8 @@ def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]: unchanged = bool( previous and previous["status"] != "missing" - and int(previous["device"]) == before.device - and int(previous["inode"]) == before.inode + and int(previous["device"]) == _sqlite_int(before.device) + and int(previous["inode"]) == _sqlite_int(before.inode) and int(previous["bytes"]) == before.size and int(previous["modified_ns"]) == before.modified_ns and previous["source_fingerprint"] == getattr(before, "fingerprint", None) @@ -1544,8 +1544,8 @@ def _upsert_session( scan.status, scan.reason, scan.records, - snapshot.device, - snapshot.inode, + _sqlite_int(snapshot.device), + _sqlite_int(snapshot.inode), snapshot.size, snapshot.modified_ns, getattr(snapshot, "fingerprint", None), @@ -1625,8 +1625,8 @@ def _upsert_virtual_session( scan.status, scan.reason, scan.records, - snapshot.device, - snapshot.inode, + _sqlite_int(snapshot.device), + _sqlite_int(snapshot.inode), snapshot.size, snapshot.modified_ns, snapshot.fingerprint, @@ -3107,6 +3107,17 @@ def _is_non_negative_sqlite_int(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value >= 0 +def _sqlite_int(value: int) -> int: + """Fold an unsigned 64-bit stat identity into SQLite's signed INTEGER range. + + Windows reports st_dev (volume serial) and st_ino (file ID) as unsigned + 64-bit values, which SQLite rejects with OverflowError above 2**63 - 1. + """ + + value &= (1 << 64) - 1 + return value - (1 << 64) if value >= 1 << 63 else value + + def _copilot_native_metadata(root: Path) -> _NativeMetadata: """Read only bounded picker names, never event/message content.""" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 9da67b1..d60c760 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -7,6 +7,7 @@ import pytest import session_migrate.catalog as catalog_module +import session_migrate.jsonl as jsonl_module from session_migrate.catalog import Catalog, auto_roots, default_catalog_path, discover_roots from session_migrate.errors import JsonlError, SessionMigrateError from session_migrate.formats import ( @@ -463,6 +464,38 @@ def test_refresh_is_incremental_and_validation_is_explicit(tmp_path: Path) -> No assert catalog.list_sessions()[0].reason is None +def test_refresh_stores_unsigned_64_bit_stat_identities( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Windows reports st_dev/st_ino as unsigned 64-bit values that can exceed + # SQLite's signed INTEGER range. + home = tmp_path / "claude" + session = home / "projects" / "-synthetic" / f"{CLAUDE_ID}.jsonl" + _write_jsonl(session, _claude_records(CLAUDE_ID, "Unsigned identity")) + real_snapshot = jsonl_module.file_snapshot + + def unsigned_snapshot(path: Path) -> jsonl_module.FileSnapshot: + snapshot = real_snapshot(path) + return jsonl_module.FileSnapshot( + device=2**64 - 1, + inode=2**63 + 7, + size=snapshot.size, + modified_ns=snapshot.modified_ns, + ) + + monkeypatch.setattr(catalog_module, "file_snapshot", unsigned_snapshot) + monkeypatch.setattr(jsonl_module, "file_snapshot", unsigned_snapshot) + + with _catalog(tmp_path) as catalog: + first = catalog.refresh(claude_roots=(home,), include_auto=False) + assert first.root_errors == 0 + assert first.scanned == 1 + second = catalog.refresh(include_auto=False) + assert second.root_errors == 0 + assert second.scanned == 0 + assert second.unchanged == 1 + + def test_pi_sessions_are_named_searchable_validatable_and_version_guarded( tmp_path: Path, ) -> None: