Compare commits
5 Commits
2.2.0
...
e153f15d43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e153f15d43 | ||
|
|
430713c741 | ||
|
|
4efc27dd3a | ||
|
|
239374704a | ||
|
|
39fe5c9237 |
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Generate Docs
|
||||
run: |
|
||||
swift package add-dependency --from 1.4.0 "https://github.com/apple/swift-docc-plugin.git"
|
||||
for target in Inotify InotifyTaskCLI; do
|
||||
for target in Inotify TaskCLI; do
|
||||
lower="${target,,}"
|
||||
mkdir -p "./public/$lower"
|
||||
swift package --allow-writing-to-directory "./public/$lower" \
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
cp ./.github/workflows/index.tpl.html public/index.html
|
||||
sed -i -e 's/{{project.name}}/Swift Inotify/g' public/index.html
|
||||
sed -i -e 's/{{project.tagline}}/🗂️ Monitor filesystem events on Linux using modern Swift concurrency/g' public/index.html
|
||||
sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html
|
||||
sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="taskcli/documentation/taskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "17ce26ba5c862ca674cd3ceeb43a9fe8a5c5251c5561de65e632a06d79916342",
|
||||
"originHash" : "fd1e824e418c767633bb79b055a4e84d9c86165746bc881d5d27457ad34b0c20",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "noora",
|
||||
@@ -33,8 +33,17 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b",
|
||||
"version" : "1.7.1"
|
||||
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
|
||||
"version" : "1.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,10 +8,15 @@ let package = Package(
|
||||
.library(
|
||||
name: "Inotify",
|
||||
targets: ["Inotify"]
|
||||
),
|
||||
.executable(
|
||||
name: "task",
|
||||
targets: ["TaskCLI"]
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"),
|
||||
.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"),
|
||||
@@ -37,15 +42,15 @@ let package = Package(
|
||||
],
|
||||
),
|
||||
.executableTarget(
|
||||
name: "InotifyTaskCLI",
|
||||
name: "TaskCLI",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "_NIOFileSystem", package: "swift-nio"),
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Noora", package: "Noora")
|
||||
],
|
||||
path: "Sources/TaskCLI"
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
29
README.md
29
README.md
@@ -75,24 +75,6 @@ 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.
|
||||
@@ -131,15 +113,10 @@ try inotify.removeWatch(wd)
|
||||
|
||||
## Build Tool
|
||||
|
||||
The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It automates running tests and generating documentation inside Linux Docker containers, so you can validate everything on the correct platform even when developing on macOS.
|
||||
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
|
||||
|
||||
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||
|
||||
### Tests
|
||||
The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It spins up a Docker container running `swift:latest` on Linux and executes the full test suite inside it, so you can validate everything on the correct platform even when developing on macOS.
|
||||
|
||||
```bash
|
||||
./task.sh test
|
||||
swift run task test
|
||||
```
|
||||
|
||||
Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation).
|
||||
@@ -151,7 +128,7 @@ Docker must be installed and running on your machine.
|
||||
Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with:
|
||||
|
||||
```bash
|
||||
./task.sh generate-docs
|
||||
swift run task generate-docs
|
||||
```
|
||||
|
||||
Then open the files in the newly created `public` folder.
|
||||
|
||||
@@ -3,35 +3,42 @@ import _NIOFileSystem
|
||||
public struct DirectoryResolver {
|
||||
static let fileManager = FileSystem.shared
|
||||
|
||||
public static func resolve(_ paths: String..., excluding itemNames: Set<String> = []) async throws -> [FilePath] {
|
||||
try await Self.resolve(paths, excluding: itemNames)
|
||||
public static func resolve(_ paths: String...) async throws -> [FilePath] {
|
||||
try await Self.resolve(paths)
|
||||
}
|
||||
|
||||
static func resolve(_ paths: [String], excluding itemNames: Set<String> = []) async throws -> [FilePath] {
|
||||
static func resolve(_ paths: [String]) async throws -> [FilePath] {
|
||||
var resolved: [FilePath] = []
|
||||
|
||||
for path in paths {
|
||||
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)
|
||||
}
|
||||
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 withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws {
|
||||
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() {
|
||||
for try await childContent in directoryHandle.listContents(recursive: true) {
|
||||
guard childContent.type == .directory else { continue }
|
||||
try await body(childContent.path)
|
||||
if recursive {
|
||||
try await withSubdirectories(at: childContent.path, recursive: recursive, body: body)
|
||||
}
|
||||
result.append(childContent.path)
|
||||
}
|
||||
try await directoryHandle.close()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ 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
|
||||
@@ -32,10 +30,6 @@ All public types conform to `Sendable`, so they can be safely passed across conc
|
||||
- ``InotifyEvent``
|
||||
- ``InotifyEventMask``
|
||||
|
||||
### Articles
|
||||
|
||||
- <doc:WatchingDirectoryTrees>
|
||||
|
||||
### Errors
|
||||
|
||||
- ``InotifyError``
|
||||
|
||||
@@ -33,22 +33,6 @@ 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,11 +3,10 @@ import CInotify
|
||||
|
||||
public actor Inotify {
|
||||
private let fd: CInt
|
||||
private var excludedItemNames: Set<String> = []
|
||||
private var watches = InotifyWatchManager()
|
||||
private var eventReader: any DispatchSourceRead
|
||||
private nonisolated let eventStream: AsyncStream<RawInotifyEvent>
|
||||
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
|
||||
private var eventStream: AsyncStream<RawInotifyEvent>
|
||||
public var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
|
||||
self.eventStream.compactMap(self.transform(_:))
|
||||
}
|
||||
|
||||
@@ -19,24 +18,6 @@ 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)
|
||||
@@ -49,7 +30,7 @@ public actor Inotify {
|
||||
|
||||
@discardableResult
|
||||
public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] {
|
||||
let directoryPaths = try await DirectoryResolver.resolve(path, excluding: self.excludedItemNames)
|
||||
let directoryPaths = try await DirectoryResolver.resolve(path)
|
||||
var result: [CInt] = []
|
||||
for path in directoryPaths {
|
||||
let wd = try self.addWatch(path: path.string, mask: mask)
|
||||
@@ -78,7 +59,6 @@ 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)
|
||||
|
||||
@@ -3,15 +3,27 @@ import _NIOFileSystem
|
||||
public struct DoccFinder {
|
||||
static let fileManager = FileSystem.shared
|
||||
|
||||
public static func hasDoccFolder(at path: String) async throws -> Bool {
|
||||
let itemPath = FilePath(path)
|
||||
var hasDoccFolder = false
|
||||
public static func getTargetsWithDocumentation(at paths: String...) async throws -> [String] {
|
||||
try await Self.getTargetsWithDocumentation(at: paths)
|
||||
}
|
||||
|
||||
try await withSubdirectories(at: itemPath) { subdirectory in
|
||||
guard subdirectory.description.hasSuffix(".docc") else { return }
|
||||
hasDoccFolder = true
|
||||
static func getTargetsWithDocumentation(at paths: [String]) async throws -> [String] {
|
||||
var resolved: [String] = []
|
||||
|
||||
for path in paths {
|
||||
let itemPath = FilePath(path)
|
||||
|
||||
try await withSubdirectories(at: itemPath) { targetPath in
|
||||
print("Target path is", targetPath.description)
|
||||
try await withSubdirectories(at: targetPath) { subdirectory in
|
||||
guard subdirectory.description.hasSuffix(".docc") else { return }
|
||||
guard let target = targetPath.lastComponent?.description else { return }
|
||||
resolved.append(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasDoccFolder
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
struct Docker {
|
||||
static func getLinuxPlatformStringWithHostArchitecture() -> String {
|
||||
#if arch(x86_64)
|
||||
return "linux/amd64"
|
||||
#else
|
||||
return "linux/arm64"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import ArgumentParser
|
||||
import AsyncAlgorithms
|
||||
import Foundation
|
||||
import Logging
|
||||
import Noora
|
||||
@@ -42,21 +43,28 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
let script = Self.makeRunScript(for: targets)
|
||||
|
||||
logger.debug("Container script", metadata: ["script": "\(script)"])
|
||||
let dockerRunResult = try await Subprocess.run(
|
||||
let dockerResult = try await Subprocess.run(
|
||||
.name("docker"),
|
||||
arguments: [
|
||||
"run", "--rm",
|
||||
"-v", "\(tempDirectory.path):/code",
|
||||
"-v", "swift-inotify-build-cache:/code/.build",
|
||||
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
|
||||
"-v", "\(tempDirectory.path(percentEncoded: false)):/code",
|
||||
"--platform", "linux/arm64",
|
||||
"-w", "/code",
|
||||
"swift:latest",
|
||||
"/bin/bash", "-c", script
|
||||
"/bin/bash", "-c", script,
|
||||
],
|
||||
output: .standardOutput,
|
||||
error: .standardError
|
||||
)
|
||||
if !dockerRunResult.terminationStatus.isSuccess {
|
||||
preferredBufferSize: 10,
|
||||
) { execution, standardInput, standardOutput, standardError in
|
||||
print("")
|
||||
let stdout = standardOutput.lines()
|
||||
let stderr = standardError.lines()
|
||||
for try await line in merge(stdout, stderr) {
|
||||
noora.passthrough("\(line)")
|
||||
}
|
||||
print("")
|
||||
}
|
||||
|
||||
guard dockerResult.terminationStatus.isSuccess else {
|
||||
noora.error("Documentation generation failed.")
|
||||
return
|
||||
}
|
||||
@@ -69,7 +77,9 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
|
||||
noora.success(
|
||||
.alert("Documentation generated successfully.",
|
||||
takeaways: ["Start a local web server with ./public as document root, i.e. with python3 -m http.server to browse the documentation."]
|
||||
takeaways: targets.map {
|
||||
"./public/\($0.lowercased())/"
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -82,7 +92,7 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"),
|
||||
("{{project.links}}", """
|
||||
<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li>\
|
||||
<li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>
|
||||
<li><a href="taskcli/documentation/taskcli/">TaskCLI</a>: The project build command.</li>
|
||||
"""),
|
||||
]
|
||||
|
||||
@@ -98,37 +108,9 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
private static func targets(for projectDirectory: URL) async throws -> [String] {
|
||||
let packages = try await Self.packageTargets()
|
||||
var packagesWithDoccFolder: [(name: String, path: String)] = []
|
||||
for package in packages {
|
||||
guard try await DoccFinder.hasDoccFolder(at: package.path) else { continue }
|
||||
packagesWithDoccFolder.append(package)
|
||||
}
|
||||
return packagesWithDoccFolder.map { $0.name }
|
||||
}
|
||||
|
||||
private static func packageTargets() async throws -> [(name: String, path: String)] {
|
||||
let packageDescriptionResult = try await Subprocess.run(
|
||||
.name("swift"),
|
||||
arguments: ["package", "describe", "--type", "json"],
|
||||
output: .data(limit: 10_000),
|
||||
error: .standardError
|
||||
)
|
||||
|
||||
struct PackageDescription: Codable {
|
||||
let targets: [Target]
|
||||
}
|
||||
struct Target: Codable {
|
||||
let name: String
|
||||
let path: String
|
||||
}
|
||||
|
||||
if !packageDescriptionResult.terminationStatus.isSuccess {
|
||||
throw GenerateDocumentationError.unableToReadPackageDescription
|
||||
}
|
||||
|
||||
let package = try JSONDecoder().decode(PackageDescription.self, from: packageDescriptionResult.standardOutput)
|
||||
return package.targets.map { ($0.name, $0.path) }
|
||||
let sourcesDirectory = projectDirectory.appending(path: "Sources").path
|
||||
let testsDirectory = projectDirectory.appending(path: "Tests").path
|
||||
return try await DoccFinder.getTargetsWithDocumentation(at: sourcesDirectory, testsDirectory)
|
||||
}
|
||||
|
||||
private static func makeRunScript(for targets: [String]) -> String {
|
||||
@@ -172,16 +154,15 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
// MARK: - Dependency Injection
|
||||
|
||||
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
|
||||
let swiftRunResult = try await Subprocess.run(
|
||||
let result = try await Subprocess.run(
|
||||
.name("swift"),
|
||||
arguments: [
|
||||
"package", "--package-path", directory.path(percentEncoded: false),
|
||||
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
|
||||
],
|
||||
output: .standardOutput,
|
||||
error: .standardError
|
||||
)
|
||||
if !swiftRunResult.terminationStatus.isSuccess {
|
||||
) { _ in }
|
||||
|
||||
guard result.terminationStatus.isSuccess else {
|
||||
throw GenerateDocumentationError.dependencyInjectionFailed
|
||||
}
|
||||
|
||||
@@ -191,14 +172,11 @@ struct GenerateDocumentationCommand: AsyncParsableCommand {
|
||||
|
||||
enum GenerateDocumentationError: Error, CustomStringConvertible {
|
||||
case dependencyInjectionFailed
|
||||
case unableToReadPackageDescription
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .dependencyInjectionFailed:
|
||||
"Failed to add swift-docc-plugin dependency to Package.swift."
|
||||
case .unableToReadPackageDescription:
|
||||
"Failed to read the package description."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ``InotifyTaskCLI``
|
||||
# ``TaskCLI``
|
||||
|
||||
The build tool for the Swift Inotify project.
|
||||
|
||||
@@ -6,14 +6,10 @@ The build tool for the Swift Inotify project.
|
||||
|
||||
`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS.
|
||||
|
||||
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
|
||||
|
||||
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||
|
||||
### Running the Tests
|
||||
|
||||
```bash
|
||||
./task.sh test
|
||||
swift run task test
|
||||
```
|
||||
|
||||
This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes:
|
||||
@@ -26,7 +22,7 @@ The container is started with `--security-opt systempaths=unconfined` so that th
|
||||
### Generating Documentation
|
||||
|
||||
```bash
|
||||
./task.sh generate-documentation
|
||||
swift run task generate-documentation
|
||||
```
|
||||
|
||||
This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages.
|
||||
@@ -62,4 +58,4 @@ Docker must be installed and running on the host machine. The container uses the
|
||||
|
||||
### Errors
|
||||
|
||||
- ``GenerateDocumentationError``
|
||||
- ``GenerateDocsError``
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import ArgumentParser
|
||||
import AsyncAlgorithms
|
||||
import Foundation
|
||||
import Noora
|
||||
import Subprocess
|
||||
import Noora
|
||||
|
||||
struct TestCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
@@ -21,21 +22,21 @@ struct TestCommand: AsyncParsableCommand {
|
||||
|
||||
noora.info("Running tests on Linux.")
|
||||
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
|
||||
let dockerRunResult = try await Subprocess.run(
|
||||
async let monitorResult = Subprocess.run(
|
||||
.name("docker"),
|
||||
arguments: [
|
||||
"run",
|
||||
"-v", "\(currentDirectory):/code",
|
||||
"-v", "swift-inotify-build-cache:/code/.build",
|
||||
"--security-opt", "systempaths=unconfined",
|
||||
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
|
||||
"-w", "/code", "swift:latest",
|
||||
"/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"
|
||||
],
|
||||
output: .standardOutput,
|
||||
error: .standardError
|
||||
)
|
||||
if dockerRunResult.terminationStatus.isSuccess {
|
||||
arguments: ["run", "-v", "\(currentDirectory):/code", "--security-opt", "systempaths=unconfined", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"],
|
||||
preferredBufferSize: 10,
|
||||
) { execution, standardInput, standardOutput, standardError in
|
||||
print("")
|
||||
let stdout = standardOutput.lines()
|
||||
let stderr = standardError.lines()
|
||||
for try await line in merge(stdout, stderr) {
|
||||
noora.passthrough("\(line)")
|
||||
}
|
||||
print("")
|
||||
}
|
||||
|
||||
if (try await monitorResult.terminationStatus.isSuccess) {
|
||||
noora.success("All tests completed successfully.")
|
||||
} else {
|
||||
noora.error("Not all tests completed successfully.")
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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,24 +21,6 @@ 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"
|
||||
@@ -50,7 +32,7 @@ struct RecursiveEventTests {
|
||||
recursive: .withAutomaticSubtreeWatching
|
||||
) { _ in
|
||||
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
try createFile(at: "\(filepath)", contents: "hello")
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,9 @@ 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)
|
||||
|
||||
58
task.sh
58
task.sh
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
# task - Run the package's TaskCLI target via a transient "task" product.
|
||||
#
|
||||
# Works around https://github.com/swiftlang/swift-package-manager/issues/8482
|
||||
# by temporarily adding an executable product named "task" to Package.swift,
|
||||
# running it with `swift run`, and restoring the original manifest afterwards.
|
||||
#
|
||||
# Usage: task [arguments...]
|
||||
#
|
||||
# The script auto-detects the package name from Package.swift and expects an
|
||||
# executable target named "<PackageName>TaskCLI" to exist.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Resolve the package root (search upward for Package.swift) -----------
|
||||
|
||||
package_root="${PWD}"
|
||||
while [[ ! -f "${package_root}/Package.swift" ]]; do
|
||||
package_root="${package_root:h}" # zsh dirname
|
||||
if [[ "${package_root}" == "/" ]]; then
|
||||
echo "error: Could not find Package.swift in any parent directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
manifest="${package_root}/Package.swift"
|
||||
backup="${manifest}.task-backup"
|
||||
|
||||
# --- Extract the package name ---------------------------------------------
|
||||
|
||||
package_name=$(sed -n 's/^.*name:[[:space:]]*"\([^"]*\)".*/\1/p' "${manifest}" | head -1)
|
||||
if [[ -z "${package_name}" ]]; then
|
||||
echo "error: Could not determine package name from ${manifest}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
target_name="${package_name}TaskCLI"
|
||||
|
||||
# --- Cleanup trap (runs on EXIT — covers success, failure, signals) -------
|
||||
|
||||
function cleanup {
|
||||
if [[ -f "${backup}" ]]; then
|
||||
mv -f "${backup}" "${manifest}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- Inject the transient "task" product ----------------------------------
|
||||
|
||||
cp -f "${manifest}" "${backup}"
|
||||
|
||||
swift package --package-path "${package_root}" \
|
||||
add-product task --type executable --targets "${target_name}"
|
||||
|
||||
# --- Run it (forward all script arguments) --------------------------------
|
||||
|
||||
swift run --package-path "${package_root}" task "$@"
|
||||
Reference in New Issue
Block a user