Reliable file-watching library for Zig.
  • Zig 98.8%
  • Shell 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker 2e19f3c11a
All checks were successful
test / test (push) Successful in 32s
context: Linux CI runs the poll backend (inotify is a stub)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:43:06 +02:00
.forgejo/workflows ci: zig build test on push (Zig 0.17, pinned) 2026-10-06 20:12:27 +02:00
scripts Migrate to Zig 0.17 2026-10-06 12:59:31 +02:00
src zig fmt (0.17) 2026-10-06 12:59:42 +02:00
tests tests: runtime-addRoot tests set a short poll interval 2026-10-06 21:42:07 +02:00
.gitignore Initial project scaffold: architecture, build system, and source stubs 2026-03-02 00:57:20 +01:00
build.zig Dispatch FSEvents per-path instead of recrawling each watch root 2026-05-04 15:37:13 +02:00
build.zig.zon Migrate to Zig 0.17 2026-10-06 12:59:31 +02:00
CLAUDE.md docs: repoint links to git.urverk.org 2026-07-25 11:41:40 +02:00
context.md context: Linux CI runs the poll backend (inotify is a stub) 2026-10-06 21:43:06 +02:00
DESIGN.md Migrate to Zig 0.17 2026-10-06 12:59:31 +02:00
LICENSE Add MIT license 2026-03-02 15:09:59 +01:00
README.md Migrate to Zig 0.17 2026-10-06 12:59:31 +02:00

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 .git filter, or provide your own
  • Runtime root management: addRoot works both before and after start()
  • Platform backends: native FSEvents (macOS), inotify (Linux, planned), polling fallback (everywhere)

Requirements

  • Zig 0.17+

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.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Bootstrap an Io implementation
    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