diff --git a/src/beaver_gateway/vault/watch.py b/src/beaver_gateway/vault/watch.py index df48ef0..778e435 100644 --- a/src/beaver_gateway/vault/watch.py +++ b/src/beaver_gateway/vault/watch.py @@ -19,12 +19,14 @@ from pathlib import Path from typing import TYPE_CHECKING from zoneinfo import ZoneInfo -from watchfiles import awatch +from watchfiles import DefaultFilter, awatch if TYPE_CHECKING: from collections.abc import Iterable -__all__ = ["Change", "VaultWatch", "WatchRules"] + from watchfiles import Change as FileChange + +__all__ = ["Change", "VaultFilter", "VaultWatch", "WatchRules"] _log = logging.getLogger("beaver_gateway.vault.watch") @@ -74,6 +76,26 @@ class _Seen: count: int +IGNORE_DIRS = (".obsidian", ".trash", ".git") +"""Housekeeping directories whose churn never means an edit of the vault.""" + +IGNORE_NAMES = (".sync.lock", "*.lock", "*.tmp", "*~") +"""Lock and scratch file names, matched against the entity name.""" + + +class VaultFilter(DefaultFilter): + """Drops housekeeping paths inside ``awatch``, before the loop is woken.""" + + def __init__(self) -> None: + super().__init__(ignore_dirs=(*DefaultFilter.ignore_dirs, *IGNORE_DIRS)) + + def __call__(self, change: FileChange, path: str) -> bool: + name = Path(path).name + if any(fnmatch.fnmatchcase(name, pattern) for pattern in IGNORE_NAMES): + return False + return super().__call__(change, path) + + class VaultWatch: def __init__( self, root: Path, rules: WatchRules, *, tz: str = "UTC", debounce: float = 4.0 @@ -98,6 +120,7 @@ class VaultWatch: _log.info("watching %s (%d files in snapshot)", self.root, len(self._seen)) async for changes in awatch( self.root, + watch_filter=VaultFilter(), debounce=int(self._debounce * 1000), stop_event=self._stop, ignore_permission_denied=True, diff --git a/tests/test_watch.py b/tests/test_watch.py new file mode 100644 index 0000000..4c7fec5 --- /dev/null +++ b/tests/test_watch.py @@ -0,0 +1,63 @@ +import tempfile +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +from watchfiles import Change as FileChange + +from beaver_gateway.vault import watch as watch_module +from beaver_gateway.vault.watch import VaultFilter, VaultWatch, WatchRules + +VAULT = Path("/vault") +FILTER = VaultFilter() + + +def wakes(rel: str) -> bool: + return FILTER(FileChange.modified, str(VAULT / rel)) + + +def test_obsidian_never_wakes_the_watcher() -> None: + assert not wakes(".obsidian/.sync.lock") + assert not wakes(".obsidian/.sync.lock/data") + assert not wakes(".obsidian") + assert not wakes(".obsidian/workspace.json") + assert not wakes(".obsidian/plugins/obsidian-git/data.json") + + +def test_other_service_paths_never_wake_the_watcher() -> None: + assert not wakes(".trash/📅 дни/2026-09-01.md") + assert not wakes(".git/index") + + +def test_notes_still_wake_the_watcher() -> None: + assert wakes("📅 дни/2026-09-09.md") + assert wakes("мета/бобер/состояние.md") + assert wakes("заметка.md") + assert wakes("💻 проекты/бобер/план.md") + + +def test_lock_beside_a_note_is_quiet_but_the_note_is_not() -> None: + assert not wakes("📅 дни/.2026-09-09.md.lock") + assert not wakes("📅 дни/2026-09-09.md.tmp") + assert not wakes("📅 дни/2026-09-09.md~") + assert wakes("📅 дни/2026-09-09.md") + + +async def test_the_filter_is_applied_by_awatch_itself( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, object] = {} + + async def fake_awatch( + *paths: object, **kwargs: object + ) -> AsyncIterator[set[object]]: + seen.update(kwargs) + seen["paths"] = paths + for change in (): + yield change + + monkeypatch.setattr(watch_module, "awatch", fake_awatch) + root = Path(tempfile.mkdtemp(prefix="beaver-vault-")) + watch = VaultWatch(root, WatchRules(names=("**",)), tz="UTC") + await watch.run() + assert isinstance(seen["watch_filter"], VaultFilter)