Reliable file-watching library for Zig.
  • Zig 99%
  • Shell 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Mikael Säker f8b4137f4f setup-tools: detect checkouts with rev-parse, not [ -d .git ]
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>
2026-07-25 23:33:06 +02:00
scripts setup-tools: detect checkouts with rev-parse, not [ -d .git ] 2026-07-25 23:33:06 +02:00
src tree: portable inode cast (Windows i64 LARGE_INTEGER → u64) 2026-05-19 16:06:32 +02:00
tests Dispatch FSEvents per-path instead of recrawling each watch root 2026-05-04 15:37:13 +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.16 2026-04-30 12:39:21 +02:00
CLAUDE.md docs: repoint links to git.urverk.org 2026-07-25 11:41:40 +02:00
context.md Update DESIGN.md + prune context.md 2026-05-04 15:49:15 +02:00
DESIGN.md Update DESIGN.md + prune context.md 2026-05-04 15:49:15 +02:00
LICENSE Add MIT license 2026-03-02 15:09:59 +01:00
README.md docs: repoint links to git.urverk.org 2026-07-25 11:41:40 +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.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