feat: emit subtree creates on dir move-in; normalize FSEvents renames

This commit is contained in:
CJ van den Berg 2026-03-29 23:30:32 +02:00
parent bfd1125449
commit ea72e0654d
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
8 changed files with 122 additions and 12 deletions

View file

@ -7,8 +7,9 @@ const ObjectType = types.ObjectType;
pub const watches_recursively = true; // FSEventStreamCreate watches the entire subtree
pub const detects_file_modifications = true;
pub const emits_close_events = false;
pub const emits_rename_for_files = true;
pub const emits_rename_for_dirs = true;
pub const emits_rename_for_files = false;
pub const emits_rename_for_dirs = false;
pub const emits_subtree_created_on_movein = false; // FSEvents emits per-path events only; no subtree synthesis
handler: *Handler,
stream: ?*anyopaque, // FSEventStreamRef
@ -235,7 +236,11 @@ fn callback(
ctx.handler.change(path, .deleted, ot) catch {};
}
if (flags & kFSEventStreamEventFlagItemRenamed != 0) {
ctx.handler.change(path, .renamed, ot) catch {};
// FSEvents fires ItemRenamed for both sides of a rename unpaired.
// Normalize to created/deleted based on whether the path still exists,
// so move-in appears as created and move-out as deleted on all platforms.
const exists = if (std.fs.accessAbsolute(path, .{})) |_| true else |_| false;
ctx.handler.change(path, if (exists) .created else .deleted, ot) catch {};
}
if (flags & kFSEventStreamEventFlagItemModified != 0) {
ctx.handler.change(path, .modified, ot) catch {};

View file

@ -38,6 +38,7 @@ pub fn Create(comptime variant: InterfaceType) type {
pub const emits_close_events = true;
pub const emits_rename_for_files = true;
pub const emits_rename_for_dirs = true;
pub const emits_subtree_created_on_movein = true;
pub const polling = variant == .polling;
const WatchEntry = struct { path: []u8, is_dir: bool };
@ -211,6 +212,26 @@ pub fn Create(comptime variant: InterfaceType) type {
}
}
// Walk dir_path recursively, emitting a 'created' event for every file and
// subdirectory. Called after an unpaired IN_MOVED_TO for a directory so that
// the caller sees individual 'created' events for the moved-in tree contents.
fn emit_subtree_created(self: *@This(), dir_path: []const u8) error{HandlerFailed}!void {
var dir = std.fs.openDirAbsolute(dir_path, .{ .iterate = true }) catch return;
defer dir.close();
var iter = dir.iterate();
while (iter.next() catch return) |entry| {
const ot: ObjectType = switch (entry.kind) {
.file => .file,
.directory => .dir,
else => continue,
};
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, entry.name }) catch continue;
try self.handler.change(full_path, EventType.created, ot);
if (ot == .dir) try self.emit_subtree_created(full_path);
}
}
pub fn handle_read_ready(self: *@This(), allocator: std.mem.Allocator) (std.posix.ReadError || error{ NoSpaceLeft, OutOfMemory, HandlerFailed })!void {
const InotifyEvent = extern struct {
wd: i32,
@ -312,6 +333,7 @@ pub fn Create(comptime variant: InterfaceType) type {
// No paired MOVED_FROM, file was moved in from outside the watched tree.
const ot: ObjectType = if (ev.mask & IN.ISDIR != 0) .dir else .file;
try self.handler.change(full_path, EventType.created, ot);
if (ot == .dir) try self.emit_subtree_created(full_path);
}
} else if (ev.mask & IN.MOVE_SELF != 0) {
// Suppress if the rename was already delivered as a paired

View file

@ -9,6 +9,7 @@ pub const detects_file_modifications = true;
pub const emits_close_events = false;
pub const emits_rename_for_files = false;
pub const emits_rename_for_dirs = true;
pub const emits_subtree_created_on_movein = true;
handler: *Handler,
kq: std.posix.fd_t,
@ -282,8 +283,10 @@ fn scan_dir(self: *@This(), allocator: std.mem.Allocator, dir_path: []const u8)
// Emit all events outside the lock so handlers may safely call watch()/unwatch().
// Emit created dirs, then deletions, then creations. Deletions first ensures that
// a rename (old disappears, new appears) reports the source path before the dest.
for (new_dirs.items) |full_path|
for (new_dirs.items) |full_path| {
try self.handler.change(full_path, EventType.created, .dir);
try self.emit_subtree_created(allocator, full_path);
}
for (to_delete.items) |name| {
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, name }) catch continue;
@ -299,6 +302,31 @@ fn scan_dir(self: *@This(), allocator: std.mem.Allocator, dir_path: []const u8)
// arena.deinit() frees current_files, current_dirs, new_dirs, and list metadata
}
// Walk dir_path recursively, emitting 'created' events and registering per-file
// vnode watches for modification tracking. Called after a new dir appears in
// scan_dir (e.g. a directory moved into the watched tree) so callers see
// individual 'created' events for all pre-existing contents.
fn emit_subtree_created(self: *@This(), allocator: std.mem.Allocator, dir_path: []const u8) !void {
var dir = std.fs.openDirAbsolute(dir_path, .{ .iterate = true }) catch return;
defer dir.close();
var iter = dir.iterate();
while (iter.next() catch return) |entry| {
const ot: ObjectType = switch (entry.kind) {
.file => .file,
.directory => .dir,
else => continue,
};
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, entry.name }) catch continue;
try self.handler.change(full_path, EventType.created, ot);
if (ot == .file) {
self.register_file_watch(allocator, full_path);
} else {
try self.emit_subtree_created(allocator, full_path);
}
}
}
fn register_file_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) void {
self.file_watches_mutex.lock();
const already = self.file_watches.contains(path);

View file

@ -9,6 +9,7 @@ pub const detects_file_modifications = false;
pub const emits_close_events = false;
pub const emits_rename_for_files = false;
pub const emits_rename_for_dirs = true;
pub const emits_subtree_created_on_movein = true;
pub const WatchEntry = struct { fd: std.posix.fd_t, is_file: bool };
handler: *Handler,
@ -276,8 +277,10 @@ fn scan_dir(self: *@This(), allocator: std.mem.Allocator, dir_path: []const u8)
// Emit all events outside the lock so handlers may safely call watch()/unwatch().
// Order: new dirs, deletions (source before dest for renames), creations, modifications.
for (new_dirs.items) |full_path|
for (new_dirs.items) |full_path| {
try self.handler.change(full_path, EventType.created, .dir);
try self.emit_subtree_created(full_path);
}
for (to_delete.items) |name| {
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, name }) catch continue;
@ -296,6 +299,26 @@ fn scan_dir(self: *@This(), allocator: std.mem.Allocator, dir_path: []const u8)
// arena.deinit() frees current_files, current_dirs, new_dirs, and list metadata
}
// Walk dir_path recursively, emitting 'created' events for all contents.
// Called after a new dir appears in scan_dir so callers see individual
// 'created' events for the pre-existing tree of a moved-in directory.
fn emit_subtree_created(self: *@This(), dir_path: []const u8) error{HandlerFailed}!void {
var dir = std.fs.openDirAbsolute(dir_path, .{ .iterate = true }) catch return;
defer dir.close();
var iter = dir.iterate();
while (iter.next() catch return) |entry| {
const ot: ObjectType = switch (entry.kind) {
.file => .file,
.directory => .dir,
else => continue,
};
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ dir_path, entry.name }) catch continue;
try self.handler.change(full_path, EventType.created, ot);
if (ot == .dir) try self.emit_subtree_created(full_path);
}
}
pub fn add_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8) error{ WatchFailed, OutOfMemory }!void {
self.watches_mutex.lock();
const already = self.watches.contains(path);

View file

@ -9,6 +9,7 @@ pub const detects_file_modifications = true;
pub const emits_close_events = false;
pub const emits_rename_for_files = true;
pub const emits_rename_for_dirs = true;
pub const emits_subtree_created_on_movein = true;
const windows = std.os.windows;
@ -279,6 +280,13 @@ fn thread_fn(
watches_mutex.lock();
break;
};
if (event_type == .created and object_type == .dir) {
emit_subtree_created(handler, full_path) catch |e| {
std.log.err("nightwatch: handler returned {s}, stopping watch thread", .{@errorName(e)});
watches_mutex.lock();
break;
};
}
watches_mutex.lock();
if (next_entry_offset == 0) break;
offset += next_entry_offset;
@ -341,6 +349,27 @@ pub fn add_watch(self: *@This(), allocator: std.mem.Allocator, path: []const u8)
// Called from add_watch and from thread_fn on directory renames so that
// FILE_ACTION_REMOVED events for children of a renamed directory can
// resolve their ObjectType from cache.
// Walk dir_path recursively, emitting 'created' events for all contents.
// Called after a directory is created or moved into the watched tree so callers
// see individual 'created' events for all pre-existing contents.
// Must be called with watches_mutex NOT held (handler calls are outside the lock).
fn emit_subtree_created(handler: *Handler, dir_path: []const u8) error{HandlerFailed}!void {
var dir = std.fs.openDirAbsolute(dir_path, .{ .iterate = true }) catch return;
defer dir.close();
var iter = dir.iterate();
while (iter.next() catch return) |entry| {
const ot: ObjectType = switch (entry.kind) {
.file => .file,
.directory => .dir,
else => continue,
};
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const full_path = std.fmt.bufPrint(&path_buf, "{s}\\{s}", .{ dir_path, entry.name }) catch continue;
try handler.change(full_path, EventType.created, ot);
if (ot == .dir) try emit_subtree_created(handler, full_path);
}
}
fn scan_path_types(self: *@This(), allocator: std.mem.Allocator, root: []const u8) void {
scan_path_types_into(allocator, &self.path_types, root);
}

View file

@ -117,6 +117,7 @@ pub fn Create(comptime variant: Variant) type {
pub const emits_close_events = Backend.emits_close_events;
pub const emits_rename_for_files = Backend.emits_rename_for_files;
pub const emits_rename_for_dirs = Backend.emits_rename_for_dirs;
pub const emits_subtree_created_on_movein = Backend.emits_subtree_created_on_movein;
/// Create a new watcher.
///

View file

@ -678,11 +678,8 @@ fn testMoveOutFile(comptime Watcher: type, allocator: std.mem.Allocator) !void {
try std.fs.renameAbsolute(src_path, dst_path);
try drainEvents(Watcher, &watcher);
// File moved out of the watched tree: appears as deleted (INotify, Windows)
// or renamed (kqueue, which holds a vnode fd and sees NOTE_RENAME).
const src_gone = th.hasChange(src_path, .deleted, .file) or
th.hasChange(src_path, .renamed, .file);
try std.testing.expect(src_gone);
// File moved out of the watched tree: appears as deleted on all platforms.
try std.testing.expect(th.hasChange(src_path, .deleted, .file));
// No event for the destination - it is in an unwatched directory.
try std.testing.expect(!th.hasChange(dst_path, .created, .file));
}
@ -767,6 +764,8 @@ fn testMoveInSubdir(comptime Watcher: type, allocator: std.mem.Allocator) !void
try drainEvents(Watcher, &watcher);
try std.testing.expect(th.hasChange(dst_sub, .created, .dir));
if (comptime Watcher.emits_subtree_created_on_movein)
try std.testing.expect(th.hasChange(dst_file, .created, .file));
// Delete the file inside the moved-in subdir.
try std.fs.deleteFileAbsolute(dst_file);

View file

@ -38,8 +38,11 @@ pub const EventType = enum {
/// watched directory are detected indirectly via directory-level
/// `NOTE_WRITE` events and appear as `deleted` + `created`.
///
/// - **FSEvents**: each path involved in a rename receives its own
/// `renamed` change event; the two sides are not paired.
/// - **FSEvents**: renames are normalized to `deleted` (old path) and
/// `created` (new path) via an existence check at event time. The two
/// sides are not paired. Move-in and move-out therefore appear as
/// `created` and `deleted` respectively, consistent with all other
/// backends.
renamed,
};