import Foundation import os // MARK: - GitLandedWindow /// **One debounce window's commits, as the undo stack needs to hear about them** — see /// `GitAutoCommitter.reportLanded`. /// /// The heal halves are separated because the two rules they serve are separate: `healOIDs` is what /// makes the *pointer* pass over a heal commit, and `healPaths` is what makes a restore's /// materialized diff **exclude** the paths whose divergence is heal work (06-history-undo.md ▸ Rules /// ▸ Heal commits are transparent to undo, in-session — both halves, stated in one sentence). public struct GitLandedWindow: Sendable, Equatable { /// Every commit the window landed, oldest first. public let commits: [GitLandedCommit] /// Board-root-relative paths this window committed as heal work. public let healPaths: Set public init(commits: [GitLandedCommit], healPaths: Set) { self.commits = commits self.healPaths = healPaths } /// The oids of the heal-class commits — the ones the undo pointer passes over. public var healOIDs: Set { Set(commits.filter { $0.kind == .heal }.map(\.oid)) } /// Whether *everything* this window landed was heal work. /// /// The distinction the stack acts on: a window of nothing but heal commits must leave the undo /// pointer and the redo stack exactly where they were — "the fresh heal commit is in-session, /// transparent, and the undo run continues past it" (06). A window carrying anything else is an /// ordinary arrival, and arrivals clear redo. public var isEntirelyHeal: Bool { !commits.isEmpty && commits.allSatisfy { $0.kind == .heal } } } // MARK: - GitAutoCommitter /// **Every settled change becomes a commit** (06-history-undo.md ▸ Rules ▸ Auto-commit), debounced /// past drag and typing churn, on git-mode boards and nowhere else. /// /// ### Structurally unreachable off Pro /// /// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro /// (`HistoryStore.compose` is the tier gate). The free tier therefore has no committer to disable, /// no debounce to cancel and no `.git` to touch — 12-editions.md's inert posture as a shape rather /// than as a flag, which `InertGitTests` pins against real bytes. /// /// ### What arms it /// /// Two signals, both from `BoardStore` through `HistoryCommitSeam`, and both meaning "the tree may /// have moved": /// /// - **A write bracket closed.** The app just wrote. This is also where receipts are *harvested* — /// see `HarvestedReceipt` for why the committer cannot simply read the ledger two seconds later. /// - **A reload landed.** Which covers foreign changes on the same debounce as app-mediated ones: /// "Agent and hand edits arrive through the watcher like any change and get auto-committed on the /// same debounce" (06 ▸ Interaction with external writers). It covers strays too — the reload /// lands whether or not the snapshot changed, and "its commit condition is the *tree*, not the /// snapshot diff, so a stray-only window commits". /// /// The committer's own commits do not re-arm it: `FolderWatcher` filters `.git`'s internals, so /// writing an index, an object and a ref produces no event at all. The pathfinder relied on the /// clean-tree no-op to break that echo; here there is no echo to break. /// /// ### Isolation /// /// `@MainActor` for the state — the debounce task, the harvest, the session registry — and every /// piece of libgit2 work runs in a `Task.detached` over `Sendable` values (`FlushInput`), which is /// `GitRepository`'s rule restated: the main actor never blocks on libgit2, and libgit2 never sees /// two threads on one handle. The **one** deliberate exception is `noteWillWrite()`; see its note. @MainActor @Observable public final class GitAutoCommitter { // MARK: - Identity /// The board this commits, which in git mode is also the repository's working-tree root. public let boardRoot: URL /// **This board's write-provenance ledger** (`BoardStore.echoes`), read — never written — at the /// close of every write bracket. @ObservationIgnored private let ledger: EchoLedger // MARK: - Seams /// **The debounce** — how long the tree must be quiet before a commit. /// /// Two seconds is the pathfinder's interval, kept because the cadence constraint (06 ▸ Rules) /// asks the same thing of it as the pathfinder did: long enough that a drag, a multi-select /// delete and a burst of typing each land as one commit, short enough that a board's history is /// never far behind its files. Settable for `CardBodyEditSession.debounceInterval`'s reason /// exactly — a test must not have to spend it. @ObservationIgnored public var debounceInterval: Duration = .seconds(2) /// How long to wait between attempts when `index.lock` is held, and how many times. /// /// "If the auto-committer finds the index locked (an agent's commit in flight), it backs off /// briefly and retries; if the lock persists, it simply re-debounces" (06 ▸ Interaction with /// external writers). *Briefly* is the operative word: a held lock is another writer doing its /// job, and the pending changes lose nothing by waiting for the next quiet moment. @ObservationIgnored public var lockRetryDelay: Duration = .milliseconds(120) @ObservationIgnored public var lockRetryAttempts = 3 /// How long a held repository waits before re-checking its own state. /// /// **Not a retry** — nothing is attempted — but the pause has to end somehow: "edits keep landing /// on disk and commit as one settled batch when the state clears", and a rebase finished in a /// terminal that moves only refs produces no watcher event at all (`.git` is filtered), so /// nothing else would ever nudge this board again. A `git_repository_state` read is a handful of /// `stat`s; at this cadence, only while a pause stands, it is the cheapest thing that keeps the /// promise. 07's "never hammer" is about not retrying the *operation*, which this never does. @ObservationIgnored public var holdRecheckInterval: Duration = .seconds(15) /// **What a commit says** — the seam, holding the semantic composer by default /// (06 ▸ Commit messages). Settable so a test can inject a fake and assert *that* a message was /// asked for without asserting what it said. @ObservationIgnored public var composer: any CommitMessageComposing = SemanticCommitMessage() /// The board as the app last read it, for the composer's "current" half. `nil` where no store is /// attached, which is every storeless test. @ObservationIgnored public var currentSnapshot: (@MainActor () -> BoardModel?)? /// **A genuine commit failure** — disk full, repo corruption (06: "files stay safe on disk but /// history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried /// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`. /// /// Deliberately **not** called for lock contention, which "is never an error", nor for a held /// repository, whose surface is the popover's badge (the branch-switching card's). @ObservationIgnored public var reportFailure: (@MainActor (GitOperationFailure) -> Void)? /// History is advancing again — the standing suspension's clearing rule /// (`BannerCenter.clearHistorySuspension`), which is "the ordinary shape of commit succeeded". @ObservationIgnored public var reportRecovery: (@MainActor () -> Void)? /// **What a flush landed, and which of it was heal work** — the undo stack's in-session ear /// (06-history-undo.md ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live; ▸ Heal commits /// are transparent to undo, in-session). /// /// Two facts travel here and nowhere else can carry them. **Liveness**: "foreign commits … /// push onto the in-session undo stack as ordinary steps as they land", and a commit this engine /// made is the one kind of arrival the stack could otherwise only discover by polling HEAD. /// **Heal transparency**: the heal class is known from the Writer's heal-marked receipts, which /// this engine clears the instant a window commits — so the moment of landing is the only moment /// at which "that commit was the heal" is knowable at all. /// /// `nil` on every board with no undo provider bound (the free tier's committer does not exist; /// a Pro board's does, and a provider is bound beside it). @ObservationIgnored public var reportLanded: (@MainActor (GitLandedWindow) -> Void)? // MARK: - Observable state /// **The repository state the engine is holding for**, or `nil` when it is free to commit /// (06 ▸ Rules ▸ Abnormal repo states). /// /// Queryable rather than merely internal because the *UI* surface of the pause — the popover's /// badge and its plain-language explanation, and disabling Undo/Redo, the branch controls, Pull /// and Push with it — is the branch-switching card's, and it needs exactly this fact. What this /// card owns is the hold itself. public private(set) var pause: GitRepositoryPause? /// The last genuine failure, or `nil` if history is advancing. Beside `pause` for the popover's /// sake, and because `HistoryStore.lastFailure` is add-git's, not this. public private(set) var lastFailure: GitOperationFailure? /// Commits this committer has landed, and the newest OID — the debounce's own testimony, which a /// test would otherwise have to infer from a commit walk. public private(set) var commitCount = 0 public private(set) var lastCommitOIDs: [String] = [] // MARK: - Private state /// Receipts copied out of the ledger at bracket close, keyed by absolute path. Cleared when a /// flush commits them — the window is over, and a stale receipt would vouch for the next window's /// changes. @ObservationIgnored private var harvested: [String: HarvestedReceipt] = [:] /// **Whether this window holds a change nobody vouched for** — the flush-before-overwrite gate. @ObservationIgnored private var holdsForeignChanges = false /// Open Edit sessions, each answering with the folder to stage around *right now*. /// /// A closure per session rather than a stored URL, because a card can move lane, or into the /// trash, in the middle of a session — its folder is a fact about the current snapshot, not /// about when Edit was entered. @ObservationIgnored private var editSessions: [UUID: @MainActor () -> URL?] = [:] @ObservationIgnored private var pending: Task? @ObservationIgnored private var isFlushing = false private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") init(boardRoot: URL, ledger: EchoLedger) { self.boardRoot = boardRoot self.ledger = ledger } // MARK: - Lifecycle /// **Launch catch-up** (06 ▸ Commit messages: "changes found pending at board open … through the /// same composer, instead of committing blind"). /// /// One armed debounce and nothing else: a board that opens clean spends one `git status` and /// commits nothing, and a board that opens dirty commits through the ordinary engine — same /// split, same authorship, same message seam. Everything found pending classifies **foreign**, /// which is not a shortcut but the doctrine: the ledger is empty because the app was not running, /// and "the app never vouches for changes it didn't witness". public func start() { arm() } /// Stops the engine and forgets the window. Called at teardown so a closed board's debounce /// cannot fire against a store that has gone. public func stop() { pending?.cancel() pending = nil } // MARK: - Inbound signals /// **A write bracket closed** — harvest, then arm. /// /// The harvest is the whole reason this signal exists separately from the reload: receipts /// describe a completed write and are consumed by the landing reload that classifies them, so /// this is the only moment at which the committer can still see them (`HarvestedReceipt`). public func noteWriteBracketClosed() { harvest() arm() } /// **A reload landed** — the tree settled, and here is whether any of it was somebody else's. /// /// - Parameter sawForeignChange: what the landing reload's own `EchoLedger.verdicts` concluded. /// It arms flush-before-overwrite and nothing else; the commit split re-derives provenance per /// *file* at flush time, because this is one bit about a whole reload. public func noteReloadLanded(sawForeignChange: Bool) { if sawForeignChange { holdsForeignChanges = true } arm() } /// **Flush-before-overwrite** (06 ▸ Rules): "before an app write overwrites on-disk state that /// differs from the last-loaded snapshot … the pending auto-commit is flushed so the external /// version enters history first. *Both versions exist as commits* is thereby a guarantee, not a /// likelihood." /// /// ### The gate /// /// It fires **only when the window holds a change the app does not vouch for**. That is exactly /// the condition under which overwriting can bury someone else's uncommitted version; a window /// of nothing but the app's own writes has nothing to protect, and flushing there would commit /// once per gesture and make the cadence constraint's "unbearable shared log" come true. /// /// ### The two costs, recorded /// /// **It runs on the main actor, synchronously.** `performWrite` is synchronous — it is a /// gesture's write path — so an ordering guarantee *before* it can only be kept by a synchronous /// commit. 02's hang-avoidance doctrine and 06's ordering guarantee genuinely conflict here, and /// the guarantee wins for an operation that is rare (foreign change pending), bounded (one /// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a /// version of somebody's file with no commit to recover it from). /// /// **The semantic composer widened that bound**, and it is recorded rather than discovered: this /// flush now also materializes HEAD's tree and reads it back through `BoardLoader` /// (`composition(for:input:)`), so the synchronous cost is a few board-sized walks rather than /// one. Still bounded and still rare — and the alternative, a placeholder message on exactly the /// commit that preserves somebody else's version, would be the worst message in the trail. /// /// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns /// about foreign changes from landed reloads, so a write that lands inside the watcher's own /// debounce is not yet known to be pending. Bounded by that debounce, and the same window /// 05-card-window.md's dirty-buffer rule already calls last-writer-wins — but it is a real gap in /// "guarantee", and it is recorded here rather than discovered later. public func noteWillWrite() { guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return } isFlushing = true defer { isFlushing = false } pending?.cancel() pending = nil // One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means // the foreign version commits on the next quiet debounce instead — which is the same // "re-debounce" answer contention gets everywhere else. let result = Self.execute(input) apply(result.outcome, healPaths: result.healPaths) } // MARK: - Edit sessions /// **Registers an open Edit session's card folder** (06 ▸ Rules ▸ Auto-commit: "The committer /// stages around open Edit sessions: a board change committing mid-session excludes the session /// card's folder from staging, so a lane move never sweeps half-typed body text into its /// commit"). /// /// The exclusion is absolute where it applies: "whole-root staging widening *what* commits, never /// overriding the exclusion" (06 ▸ Commit messages ▸ Non-snapshot files commit too). A stray /// dropped inside the session card's folder therefore waits for the session to end, along with /// the body. /// /// - Parameters: /// - token: the window's identity, so ending twice is idempotent. /// - cardFolder: asked at every flush rather than stored, so a card moved mid-session is staged /// around at wherever it now is. public func beginEditSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) { editSessions[token] = cardFolder } /// Ends one, and **nudges** — which is what makes "exactly one body commit per session" true: /// the session's debounced saves committed nothing while it was open, and this is the moment its /// whole diff becomes committable (06 ▸ Rules ▸ Auto-commit: the Edit→Preview flip is "the /// effective Save button"; raw-source entry and window close end the session too). public func endEditSession(_ token: UUID) { guard editSessions.removeValue(forKey: token) != nil else { return } arm() } /// Whether a card window's folder is currently staged around — the stage-around rule, made /// assertable without reaching into private state. public var stagedAroundFolders: [URL] { editSessions.values.compactMap { $0() } } // MARK: - Flushing /// **Commits now**, cancelling the debounce — the close/quit path, and File ▸ Duplicate's /// pending-work step. /// /// 02-architecture.md § Windows fixes where it sits: "closing a board window (and app quit) first /// closes the board's card windows — each open Edit session ends with its normal session commit — /// then flushes pending debounced work, editor saves before the pending auto-commit, before the /// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the /// sessions have ended, so nothing is staged around any more. public func flushNow() async { await flush() } /// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and /// typing churn" is one timer rather than a rule each call site remembers. private func arm(after interval: Duration? = nil) { pending?.cancel() let delay = interval ?? debounceInterval pending = Task { [weak self] in try? await Task.sleep(for: delay) guard !Task.isCancelled, let self else { return } self.pending = nil await self.flush() } } private func flush() async { guard !isFlushing else { return } isFlushing = true defer { isFlushing = false } pending?.cancel() pending = nil guard let input = makeInput() else { return } // The brief backoff. Off the main actor for the git work, on it for the sleep, so a held // lock costs a couple of suspended turns rather than a blocked UI. for attempt in 0...max(0, lockRetryAttempts) { let result = await Task.detached(priority: .utility) { Self.execute(input) }.value if case .locked = result.outcome, attempt < max(0, lockRetryAttempts) { try? await Task.sleep(for: lockRetryDelay) continue } apply(result.outcome, healPaths: result.healPaths) return } } // MARK: - The plan /// Everything one flush needs, as values — so the whole of it can cross to a detached task. private struct FlushInput: Sendable { let boardRoot: URL let excludedFolders: [String] let receipts: [String: HarvestedReceipt] let composer: any CommitMessageComposing let snapshot: BoardModel? } private func makeInput() -> FlushInput? { FlushInput( boardRoot: boardRoot, excludedFolders: editSessions.values.compactMap { $0() }.map(EchoLedger.key), receipts: harvested, composer: composer, snapshot: currentSnapshot?() ) } /// What one flush concluded — the outcome, plus the paths it committed as heal work. /// /// The second half exists for `reportLanded`: heal paths are known only inside the split, which /// runs here, and the stack that needs them lives on the main actor. private struct FlushOutput: Sendable { let outcome: GitCommitOutcome var healPaths: Set = [] } /// **One whole flush**, off the main actor: read the state, list the tree's changes, stage around /// the open sessions, split by provenance, compose, commit. private nonisolated static func execute(_ input: FlushInput) -> FlushOutput { let reading = GitCommitOperation.reading(at: input.boardRoot) if let pause = reading.pause { return FlushOutput(outcome: .held(pause)) } if reading.isIndexLocked { return FlushOutput(outcome: .locked) } // `nil` is "the survey could not be taken" — an unwritable object store, a corrupt index — // and it must not read as a clean tree: that would no-op silently and let history stop // advancing with nothing on the banner strip (06 ▸ Interaction with external writers, the // genuine-failure clause). guard let surveyed = GitCommitOperation.surveyChangedPaths(at: input.boardRoot) else { return FlushOutput(outcome: .failed(GitOperationFailure( operation: "Recording this board's history", message: "this board's repository could not be read" ))) } let changed = surveyed .filter { !isExcluded($0.path, under: input.boardRoot, by: input.excludedFolders) } guard !changed.isEmpty else { return FlushOutput(outcome: .nothingToCommit) } let commits = plan( changed, reading: reading, input: input, composition: composition(for: changed, input: input) ) return FlushOutput( outcome: GitCommitOperation.perform(at: input.boardRoot, commits: commits), healPaths: Set(commits.filter { $0.kind == .heal }.flatMap(\.paths)) ) } // MARK: - What the composer is handed /// **The composer's environment, resolved once per flush** (06 ▸ Commit messages: "a structural /// diff of two board snapshots — last-committed vs. current"). /// /// Once per *flush*, not once per planned commit: a window that splits three ways /// (foreign → heal → user) composes all three messages against the same HEAD, so materializing /// HEAD's tree three times would be three answers to one question. Each message is then narrowed /// to its own commit by `CommitMessageRequest.changedPaths`, which the split already narrows. private struct Composition: Sendable { var previous: BoardModel? var current: BoardModel? var agentGuideText: String? } /// Reads the two snapshots and the guide's bytes — the only impure step in the message path, kept /// here so `CommitMessageEngine` can be a pure function of values. /// /// **Skipped entirely when nothing in the window could touch the model**, which is the ordinary /// stray-only and guide-only window: those compose path-shaped events, and materializing a board /// twice to describe a changed `.gitignore` would be work with no reader. private nonisolated static func composition( for changed: [GitChangedPath], input: FlushInput ) -> Composition { var composition = Composition() if changed.contains(where: { $0.path == AgentGuide.filename }) { composition.agentGuideText = try? String( contentsOf: input.boardRoot.appendingPathComponent(AgentGuide.filename), encoding: .utf8 ) } // **The comment family needs a board but not a diff.** Comments are outside the snapshot // entirely (01-storage-format.md § Enhanced schema), so HEAD's tree has nothing to say about // them — but the verbs name the *card* ("Comment on 'Fix login'"), and only a board knows a // card's title. So a comment-only window loads the current board and skips the materialization. let touchesModel = changed.contains { CommitMessageEngine.Paths.mightAffectSnapshot($0.path) } let namesACard = changed.contains { CommentPath.classify($0.path) != nil } guard touchesModel || namesACard else { return composition } // **The store's snapshot when there is one, disk when there is not.** A storeless committer is // a real configuration (`HistoryStore.compose` without a session, every engine-level test), and // a composer handed no current board could only ever shrug. Loading here rather than in // `makeInput` keeps the read off the main actor, where every other read in this flush already // is. composition.current = input.snapshot ?? (try? BoardLoader.load(boardRoot: input.boardRoot).model) guard touchesModel else { return composition } composition.previous = GitHeadSnapshot.load(at: input.boardRoot) return composition } /// The three-way split turned into commits — or, on an unborn HEAD, the one commit 06 fixes. private nonisolated static func plan( _ changed: [GitChangedPath], reading: GitRepositoryReading, input: FlushInput, composition: Composition ) -> [PlannedCommit] { let user = GitCommitOperation.userIdentity(at: input.boardRoot) func request( _ paths: [GitChangedPath], _ authorship: CommitAuthorship, isRootCommit: Bool = false ) -> CommitMessageRequest { CommitMessageRequest( boardRoot: input.boardRoot, changedPaths: paths, authorship: authorship, isRootCommit: isRootCommit, snapshot: composition.current, previousSnapshot: composition.previous, agentGuideText: composition.agentGuideText ) } // **The root commit is not split** (06 ▸ Rules ▸ Abnormal repo states): "it commits the whole // tree as *Initial board state*, never a folded diff-from-empty: there is no last-committed // snapshot to diff against". Splitting a repository's first commit three ways by the // provenance of files that mostly predate the app knowing about them would be a fiction; the // whole tree arriving at once is the event, and it is the user's own opt-in that caused it, // so it is authored as the user. (Recorded as a judgment call: DESIGN fixes the subject and // the shape, not the author.) guard !reading.isUnborn else { return [PlannedCommit( paths: changed.map(\.path), message: input.composer.message(for: request(changed, .user, isRootCommit: true)), author: user, committer: user, kind: .root )] } let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts) return split.ordered.map { group in let authorship: CommitAuthorship switch group.kind { case .foreign: authorship = .foreign( CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot) ) // **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a // heal's paths commit *separately* and says nothing about who they are by. The healer is // the app acting on the user's behalf — its writes are app-mediated, receipt and all — so // authoring them as the user is the honest reading, and authoring them as `Lanework // External` would blame the outside world for the app's own repair. case .heal: authorship = .heal case .user: authorship = .user } let author: GitIdentity if case let .foreign(identity) = authorship { author = identity } else { author = user } let kind: PlannedCommitKind switch group.kind { case .foreign: kind = .foreign case .heal: kind = .heal case .user: kind = .user } return PlannedCommit( paths: group.paths.map(\.path), message: input.composer.message(for: request(group.paths, authorship)), author: author, committer: user, kind: kind ) } } /// Whether a changed path lives inside a folder staged around. private nonisolated static func isExcluded( _ relativePath: String, under boardRoot: URL, by folders: [String] ) -> Bool { guard !folders.isEmpty else { return false } let absolute = EchoLedger.key(boardRoot.appendingPathComponent(relativePath)) return folders.contains { absolute == $0 || absolute.hasPrefix($0 + "/") } } // MARK: - Outcomes private func apply(_ outcome: GitCommitOutcome, healPaths: Set = []) { switch outcome { case let .committed(landed): let oids = landed.map(\.oid) pause = nil lastFailure = nil commitCount += oids.count lastCommitOIDs = oids // **The stack hears about every commit this engine lands** (06 ▸ Rules ▸ The stack is // HEAD's first-parent ancestry, live), *before* the receipts that describe them are // cleared below — the heal class exists only for as long as they do. reportLanded?(GitLandedWindow(commits: landed, healPaths: healPaths)) // The window is over: its receipts have said everything they can say, and keeping them // would let them vouch for the *next* window's changes to the same paths. harvested.removeAll() holdsForeignChanges = false reportRecovery?() Self.logger.debug("auto-commit landed \(oids.count, privacy: .public) commit(s)") case .nothingToCommit: // **The happy path, not a malfunction** (06): an agent committed its own work, or the // whole window was staged around. Silent, and the window closes either way. pause = nil lastFailure = nil harvested.removeAll() holdsForeignChanges = false reportRecovery?() case .locked: // "No banner, no log-worthy failure: a held lock is another writer doing its job." The // changes are still pending and the harvest is still held, so the next quiet moment // commits them with their provenance intact. Self.logger.debug("index.lock held — re-debouncing") arm() case let .held(reason): pause = reason Self.logger.notice("auto-commit held: \(reason.rawValue, privacy: .public)") arm(after: holdRecheckInterval) case let .failed(failure): pause = nil lastFailure = failure Self.logger.error("auto-commit failed: \(failure.description, privacy: .public)") reportFailure?(failure) arm() } } // MARK: - Harvest /// Copies the ledger's current receipts into this window's own record. /// /// Whole-ledger rather than bracket-scoped, deliberately: the Writer's primitives drop receipts /// without telling anyone which paths they were, and a diff of key sets would miss a /// *supersession* (the same key, newer bytes) — which is exactly the case that must not be /// missed, since the newest write is the one disk will be compared against. Copying is cheap: /// the ledger holds tens of entries, and the harvest happens once per gesture. private func harvest() { for (path, entry) in ledger.outstandingEntries() { harvested[path] = entry } } }