mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
fix(watch): ignore read-only inotify events so the watcher stops re-triggering on its own reads
On Linux, inotify emits opened / closed_no_write events for every file open and close, including the watcher's own AST rebuild reading the tree, so the watcher fed itself a rebuild loop. Drop those two read-only event types; close-after-write and create/modify/move/delete still trigger, and the filter is a no-op on the macOS/Windows backends that never emit them.
This commit is contained in:
+19
-1
@@ -1930,6 +1930,24 @@ def _batch_triggers_rebuild(batch: list[Path]) -> bool:
|
||||
return has_code or has_deletion
|
||||
|
||||
|
||||
_READ_ONLY_EVENT_TYPES = frozenset({"opened", "closed_no_write"})
|
||||
|
||||
|
||||
def _is_read_only_event(event) -> bool:
|
||||
"""True for watchdog events that mean a file was merely READ, not changed.
|
||||
|
||||
On Linux, inotify (watchdog >= 2.3) reports ``opened`` and, since watchdog 4,
|
||||
``closed_no_write`` for every file open/close — including the watcher's own
|
||||
AST rebuild reading the tree, hook guards stat-ing sources, and editors or
|
||||
agents reading files. Counting those as changes makes the watcher re-trigger
|
||||
itself forever ("N file(s) changed" while nothing was modified), burn CPU and
|
||||
keep re-writing the ``needs_update`` flag. Only creation, modification, move,
|
||||
deletion and close-after-write are changes; macOS (PollingObserver) never
|
||||
emits these, so the filter is a no-op there.
|
||||
"""
|
||||
return getattr(event, "event_type", None) in _READ_ONLY_EVENT_TYPES
|
||||
|
||||
|
||||
def _batch_needs_llm_flag(batch: list[Path]) -> bool:
|
||||
"""True when the batch contains a non-code file that still exists on disk.
|
||||
|
||||
@@ -1977,7 +1995,7 @@ def watch(watch_path: Path, debounce: float = 3.0) -> None:
|
||||
class Handler(FileSystemEventHandler):
|
||||
def on_any_event(self, event):
|
||||
nonlocal last_trigger, pending
|
||||
if event.is_directory:
|
||||
if event.is_directory or _is_read_only_event(event):
|
||||
return
|
||||
path = Path(os.fsdecode(event.src_path))
|
||||
# Check .graphifyignore BEFORE the extension/dotfile/out filters so
|
||||
|
||||
@@ -3769,3 +3769,32 @@ def test_subfolder_marker_incremental_matches_cold_build(tmp_path, monkeypatch):
|
||||
f"incremental vs cold id drift: only-incremental={sorted(incremental_ids - cold_ids)[:5]}, "
|
||||
f"only-cold={sorted(cold_ids - incremental_ids)[:5]}"
|
||||
)
|
||||
|
||||
|
||||
# --- read-only inotify events must not count as changes (#watch-self-trigger) ---
|
||||
|
||||
def test_read_only_events_are_ignored():
|
||||
"""``opened`` / ``closed_no_write`` mean a file was read, not changed."""
|
||||
from graphify.watch import _is_read_only_event
|
||||
|
||||
class E:
|
||||
def __init__(self, t):
|
||||
self.event_type = t
|
||||
|
||||
assert _is_read_only_event(E("opened"))
|
||||
assert _is_read_only_event(E("closed_no_write"))
|
||||
for t in ("created", "modified", "deleted", "moved", "closed"):
|
||||
assert not _is_read_only_event(E(t)), t
|
||||
|
||||
|
||||
def test_read_only_events_with_real_watchdog_classes():
|
||||
pytest.importorskip("watchdog.events")
|
||||
from watchdog import events as we
|
||||
from graphify.watch import _is_read_only_event
|
||||
|
||||
assert _is_read_only_event(we.FileOpenedEvent("/tmp/x.py"))
|
||||
if hasattr(we, "FileClosedNoWriteEvent"):
|
||||
assert _is_read_only_event(we.FileClosedNoWriteEvent("/tmp/x.py"))
|
||||
assert not _is_read_only_event(we.FileModifiedEvent("/tmp/x.py"))
|
||||
assert not _is_read_only_event(we.FileCreatedEvent("/tmp/x.py"))
|
||||
assert not _is_read_only_event(we.FileClosedEvent("/tmp/x.py"))
|
||||
|
||||
Reference in New Issue
Block a user