diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c222bd..28cbc87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ All notable changes to Forel are documented here. Format loosely follows - Move to folder and Copy to folder destinations now accept {year}, {month}, and {day} date tokens (e.g. ~/Archive/{year}/{month}), resolved from each file's creation date. The destination field can now also be typed into. ### Fixed +- Symbolic links to folders no longer cause automatic rules to process files outside the watched folder. - Date Added conditions no longer crash when reading a file's timestamp. +- Moving a folder into a watched folder now applies matching rules to the files inside it automatically, the same way Run Now does. - Editing the number in a relative date or size condition no longer resets its unit, which could silently turn "2 Months" into "2 Days". ## [1.1.0] - 2026-10-22 diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 34a3da4..7aa3fde 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -224,11 +224,23 @@ public final class WatcherCoordinator: @unchecked Sendable { } settling[path] = nil handle(path: path) - if state.hasDeferredDescendants { + // Contents a folder arrived with never get their own FSEvents events — + // they were created outside the watched tree — so a folder that + // settles must have its subtree walked, the way Run Now does. The + // includesContents snapshot already waited for everything inside to + // stop changing, so the entries can be evaluated right away. + if state.hasDeferredDescendants || (state.includesContents && isRealDirectory(path)) { handleRescanSubtree(root: path, waitsForSettledFiles: false) } } + private func isRealDirectory(_ path: String) -> Bool { + // Inspect the item itself: following a directory symlink would let a + // subtree scan apply this watched folder's rules outside its tree. + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path) else { return false } + return (attributes[.type] as? FileAttributeType) == .typeDirectory + } + public func isProcessing(in root: String) -> Bool { activeProcessingLock.lock() defer { activeProcessingLock.unlock() } @@ -324,9 +336,8 @@ public final class WatcherCoordinator: @unchecked Sendable { } private func handleRescanSubtree(root: String, waitsForSettledFiles: Bool) { - var isDir: ObjCBool = false - guard FileManager.default.fileExists(atPath: root, isDirectory: &isDir) else { return } - guard isDir.boolValue else { + guard FileManager.default.fileExists(atPath: root) else { return } + guard isRealDirectory(root) else { handle(event: .pathArrived(root), waitsForSettledFiles: waitsForSettledFiles) return } diff --git a/Tests/ForelCoreTests/WatcherSettlingTests.swift b/Tests/ForelCoreTests/WatcherSettlingTests.swift index 917ad79..db944be 100644 --- a/Tests/ForelCoreTests/WatcherSettlingTests.swift +++ b/Tests/ForelCoreTests/WatcherSettlingTests.swift @@ -206,6 +206,79 @@ import Foundation #expect(try db.listHistory().count == 2) } + @Test func folderArrivingWithContentsProcessesThemWithoutTheirOwnEvents() throws { + let db = try makeDB() + let dir = TempDir() + let destination = dir.dir("Documents") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "sort pdf", recursionDepth: nil) + rule.conditions = [makeCondition(.extension_, .is, "pdf", ruleId: rule.id)] + rule.actions = [makeAction(.moveToFolder, .object(["destination": .string(destination)]), position: 0, ruleId: rule.id)] + try db.insertRule(rule) + + // Like moving a folder in from elsewhere: FSEvents reports only the + // folder — its existing contents were created outside the watched + // tree and never get events of their own. + let incoming = dir.dir("Export") + _ = dir.dir("Export/Nested") + let first = (incoming as NSString).appendingPathComponent("a.pdf") + let second = (incoming as NSString).appendingPathComponent("Nested/b.pdf") + try "a".write(toFile: first, atomically: true, encoding: .utf8) + try "b".write(toFile: second, atomically: true, encoding: .utf8) + + let coordinator = WatcherCoordinator(db: db, settleInterval: 0.05) + coordinator.enqueue(event: .pathArrived(incoming)) + coordinator.waitForPendingEvents() + + #expect(FileManager.default.fileExists(atPath: incoming)) + #expect(!FileManager.default.fileExists(atPath: first)) + #expect(!FileManager.default.fileExists(atPath: second)) + #expect(Set(try FileManager.default.contentsOfDirectory(atPath: destination)) == ["a.pdf", "b.pdf"]) + #expect(try db.listHistory().count == 2) + } + + @Test(arguments: ["preview", "manual", "watcher-arrival", "watcher-rescan"]) + func directorySymlinksDoNotProcessExternalContents(execution: String) throws { + let db = try makeDB() + let dir = TempDir() + let watched = dir.dir("Watched") + let outside = dir.dir("Outside") + let destination = dir.dir("Archive") + let original = dir.file("Outside/private.pdf", contents: "private") + let link = watched + "/Link" + try FileManager.default.createSymbolicLink(atPath: link, withDestinationPath: outside) + let folder = WatchedFolder(path: watched) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "sort pdf", recursionDepth: nil) + rule.conditions = [makeCondition(.extension_, .is, "pdf", ruleId: rule.id)] + rule.actions = [makeAction(.moveToFolder, .object(["destination": .string(destination)]), ruleId: rule.id)] + try db.insertRule(rule) + let rules = try db.executionRules(folderID: folder.id, isPro: false) + + if execution == "preview" || execution == "manual" { + let entries = RuleEngine.walkEntries(root: watched, maxDepth: RuleEngine.maxRuleDepth(rules)) + #expect(entries.map(\.path) == [link]) + for entry in entries { + if execution == "preview" { + #expect(RuleEngine.previewFile(path: entry.path, depth: entry.depth, rules: rules, isPro: false) == nil) + } else { + let result = RuleEngine.run(path: entry.path, depth: entry.depth, rules: rules, batchId: "manual", root: watched, isPro: false) + #expect(result.history.isEmpty) + } + } + } else { + let coordinator = WatcherCoordinator(db: db, settleInterval: 0.01) + coordinator.enqueue(event: execution == "watcher-arrival" ? .pathArrived(link) : .rescanSubtree(link)) + coordinator.waitForPendingEvents() + } + + #expect(try String(contentsOfFile: original, encoding: .utf8) == "private") + #expect(try FileManager.default.contentsOfDirectory(atPath: destination).isEmpty) + #expect(try db.listHistory().isEmpty) + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: link) == outside) + } + @Test(.timeLimit(.minutes(1))) func rescanDoesNotReadSubfoldersBeyondWhatTheRulesNeed() throws { let db = try makeDB()