Commit 0fbfd00c08
Verified · cmc
Layout: unified · split
Sources/OrgWorkspace/Reconciler.swift added +101
| @@ -0,0 +1,101 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgIndex | ||
| 3 | |||
| 4 | public struct ReconcileReport: Sendable, Equatable { | ||
| 5 | public var indexed: [String] = [] | ||
| 6 | public var touched: [String] = [] | ||
| 7 | public var moved: [String] = [] | ||
| 8 | public var removed: [String] = [] | ||
| 9 | public var unchanged = 0 | ||
| 10 | /// iCloud files asked to download; they are indexed once they arrive. | ||
| 11 | public var placeholders: [URL] = [] | ||
| 12 | } | ||
| 13 | |||
| 14 | /// Brings the index in line with the files under a root. File events are only hints, so this | ||
| 15 | /// is what runs at launch, after dropped events, and for every batch of changed paths. | ||
| 16 | public struct Reconciler: Sendable { | ||
| 17 | public let index: IndexStore | ||
| 18 | public let settings: IndexSettings | ||
| 19 | |||
| 20 | public init(index: IndexStore, settings: IndexSettings) { | ||
| 21 | self.index = index | ||
| 22 | self.settings = settings | ||
| 23 | } | ||
| 24 | |||
| 25 | /// Reconciles the whole root. | ||
| 26 | public func reconcile(root: URL) throws -> ReconcileReport { | ||
| 27 | let discovery = try WorkspaceScanner.scan(root) | ||
| 28 | for placeholder in discovery.placeholders { | ||
| 29 | try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder) | ||
| 30 | } | ||
| 31 | var report = try reconcile(root: root, found: discovery.files, known: index.fileStates(root: root.path)) | ||
| 32 | report.placeholders = discovery.placeholders | ||
| 33 | return report | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Reconciles only `paths` under the root, for a batch of file events. | ||
| 37 | public func reconcile(root: URL, paths: [URL]) throws -> ReconcileReport { | ||
| 38 | let wanted = Set(paths.map(\.standardizedFileURL.path)) | ||
| 39 | let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) } | ||
| 40 | var found: [DiscoveredFile] = [] | ||
| 41 | for url in paths.map(\.standardizedFileURL) { | ||
| 42 | guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent), | ||
| 43 | let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]), | ||
| 44 | values.isDirectory != true else { continue } | ||
| 45 | found.append(DiscoveredFile( | ||
| 46 | url: url, kind: kind, size: values.fileSize ?? 0, | ||
| 47 | mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0 | ||
| 48 | )) | ||
| 49 | } | ||
| 50 | return try reconcile(root: root, found: found, known: known) | ||
| 51 | } | ||
| 52 | |||
| 53 | private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport { | ||
| 54 | var report = ReconcileReport() | ||
| 55 | var change = IndexChange() | ||
| 56 | var candidates: [(file: DiscoveredFile, bytes: [UInt8], hash: String)] = [] | ||
| 57 | |||
| 58 | for file in found { | ||
| 59 | let path = file.url.path | ||
| 60 | if let state = known[path], state.size == file.size, state.mtime == file.mtime, | ||
| 61 | state.settingsVersion == settings.version, state.kind == file.kind { | ||
| 62 | report.unchanged += 1 | ||
| 63 | continue | ||
| 64 | } | ||
| 65 | guard let data = try? Data(contentsOf: file.url) else { continue } | ||
| 66 | let bytes = [UInt8](data) | ||
| 67 | let hash = FileRecord.hash(bytes) | ||
| 68 | if let state = known[path], state.hash == hash, state.settingsVersion == settings.version, state.kind == file.kind { | ||
| 69 | change.touches.append((path, file.mtime)) | ||
| 70 | report.touched.append(path) | ||
| 71 | } else { | ||
| 72 | candidates.append((file, bytes, hash)) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | // A file that vanished and one that appeared with the same content is a rename. | ||
| 77 | let foundPaths = Set(found.map(\.url.path)) | ||
| 78 | var missing = known.filter { !foundPaths.contains($0.key) } | ||
| 79 | for candidate in candidates { | ||
| 80 | let path = candidate.file.url.path | ||
| 81 | if known[path] == nil, | ||
| 82 | let (oldPath, _) = missing.first(where: { | ||
| 83 | $0.value.hash == candidate.hash && $0.value.settingsVersion == settings.version && $0.value.kind == candidate.file.kind | ||
| 84 | }) { | ||
| 85 | missing[oldPath] = nil | ||
| 86 | change.moves.append((oldPath, path, candidate.file.mtime)) | ||
| 87 | report.moved.append(path) | ||
| 88 | } else { | ||
| 89 | change.records.append(FileRecord( | ||
| 90 | path: path, root: root.path, kind: candidate.file.kind, | ||
| 91 | bytes: candidate.bytes, mtime: candidate.file.mtime, settings: settings | ||
| 92 | )) | ||
| 93 | report.indexed.append(path) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | change.removals = missing.keys.sorted() | ||
| 97 | report.removed = change.removals | ||
| 98 | try index.apply(change) | ||
| 99 | return report | ||
| 100 | } | ||
| 101 | } | ||
Tests/OrgWorkspaceTests/ReconcileTests.swift added +81
| @@ -0,0 +1,81 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgIndex | ||
| 3 | import Testing | ||
| 4 | @testable import OrgWorkspace | ||
| 5 | |||
| 6 | struct ReconcileTests { | ||
| 7 | @Test func indexesUpdatesMovesAndRemoves() throws { | ||
| 8 | let folder = try Folder() | ||
| 9 | let index = try IndexStore() | ||
| 10 | let reconciler = Reconciler(index: index, settings: IndexSettings()) | ||
| 11 | let a = try folder.write("a.org", "* Alpha\n") | ||
| 12 | try folder.write("b.org", "* Beta\n") | ||
| 13 | |||
| 14 | var report = try reconciler.reconcile(root: folder.url) | ||
| 15 | #expect(report.indexed.count == 2) | ||
| 16 | #expect(try index.search("alpha").map(\.path) == [a.path]) | ||
| 17 | |||
| 18 | report = try reconciler.reconcile(root: folder.url) | ||
| 19 | #expect(report.unchanged == 2 && report.indexed.isEmpty) | ||
| 20 | |||
| 21 | try FileManager.default.moveItem(at: folder.url.appendingPathComponent("b.org"), to: folder.url.appendingPathComponent("c.org")) | ||
| 22 | try folder.write("a.org", "* Gamma\n") | ||
| 23 | report = try reconciler.reconcile(root: folder.url) | ||
| 24 | #expect(report.moved.map { URL(fileURLWithPath: $0).lastPathComponent } == ["c.org"]) | ||
| 25 | #expect(report.indexed == [a.path]) | ||
| 26 | #expect(try index.search("gamma").count == 1) | ||
| 27 | #expect(try index.search("beta").map { URL(fileURLWithPath: $0.path).lastPathComponent } == ["c.org"]) | ||
| 28 | |||
| 29 | try FileManager.default.removeItem(at: a) | ||
| 30 | report = try reconciler.reconcile(root: folder.url) | ||
| 31 | #expect(report.removed == [a.path]) | ||
| 32 | #expect(try index.search("gamma").isEmpty) | ||
| 33 | } | ||
| 34 | |||
| 35 | @Test func touchedFileWithSameContentIsNotReparsed() throws { | ||
| 36 | let folder = try Folder() | ||
| 37 | let index = try IndexStore() | ||
| 38 | let reconciler = Reconciler(index: index, settings: IndexSettings()) | ||
| 39 | let a = try folder.write("a.org", "* Alpha\n") | ||
| 40 | _ = try reconciler.reconcile(root: folder.url) | ||
| 41 | try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: 60)], ofItemAtPath: a.path) | ||
| 42 | #expect(try reconciler.reconcile(root: folder.url).touched == [a.path]) | ||
| 43 | } | ||
| 44 | |||
| 45 | @Test func newSettingsVersionReindexes() throws { | ||
| 46 | let folder = try Folder() | ||
| 47 | let index = try IndexStore() | ||
| 48 | try folder.write("a.org", "* Alpha\n") | ||
| 49 | _ = try Reconciler(index: index, settings: IndexSettings(version: 1)).reconcile(root: folder.url) | ||
| 50 | #expect(try Reconciler(index: index, settings: IndexSettings(version: 2)).reconcile(root: folder.url).indexed.count == 1) | ||
| 51 | } | ||
| 52 | |||
| 53 | @Test func conflictCopiesAreListedButNotSearchable() throws { | ||
| 54 | let folder = try Folder() | ||
| 55 | let index = try IndexStore() | ||
| 56 | try folder.write("a.sync-conflict-1.org", "* Alpha\n") | ||
| 57 | _ = try Reconciler(index: index, settings: IndexSettings()).reconcile(root: folder.url) | ||
| 58 | #expect(try index.files().map(\.kind) == [.conflict]) | ||
| 59 | #expect(try index.search("alpha").isEmpty) | ||
| 60 | } | ||
| 61 | |||
| 62 | @Test func reconcilingChangedPaths() throws { | ||
| 63 | let folder = try Folder() | ||
| 64 | let index = try IndexStore() | ||
| 65 | let reconciler = Reconciler(index: index, settings: IndexSettings()) | ||
| 66 | let a = try folder.write("a.org", "* Alpha\n") | ||
| 67 | let b = try folder.write("b.org", "* Beta\n") | ||
| 68 | _ = try reconciler.reconcile(root: folder.url) | ||
| 69 | try folder.write("a.org", "* Delta\n") | ||
| 70 | try FileManager.default.removeItem(at: b) | ||
| 71 | let report = try reconciler.reconcile(root: folder.url, paths: [a, b]) | ||
| 72 | #expect(report.indexed == [a.path]) | ||
| 73 | #expect(report.removed == [b.path]) | ||
| 74 | } | ||
| 75 | |||
| 76 | @Test func rootBookmarkResolves() throws { | ||
| 77 | let folder = try Folder() | ||
| 78 | let root = try WorkspaceRoot(url: folder.url) | ||
| 79 | #expect(try root.resolve().url.resolvingSymlinksInPath() == folder.url.resolvingSymlinksInPath()) | ||
| 80 | } | ||
| 81 | } | ||