Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions Sources/ForelCore/Watcher/WatcherCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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
}
Expand Down
73 changes: 73 additions & 0 deletions Tests/ForelCoreTests/WatcherSettlingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading