From ea72e0654d60ae9f24f3d53865826beba7603b25 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Sun, 29 Mar 2026 23:30:32 +0200 Subject: [PATCH] feat: emit subtree creates on dir move-in; normalize FSEvents renames --- src/backend/FSEvents.zig | 11 ++++++++--- src/backend/INotify.zig | 22 ++++++++++++++++++++++ src/backend/KQueue.zig | 30 +++++++++++++++++++++++++++++- src/backend/KQueueDir.zig | 25 ++++++++++++++++++++++++- src/backend/Windows.zig | 29 +++++++++++++++++++++++++++++ src/nightwatch.zig | 1 + src/nightwatch_test.zig | 9 ++++----- src/types.zig | 7 +++++-- 8 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/backend/FSEvents.zig b/src/backend/FSEvents.zig index 4624f2c..d7316b6 100644 --- a/src/backend/FSEvents.zig +++ b/src/backend/FSEvents.zig @@ -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 {}; diff --git a/src/backend/INotify.zig b/src/backend/INotify.zig index 7ec8050..e08bdf3 100644 --- a/src/backend/INotify.zig +++ b/src/backend/INotify.zig @@ -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 diff --git a/src/backend/KQueue.zig b/src/backend/KQueue.zig index b7e38b6..542031c 100644 --- a/src/backend/KQueue.zig +++ b/src/backend/KQueue.zig @@ -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); diff --git a/src/backend/KQueueDir.zig b/src/backend/KQueueDir.zig index c1a8139..be082e7 100644 --- a/src/backend/KQueueDir.zig +++ b/src/backend/KQueueDir.zig @@ -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); diff --git a/src/backend/Windows.zig b/src/backend/Windows.zig index ed0a40b..f2ff9e7 100644 --- a/src/backend/Windows.zig +++ b/src/backend/Windows.zig @@ -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); } diff --git a/src/nightwatch.zig b/src/nightwatch.zig index 7fa107a..5a3e117 100644 --- a/src/nightwatch.zig +++ b/src/nightwatch.zig @@ -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. /// diff --git a/src/nightwatch_test.zig b/src/nightwatch_test.zig index d2e83dd..6d4ed68 100644 --- a/src/nightwatch_test.zig +++ b/src/nightwatch_test.zig @@ -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); diff --git a/src/types.zig b/src/types.zig index 5af8794..cf5695b 100644 --- a/src/types.zig +++ b/src/types.zig @@ -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, };