- Zig 100%
|
Mikael Säker
921bc6b70e
tree: portable inode cast (Windows i64 LARGE_INTEGER → u64)
`std.Io.File.Stat.inode` on Windows is `windows.LARGE_INTEGER` (an i64), not u64 as on POSIX. Cross-compiling to x86_64-windows-gnu failed in statPath with "expected type 'u64', found 'i64'". We only use the inode for identity equality, so bitcasting i64→u64 preserves the bits. Comptime-select between identity (POSIX u64) and @bitCast (Windows i64) so both targets compile clean. Cross-build verified for x86_64-windows-gnu; native macOS tests still green. |
||
|---|---|---|
| src | tree: portable inode cast (Windows i64 LARGE_INTEGER → u64) | |
| tests | Dispatch FSEvents per-path instead of recrawling each watch root | |
| .gitignore | Initial project scaffold: architecture, build system, and source stubs | |
| build.zig | Dispatch FSEvents per-path instead of recrawling each watch root | |
| build.zig.zon | Migrate to Zig 0.16 | |
| CLAUDE.md | Initial project scaffold: architecture, build system, and source stubs | |
| context.md | Update DESIGN.md + prune context.md | |
| DESIGN.md | Update DESIGN.md + prune context.md | |
| LICENSE | Add MIT license | |
| README.md | Update README for per-path dispatch | |
zentry
Reliable file-watching library for Zig.
Raw OS file-watching APIs (inotify, FSEvents, kqueue) are unreliable — events can be missed, duplicated, or misleading. zentry solves this by maintaining an in-memory filesystem snapshot as the source of truth, validating every OS event against it. No changes are ever silently lost.
Inspired by filesentry (Rust).
Features
- Three event types:
create,delete,modified— simple and predictable - Snapshot-based reliability: every OS event is validated against an in-memory file tree
- Per-path dispatch: each OS event is stat-and-compared individually against the snapshot, so callback latency is independent of the watched tree size (~30ms on a 4800-file recursive watch on M1 Pro)
- Settle-time debouncing: events are batched until filesystem activity quiesces
- Queue overflow recovery: if the OS watcher overflows, a full re-crawl diffs against the snapshot
- Inode tracking: detects file replacements (same path, different inode)
- Path filtering: built-in
.gitfilter, or provide your own - Runtime root management:
addRootworks both before and afterstart() - Platform backends: native FSEvents (macOS), inotify (Linux, planned), polling fallback (everywhere)
Requirements
- Zig 0.16+
Usage
Add zentry as a dependency in your build.zig.zon:
.dependencies=.{.zentry=.{.url="https://codeberg.org/sicher/zentry/archive/<commit>.tar.gz",.hash="...",},},Then in your build.zig:
constzentry_mod=b.dependency("zentry",.{.target=target,.optimize=optimize,}).module("zentry");exe_mod.addImport("zentry",zentry_mod);Quick start
conststd=@import("std");constzentry=@import("zentry");fnonChange(events:[]constzentry.Event)void{for(events)|ev|{std.debug.print("{s}: {s}\n",.{@tagName(ev.type),ev.path.bytes});}}pubfnmain()!void{vargpa:std.heap.GeneralPurposeAllocator(.{})=.init;defer_=gpa.deinit();constallocator=gpa.allocator();// Bootstrap an Io implementation (Zig 0.16)varthreaded:std.Io.Threaded=.init(allocator,.{});deferthreaded.deinit();constio=threaded.io();varwatcher=tryzentry.Watcher.init(allocator,io,&onChange);deferwatcher.deinit();trywatcher.addRoot("/path/to/watch",true);// recursivetrywatcher.start();// ... do other work, events arrive via callback ...watcher.stop();}Configuration
varwatcher=tryzentry.Watcher.init(allocator,io,&callback);// Settle time: how long to wait for activity to quiesce before// delivering events (default: 50ms)watcher.setSettleTime(100);// Poll interval: how often the polling fallback checks for changes// (default: 1000ms, ignored when using native backends)watcher.setPollInterval(500);// Path filter: skip paths you don't care about// Built-in: Filter.default (skips .git), Filter.none (skips nothing)watcher.setFilter(zentry.Filter.none);// Add roots before calling start()trywatcher.addRoot("/project/src",true);// recursivetrywatcher.addRoot("/project/config",false);// top-level onlytrywatcher.start();Adding roots at runtime
addRoot also works after start(). Calls dispatch to the worker thread,
which performs the initial crawl, then signal completion. The call blocks
until the new root is fully registered, so errors (e.g. nonexistent path)
are returned synchronously to the caller:
trywatcher.addRoot("/project/src",true);trywatcher.start();// ... later, on any thread ...trywatcher.addRoot("/project/extra",true);// returns once watchedEvents
Events are delivered as a batch to your callback after the settle window expires:
fnonEvents(events:[]constzentry.Event)void{for(events)|ev|{switch(ev.type){.create=>{/*newfileordirectory*/},.delete=>{/*fileordirectoryremoved*/},.modified=>{/*contentchanged(mtime/sizediffer)*/},}constpath=ev.path.bytes;// absolute, normalized path}}Renames are expressed as a delete + create pair. Tempfiles (created and deleted within the settle window) are merged and discarded — your callback never sees them.
Custom filter
constmy_filter=zentry.Filter{.ignore_fn=&struct{fnignore(_:?*constanyopaque,path:[]constu8,is_dir:bool)bool{_=is_dir;returnstd.mem.endsWith(u8,path,".tmp");}}.ignore,.context=null,};watcher.setFilter(my_filter);Architecture
Watcher (public API)
└─ Worker (background thread)
├─ Backend (FSEvents / inotify / poll)
├─ FileTree (in-memory snapshot)
└─ EventDebouncer (merge/dedup)
The worker thread loops: wait for backend notification → for each changed path, stat-and-compare against the snapshot → debounce → deliver settled events via callback. New directories swept in via rename-into-watch trigger an eager subtree crawl so their pre-existing contents get tracked. The full-tree recrawl path is reserved for the rare case where the OS reports queue overflow.
Testing
zig build test # unit + integration + stress + perf
zig build perf # perf tests only (latency on deep+wide trees)
License
MIT