Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 17 additions & 6 deletions src/session_migrate/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down
33 changes: 33 additions & 0 deletions tests/test_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down
Loading