- Zig 99%
- Shell 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Submodules and worktrees have a .git FILE, so the directory test reported 'not a checkout' and the script tried to clone over an existing directory (platen vendors simgrep/zigmap as submodules, which is how this surfaced). Submodules are now left to `git submodule update`, and a non-checkout directory is reported instead of clobbered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
| scripts | ||
| src | ||
| tests | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| CLAUDE.md | ||
| context.md | ||
| DESIGN.md | ||
| LICENSE | ||
| README.md | ||
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://git.urverk.org/urverk/zentry/archive/<commit>.tar.gz",
.hash = "...",
},
},
Then in your build.zig:
const zentry_mod = b.dependency("zentry", .{
.target = target,
.optimize = optimize,
}).module("zentry");
exe_mod.addImport("zentry", zentry_mod);
Quick start
const std = @import("std");
const zentry = @import("zentry");
fn onChange(events: []const zentry.Event) void {
for (events) |ev| {
std.debug.print("{s}: {s}\n", .{ @tagName(ev.type), ev.path.bytes });
}
}
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Bootstrap an Io implementation (Zig 0.16)
var threaded: std.Io.Threaded = .init(allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var watcher = try zentry.Watcher.init(allocator, io, &onChange);
defer watcher.deinit();
try watcher.addRoot("/path/to/watch", true); // recursive
try watcher.start();
// ... do other work, events arrive via callback ...
watcher.stop();
}
Configuration
var watcher = try zentry.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()
try watcher.addRoot("/project/src", true); // recursive
try watcher.addRoot("/project/config", false); // top-level only
try watcher.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:
try watcher.addRoot("/project/src", true);
try watcher.start();
// ... later, on any thread ...
try watcher.addRoot("/project/extra", true); // returns once watched
Events
Events are delivered as a batch to your callback after the settle window expires:
fn onEvents(events: []const zentry.Event) void {
for (events) |ev| {
switch (ev.type) {
.create => { /* new file or directory */ },
.delete => { /* file or directory removed */ },
.modified => { /* content changed (mtime/size differ) */ },
}
const path = 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
const my_filter = zentry.Filter{
.ignore_fn = &struct {
fn ignore(_: ?*const anyopaque, path: []const u8, is_dir: bool) bool {
_ = is_dir;
return std.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