From 963a6543a0c779ebe3966058259389b696ae1753 Mon Sep 17 00:00:00 2001 From: Chris Wenn Date: Sat, 24 Oct 2026 01:50:52 +0400 Subject: [PATCH 1/2] fix: apply rules to contents of a folder moved into a watched folder FSEvents only reports the folder itself when it arrives with its contents: the files inside were created outside the watched tree and never generate events of their own, so the watcher evaluated the folder and stopped. Run Now processed the same files fine, because it walks the tree. When a settled arrival is a directory, rescan its subtree before finishing. The arrival's includesContents snapshot already waited for everything inside to stop changing, so the entries can be evaluated immediately; the fingerprint cache keeps a rescan from re-evaluating paths whose own events also arrived. Fixes #70 --- CHANGELOG.md | 1 + .../Watcher/WatcherCoordinator.swift | 12 ++++++- .../ForelCoreTests/WatcherSettlingTests.swift | 32 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index caf84ed..eb6a7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to Forel are documented here. Format loosely follows ### Fixed - 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. ## [1.1.0] - 2026-10-22 diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 34a3da4..145d3d0 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -224,11 +224,21 @@ 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 && isDirectory(path)) { handleRescanSubtree(root: path, waitsForSettledFiles: false) } } + private func isDirectory(_ path: String) -> Bool { + var isDir: ObjCBool = false + return FileManager.default.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue + } + public func isProcessing(in root: String) -> Bool { activeProcessingLock.lock() defer { activeProcessingLock.unlock() } diff --git a/Tests/ForelCoreTests/WatcherSettlingTests.swift b/Tests/ForelCoreTests/WatcherSettlingTests.swift index 917ad79..1303d6e 100644 --- a/Tests/ForelCoreTests/WatcherSettlingTests.swift +++ b/Tests/ForelCoreTests/WatcherSettlingTests.swift @@ -206,6 +206,38 @@ 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(.timeLimit(.minutes(1))) func rescanDoesNotReadSubfoldersBeyondWhatTheRulesNeed() throws { let db = try makeDB() From 1a2622b489a45f2ee7894b4dcdeafb73d4e306e4 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Mon, 26 Oct 2026 06:47:29 +0100 Subject: [PATCH 2/2] Symbolic links to folders no longer cause automatic rules to process files outside the watched folder --- CHANGELOG.md | 1 + .../Watcher/WatcherCoordinator.swift | 15 +++---- .../ForelCoreTests/WatcherSettlingTests.swift | 41 +++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af053e..28cbc87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ 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". diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 145d3d0..7aa3fde 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -229,14 +229,16 @@ public final class WatcherCoordinator: @unchecked Sendable { // 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 && isDirectory(path)) { + if state.hasDeferredDescendants || (state.includesContents && isRealDirectory(path)) { handleRescanSubtree(root: path, waitsForSettledFiles: false) } } - private func isDirectory(_ path: String) -> Bool { - var isDir: ObjCBool = false - return FileManager.default.fileExists(atPath: path, isDirectory: &isDir) && isDir.boolValue + 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 { @@ -334,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 1303d6e..db944be 100644 --- a/Tests/ForelCoreTests/WatcherSettlingTests.swift +++ b/Tests/ForelCoreTests/WatcherSettlingTests.swift @@ -238,6 +238,47 @@ import Foundation #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()