Implement watch exclusion lists
Allow exclusion of directories when watching recursively.
This commit is contained in:
18
README.md
18
README.md
@@ -75,6 +75,24 @@ try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||
|
||||
This is the most convenient option when you need full coverage of a growing directory tree.
|
||||
|
||||
## Excluding Items
|
||||
|
||||
You can tell the `Inotify` actor to ignore certain file or directory names. Excluded names are skipped during recursive directory resolution (so no watch is installed on them) and silently dropped from the event stream:
|
||||
|
||||
```swift
|
||||
let inotify = try Inotify()
|
||||
|
||||
// Ignore version-control and build directories
|
||||
await inotify.exclude(names: ".git", "node_modules", ".build")
|
||||
|
||||
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||
forDirectory: "/home/user/project",
|
||||
mask: [.create, .modify, .delete]
|
||||
)
|
||||
```
|
||||
|
||||
Use `isExcluded(_:)` to check whether a name is currently on the exclusion list.
|
||||
|
||||
## Event Masks
|
||||
|
||||
`InotifyEventMask` is an `OptionSet` that mirrors the native inotify flags. You can combine them freely.
|
||||
|
||||
@@ -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<String> = []) 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<String> = []) 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ Beyond single-directory watches, the library provides two higher-level methods f
|
||||
- ``Inotify/Inotify/addRecursiveWatch(forDirectory:mask:)`` installs watches on every existing subdirectory at setup time.
|
||||
- ``Inotify/Inotify/addWatchWithAutomaticSubtreeWatching(forDirectory:mask:)`` does the same **and** automatically watches subdirectories that are created after setup.
|
||||
|
||||
You can also exclude certain file or directory names so that they are skipped during directory resolution and silently dropped from the event stream. See ``Inotify/Inotify/exclude(names:)`` and <doc:WatchingDirectoryTrees> for details.
|
||||
|
||||
All public types conform to `Sendable`, so they can be safely passed across concurrency boundaries.
|
||||
|
||||
## Topics
|
||||
@@ -30,6 +32,10 @@ All public types conform to `Sendable`, so they can be safely passed across conc
|
||||
- ``InotifyEvent``
|
||||
- ``InotifyEventMask``
|
||||
|
||||
### Articles
|
||||
|
||||
- <doc:WatchingDirectoryTrees>
|
||||
|
||||
### Errors
|
||||
|
||||
- ``InotifyError``
|
||||
|
||||
@@ -33,6 +33,22 @@ let descriptors = try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||
|
||||
Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears.
|
||||
|
||||
### Excluding Directories
|
||||
|
||||
When watching large trees you often want to skip certain subdirectories entirely — version-control metadata, build artefacts, dependency caches, and so on. Call ``Inotify/Inotify/exclude(names:)`` **before** adding a recursive or automatic-subtree watch:
|
||||
|
||||
```swift
|
||||
let inotify = try Inotify()
|
||||
await inotify.exclude(names: ".git", "node_modules", ".build")
|
||||
|
||||
try await inotify.addWatchWithAutomaticSubtreeWatching(
|
||||
forDirectory: "/home/user/project",
|
||||
mask: .allEvents
|
||||
)
|
||||
```
|
||||
|
||||
Excluded names are matched against the last path component of each directory during resolution and are also filtered from the event stream, so you never receive events for items whose name is on the exclusion list.
|
||||
|
||||
### Choosing the Right Method
|
||||
|
||||
| Method | Covers existing subdirectories | Covers new subdirectories |
|
||||
|
||||
@@ -3,6 +3,7 @@ import CInotify
|
||||
|
||||
public actor Inotify {
|
||||
private let fd: CInt
|
||||
private var excludedItemNames: Set<String> = []
|
||||
private var watches = InotifyWatchManager()
|
||||
private var eventReader: any DispatchSourceRead
|
||||
private var eventStream: AsyncStream<RawInotifyEvent>
|
||||
@@ -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)
|
||||
|
||||
17
Tests/InotifyIntegrationTests/DirectoryResolverTests.swift
Normal file
17
Tests/InotifyIntegrationTests/DirectoryResolverTests.swift
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user