Implement recursive watching of a directory

This commit is contained in:
T. R. Bernstein
2026-03-12 01:00:39 +01:00
parent d57f998fd4
commit b41b82bd0f
7 changed files with 122 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
{
"originHash" : "db0ba74c125e968c67646390cbba012a5572a5c9c54171588ecbb73e370a448d",
"originHash" : "fd1e824e418c767633bb79b055a4e84d9c86165746bc881d5d27457ad34b0c20",
"pins" : [
{
"identity" : "noora",
@@ -46,6 +46,15 @@
"version" : "1.1.3"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
@@ -64,6 +73,15 @@
"version" : "1.10.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio",
"state" : {
"revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0",
"version" : "2.95.0"
}
},
{
"identity" : "swift-subprocess",
"kind" : "remoteSourceControl",

View File

@@ -18,6 +18,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"),
.package(url: "https://github.com/apple/swift-log", from: "1.10.1"),
.package(url: "https://github.com/apple/swift-nio", from: "2.95.0"),
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.3.0"),
.package(url: "https://github.com/tuist/Noora", from: "0.55.1")
@@ -29,6 +30,7 @@ let package = Package(
dependencies: [
"CInotify",
.product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"),
.product(name: "SystemPackage", package: "swift-system")
]
),

View File

@@ -0,0 +1,44 @@
import _NIOFileSystem
public struct DirectoryResolver {
static let fileManager = FileSystem.shared
public static func resolve(_ paths: String...) async throws -> [FilePath] {
try await Self.resolve(paths)
}
static func resolve(_ paths: [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)
}
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] = []
let directoryHandle = try await fileManager.openDirectory(atPath: path)
for try await childContent in directoryHandle.listContents(recursive: true) {
guard childContent.type == .directory else { continue }
result.append(childContent.path)
}
try await directoryHandle.close()
return result
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
import SystemPackage
public enum DirectoryResolverError: LocalizedError, Equatable {
case pathNotFound(FilePath)
case pathIsNoDirectory(FilePath)
var errorDescription: String {
switch self {
case .pathNotFound(let path):
return "Path not found: \(path)"
case .pathIsNoDirectory(let path):
return "Path is not a directory: \(path)"
}
}
}

View File

@@ -28,6 +28,17 @@ public actor Inotify {
return wd
}
@discardableResult
public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] {
let directoryPaths = try await DirectoryResolver.resolve(path)
var result: [CInt] = []
for path in directoryPaths {
let wd = try self.addWatch(path: path.string, mask: mask)
result.append(wd)
}
return result
}
public func removeWatch(_ wd: CInt) throws {
guard inotify_rm_watch(self.fd, wd) == 0 else {
throw InotifyError.removeWatchFailed(watchDescriptor: wd, errno: cinotify_get_errno())

View File

@@ -0,0 +1,23 @@
import Foundation
import Testing
@testable import Inotify
@Suite("Recursive Event Detection")
struct RecursiveEventTests {
@Test func detectsFileCreationInSubfolder() 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: true
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
#expect(createEvent != nil, "Expected CREATE for '\(filepath)', got: \(events)")
}
}
}

View File

@@ -3,10 +3,15 @@ import Inotify
func getEventsForTrigger(
in dir: String,
mask: InotifyEventMask,
trigger: @escaping (String) async throws -> Void
recursive: Bool = false,
trigger: @escaping (String) async throws -> Void,
) async throws -> [InotifyEvent] {
let watcher = try Inotify()
try await watcher.addWatch(path: dir, mask: mask)
if recursive {
try await watcher.addRecursiveWatch(forDirectory: dir, mask: mask)
} else {
try await watcher.addWatch(path: dir, mask: mask)
}
let eventTask = Task {
var events: [InotifyEvent] = []