Files
Zecheng Zhang 4eef9413f9 feat(watch): ship delta_hook for ten more backends
Adds pull change detection to s3, github, dropbox, disk, gridfs, the hf
family, ssh, gdrive, graph and box, so eleven resource families now
answer "what changed under this root since my checkpoint".

Two shared helpers keep a new backend from writing its own loop:
synth_dirs builds the directory rows a prefix store implies, and
ReaddirWalk descends through a backend's own readdir with a fresh
private index per pull.

Adding github to the integ battery surfaced two real bugs. Its index was
keyed repo-relative while the other eleven backends and CacheManager key
mount-absolute, so cache invalidation never reached a github mount at
all, silently, since evicting an absent key succeeds. And invalidate_dir
drops a row rather than expiring it, so github's readers took a dropped
index for real absence. Github now keys mount-absolute like everyone
else, its repo-relative path logic reads the git tree on the accessor
the way TypeScript's always has, and ensure_live_index refetches when
the index holds no listing.

Box fingerprinted on modified_at while the same listing already carried
sha1, so two writes in one second were indistinguishable. It now prefers
sha1.

The integ watch battery covers eleven targets at 238 cases, and the disk
example is gated in both languages against one shared truth file.
2026-08-15 02:58:04 -07:00

124 lines
4.4 KiB
Python

# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import base64
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import mirage.core.github.read
import mirage.core.github.tree
from mirage.cache.index import IndexEntry
from mirage.cache.index.ram import RAMIndexCacheStore
from mirage.core.github.config import GitHubConfig
from mirage.core.github.read import read, read_bytes
from mirage.core.github.tree_entry import TreeEntry
from mirage.types import PathSpec
@pytest.fixture
def config():
return GitHubConfig(token="ghp_test")
@pytest.mark.asyncio
@patch("mirage.core.github.read.github_get", new_callable=AsyncMock)
async def test_read_bytes_utf8(mock_get, config):
content = b"hello world"
mock_get.return_value = {"content": base64.b64encode(content).decode()}
result = await read_bytes(config, "acme", "proj", "sha123")
assert result == content
assert result.decode("utf-8") == "hello world"
@pytest.mark.asyncio
@patch("mirage.core.github.read.github_get", new_callable=AsyncMock)
async def test_read_bytes_binary(mock_get, config):
content = bytes(range(256))
mock_get.return_value = {"content": base64.b64encode(content).decode()}
result = await read_bytes(config, "acme", "proj", "sha456")
assert result == content
def _index() -> RAMIndexCacheStore:
index = RAMIndexCacheStore()
entry = IndexEntry(id="bbb", name="main.py", resource_type="file", size=3)
index._entries["/src/main.py"] = entry
index._children["/src"] = ["/src/main.py"]
index._expiry["/src"] = datetime.now(timezone.utc) + timedelta(days=365)
# The root row is what makes this a live index rather than a dropped
# one; without it every read here would be a refill, which is the
# distinction ensure_live_index draws.
index._entries["/src"] = IndexEntry(id="aaa",
name="src",
resource_type="folder")
index._children["/"] = ["/src"]
index._expiry["/"] = datetime.now(timezone.utc) + timedelta(days=365)
return index
# Same rule readdir follows: an expired index is a tree that aged out, so
# `cat` refetches once rather than reporting the file gone.
@pytest.mark.asyncio
async def test_read_refills_an_expired_index(monkeypatch):
index = _index()
await index.invalidate()
calls = []
async def fake_fetch_tree(config, owner, repo, ref):
calls.append(ref)
return {
"src":
TreeEntry(path="src", type="tree", sha="aaa", size=None),
"src/main.py":
TreeEntry(path="src/main.py", type="blob", sha="bbb", size=3),
}, False
async def fake_read_bytes(config, owner, repo, sha):
return b"hi\n"
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
monkeypatch.setattr(mirage.core.github.read, "read_bytes", fake_read_bytes)
accessor = MagicMock()
accessor.truncated = False
out = await read(
accessor,
PathSpec(resource_path="src/main.py",
virtual="/src/main.py",
directory="/src"), index)
assert out == b"hi\n"
assert len(calls) == 1
@pytest.mark.asyncio
async def test_read_does_not_refill_on_a_real_miss(monkeypatch):
index = _index()
calls = []
async def fake_fetch_tree(config, owner, repo, ref):
calls.append(ref)
return {}, False
monkeypatch.setattr(mirage.core.github.tree, "fetch_tree", fake_fetch_tree)
accessor = MagicMock()
accessor.truncated = False
with pytest.raises(FileNotFoundError):
await read(
accessor,
PathSpec(resource_path="src/gone.py",
virtual="/src/gone.py",
directory="/src"), index)
assert calls == []