Sources/OrgWorkspace/Reconciler.swift
131 lines · 6444 bytes
1import Foundation
2import OrgIndex
3
4public 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.
16public struct Reconciler: Sendable {
17 public let index: IndexStore
18 public let settings: IndexSettings
19 public let rules: ScanRules
20
21 public init(index: IndexStore, settings: IndexSettings, rules: ScanRules = ScanRules()) {
22 self.index = index
23 self.settings = settings
24 self.rules = rules
25 }
26
27 /// Reconciles the whole root.
28 public func reconcile(root: URL) throws -> ReconcileReport {
29 let discovery = try WorkspaceScanner.scan(root, rules: rules)
30 for placeholder in discovery.placeholders {
31 try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
32 }
33 // Files iCloud evicted keep their rows until they're back.
34 let pending = Set(discovery.placeholders.map(\.path))
35 var report = try reconcile(root: root, found: discovery.files, known: index.fileStates(root: root.path).filter { !pending.contains($0.key) })
36 report.placeholders = discovery.placeholders
37 return report
38 }
39
40 /// Reconciles only `paths` under the root, for a batch of file events.
41 public func reconcile(root: URL, paths: [URL]) throws -> ReconcileReport {
42 let wanted = Set(paths.map(\.standardizedFileURL.path))
43 let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
44 var found: [DiscoveredFile] = []
45 var placeholders: [URL] = []
46 let rootComponents = root.standardizedFileURL.pathComponents.count
47 for url in paths.map(\.standardizedFileURL) {
48 // Files in ignored folders (`.git`) aren't part of the workspace, as in `scan`.
49 if url.pathComponents.dropFirst(rootComponents).dropLast().contains(where: rules.skipsFolder) { continue }
50 guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent, rules: rules),
51 let values = try? url.resourceValues(forKeys: Set(WorkspaceScanner.keys)),
52 values.isDirectory != true else { continue }
53 if WorkspaceScanner.isPending(values) {
54 try? FileManager.default.startDownloadingUbiquitousItem(at: url)
55 placeholders.append(url)
56 continue
57 }
58 found.append(DiscoveredFile(
59 url: url, kind: kind, size: values.fileSize ?? 0,
60 mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
61 ))
62 }
63 let pending = Set(placeholders.map(\.path))
64 var report = try reconcile(root: root, found: found, known: known.filter { !pending.contains($0.key) })
65 report.placeholders = placeholders
66 return report
67 }
68
69 private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport {
70 var report = ReconcileReport()
71 var change = IndexChange()
72 var candidates: [(file: DiscoveredFile, bytes: [UInt8], hash: String)] = []
73
74 for file in found {
75 let path = file.url.path
76 if let state = known[path], state.size == file.size, state.mtime == file.mtime,
77 state.settingsVersion == settings.version, state.kind == file.kind {
78 report.unchanged += 1
79 continue
80 }
81 if file.kind == .other {
82 let record = FileRecord(path: path, root: root.path, unreadSize: file.size, mtime: file.mtime, settings: settings)
83 if let state = known[path], state.hash == record.hash, state.settingsVersion == settings.version, state.kind == .other {
84 report.unchanged += 1
85 } else {
86 candidates.append((file, [], record.hash))
87 }
88 continue
89 }
90 guard let data = try? Data(contentsOf: file.url) else { continue }
91 let bytes = [UInt8](data)
92 let hash = FileRecord.hash(bytes)
93 if let state = known[path], state.hash == hash, state.settingsVersion == FileRecord.settingsVersion(settings, bytes: bytes, path: path),
94 state.kind == file.kind {
95 change.touches.append((path, file.mtime))
96 report.touched.append(path)
97 } else {
98 candidates.append((file, bytes, hash))
99 }
100 }
101
102 // A file that vanished and one that appeared with the same content is a rename.
103 let foundPaths = Set(found.map(\.url.path))
104 var missing = known.filter { !foundPaths.contains($0.key) }
105 for candidate in candidates {
106 let path = candidate.file.url.path
107 if known[path] == nil,
108 let (oldPath, _) = missing.first(where: {
109 $0.value.hash == candidate.hash && $0.value.kind == candidate.file.kind
110 && $0.value.settingsVersion == (candidate.file.kind == .other ? settings.version : FileRecord.settingsVersion(settings, bytes: candidate.bytes, path: path))
111 }) {
112 missing[oldPath] = nil
113 change.moves.append((oldPath, path, candidate.file.mtime))
114 report.moved.append(path)
115 } else if candidate.file.kind == .other {
116 change.records.append(FileRecord(path: path, root: root.path, unreadSize: candidate.file.size, mtime: candidate.file.mtime, settings: settings))
117 report.indexed.append(path)
118 } else {
119 change.records.append(FileRecord(
120 path: path, root: root.path, kind: candidate.file.kind,
121 bytes: candidate.bytes, mtime: candidate.file.mtime, settings: settings
122 ))
123 report.indexed.append(path)
124 }
125 }
126 change.removals = missing.keys.sorted()
127 report.removed = change.removals
128 try index.apply(change)
129 return report
130 }
131}