From a12c20af33f94cb5fa729b8e41d080fe957fbbd0 Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Sun, 15 Mar 2026 22:30:10 +0100 Subject: [PATCH] Implement watch exclusion lists Allow exclusion of directories when watching recursively. --- Sources/Inotify/DirectoryResolver.swift | 39 ++++++++----------- Sources/Inotify/Inotify.swift | 22 ++++++++++- .../DirectoryResolverTests.swift | 17 ++++++++ .../RecursiveEventTests.swift | 20 +++++++++- .../Utilities/getEventsForTrigger.swift | 2 + 5 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 Tests/InotifyIntegrationTests/DirectoryResolverTests.swift diff --git a/Sources/Inotify/DirectoryResolver.swift b/Sources/Inotify/DirectoryResolver.swift index 4971d8f..b96bba0 100644 --- a/Sources/Inotify/DirectoryResolver.swift +++ b/Sources/Inotify/DirectoryResolver.swift @@ -3,42 +3,35 @@ import _NIOFileSystem public struct DirectoryResolver { static let fileManager = FileSystem.shared - public static func resolve(_ paths: String...) async throws -> [FilePath] { - try await Self.resolve(paths) + public static func resolve(_ paths: String..., excluding itemNames: Set = []) async throws -> [FilePath] { + try await Self.resolve(paths, excluding: itemNames) } - static func resolve(_ paths: [String]) async throws -> [FilePath] { + static func resolve(_ paths: [String], excluding itemNames: Set = []) async throws -> [FilePath] { var resolved: [FilePath] = [] for path in paths { - let itemPath = FilePath(path) - try await Self.ensure(itemPath, is: .directory) - - let allDirectoriesIncludingSelf = try await getAllSubdirectoriesAndSelf(at: itemPath) - resolved.append(contentsOf: allDirectoriesIncludingSelf) + let path = FilePath(path) + resolved.append(path) + try await withSubdirectories(at: path, recursive: true) { subdirectoryPath in + guard let basename = subdirectoryPath.lastComponent?.description else { return } + guard !itemNames.contains(basename) else { return } + resolved.append(subdirectoryPath) + } } return resolved } - private static func ensure(_ path: FilePath, is fileType: FileType) async throws { - guard let fileInfo = try await fileManager.info(forFileAt: path) else { - throw DirectoryResolverError.pathNotFound(path) - } - - guard fileInfo.type == fileType else { - throw DirectoryResolverError.pathIsNoDirectory(path) - } - } - - private static func getAllSubdirectoriesAndSelf(at path: FilePath) async throws -> [FilePath] { - var result: [FilePath] = [] + private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws { let directoryHandle = try await fileManager.openDirectory(atPath: path) - for try await childContent in directoryHandle.listContents(recursive: true) { + for try await childContent in directoryHandle.listContents() { guard childContent.type == .directory else { continue } - result.append(childContent.path) + try await body(childContent.path) + if recursive { + try await withSubdirectories(at: childContent.path, recursive: recursive, body: body) + } } try await directoryHandle.close() - return result } } diff --git a/Sources/Inotify/Inotify.swift b/Sources/Inotify/Inotify.swift index c65ed25..4c492c8 100644 --- a/Sources/Inotify/Inotify.swift +++ b/Sources/Inotify/Inotify.swift @@ -3,6 +3,7 @@ import CInotify public actor Inotify { private let fd: CInt + private var excludedItemNames: Set = [] private var watches = InotifyWatchManager() private var eventReader: any DispatchSourceRead private var eventStream: AsyncStream @@ -18,6 +19,24 @@ public actor Inotify { (self.eventReader, self.eventStream) = Self.createEventReader(forFileDescriptor: fd) } + public func isExcluded(_ name: String) -> Bool { + self.excludedItemNames.contains(name) + } + + public func exclude(name: String) { + self.excludedItemNames.insert(name) + } + + public func exclude(names: String...) { + self.exclude(names: names) + } + + public func exclude(names: [String]) { + for name in names { + self.excludedItemNames.insert(name) + } + } + @discardableResult public func addWatch(path: String, mask: InotifyEventMask) throws -> CInt { let wd = inotify_add_watch(self.fd, path, mask.rawValue) @@ -30,7 +49,7 @@ public actor Inotify { @discardableResult public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] { - let directoryPaths = try await DirectoryResolver.resolve(path) + let directoryPaths = try await DirectoryResolver.resolve(path, excluding: self.excludedItemNames) var result: [CInt] = [] for path in directoryPaths { let wd = try self.addWatch(path: path.string, mask: mask) @@ -59,6 +78,7 @@ public actor Inotify { private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? { guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil } + guard !self.excludedItemNames.contains(rawEvent.name) else { return nil } let event = InotifyEvent.init(from: rawEvent, inDirectory: path) await self.addWatchInCaseOfAutomaticSubtreeWatching(event) return InotifyEvent.init(from: rawEvent, inDirectory: path) diff --git a/Tests/InotifyIntegrationTests/DirectoryResolverTests.swift b/Tests/InotifyIntegrationTests/DirectoryResolverTests.swift new file mode 100644 index 0000000..1d1012c --- /dev/null +++ b/Tests/InotifyIntegrationTests/DirectoryResolverTests.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing +@testable import Inotify + +@Suite("Directory Resolver") +struct DirectoryResolverTests { + @Test func listsDirectoryTree() async throws { + try await withTempDir { dir in + let subDirectory = "\(dir)/Subfolder/Folder 01" + try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true) + let directories = try await DirectoryResolver.resolve(dir) + + #expect(directories.count == 3) + #expect(directories.map { $0.description } == [dir, "\(dir)/Subfolder", subDirectory]) + } + } +} diff --git a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift index 5a012a2..7965a34 100644 --- a/Tests/InotifyIntegrationTests/RecursiveEventTests.swift +++ b/Tests/InotifyIntegrationTests/RecursiveEventTests.swift @@ -21,6 +21,24 @@ struct RecursiveEventTests { } } + @Test func ignoresFileCreationInIgnoredSubfolder() async throws { + try await withTempDir { dir in + let subDirectory = "\(dir)/Subfolder" + let filepath = "\(subDirectory)/modify-target.txt" + try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true) + + let events = try await getEventsForTrigger( + in: dir, + mask: [.create], + recursive: .recursive, + exclude: ["Subfolder"] + ) { _ in try createFile(at: "\(filepath)", contents: "hello") } + + let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath } + #expect(createEvent == nil, "Did not expect CREATE for '\(filepath)', got: \(events)") + } + } + @Test func newSubfoldersOfRecursiveWatchAreAutomaticallyWatchedToo() async throws { try await withTempDir { dir in let subDirectory = "\(dir)/Subfolder" @@ -32,7 +50,7 @@ struct RecursiveEventTests { recursive: .withAutomaticSubtreeWatching ) { _ in try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true) - try await Task.sleep(for: .milliseconds(200)) + try await Task.sleep(for: .milliseconds(400)) try createFile(at: "\(filepath)", contents: "hello") } diff --git a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift index 1f54767..1cbfc97 100644 --- a/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift +++ b/Tests/InotifyIntegrationTests/Utilities/getEventsForTrigger.swift @@ -10,9 +10,11 @@ func getEventsForTrigger( in dir: String, mask: InotifyEventMask, recursive: RecursivKind = .nonrecursive, + exclude: [String] = [], trigger: @escaping (String) async throws -> Void, ) async throws -> [InotifyEvent] { let watcher = try Inotify() + await watcher.exclude(names: exclude) switch recursive { case .nonrecursive: try await watcher.addWatch(path: dir, mask: mask)