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?)? /// **The reload pipeline settling** — `BoardStore.awaitQuiescence()`, and `nil` on a storeless /// committer. /// /// Read only by `awaitCoveringSnapshot()`, whose whole correctness rests on it: it is what makes /// the *next* walk a walk that started after this flush's changes were on disk. @ObservationIgnored public var awaitReloadQuiescence: (@MainActor () async -> Void)? /// **How many tree walks the board has landed** — `BoardStore.landedReloads`, incremented by /// every reload that completed with a snapshot in hand. /// /// **The walk, not the applied snapshot**, and the distinction is load-bearing since 2026-07-31: /// a reload whose tree turned out to be value-equal skips the snapshot assignment and its counter /// (02-architecture.md § Live-reload resilience), and a gate watching *that* counter would sit out /// its whole deadline on a flush whose covering walk had already landed. What covers a flush is a /// walk that started after its writes reached disk, and a /// completed walk covers them whether or not it found anything different to show. /// /// `nil` — the closure absent, or answering `nil` because the store has gone — means there is no /// snapshot to be outrun by, and the covering await becomes the no-op it is on every storeless /// committer. @ObservationIgnored public var landedReloads: (@MainActor () -> Int?)? /// **How long an explicit flush waits for its covering reload** before composing from the snapshot /// it already has. /// /// A bound rather than an open-ended wait, and recorded as a judgment call: 06 rules that the /// flush awaits its covering snapshot and does not say what happens if that reload never lands. It /// normally lands within the watcher's ~200 ms debounce, and it is *scheduled unconditionally* by /// the write bracket that closed (`FolderWatcher.endBracket`, "the mandatory single post-bracket /// reload … even if not one filesystem event was seen"), so the wait is short and certain in every /// ordinary case. What it must not be is unbounded: this flush runs on the close and quit paths, /// and a board whose watcher stream failed to start (`BoardStoreRegistry.acquire` logs and carries /// on) would otherwise make the app unquittable. So the wait ends, generously, and the commit is /// composed from the snapshot in hand — one stale subject in a degraded configuration, against a /// hang. @ObservationIgnored public var coveringSnapshotDeadline: Duration = .seconds(1) /// How often the wait re-reads the generation. Polled rather than signalled for /// `CloseFlushCoordinator.drainCardWindows`' reason: the point of this wait is that it *ends*, and /// a continuation resumed by a reload that never lands has no way to. @ObservationIgnored public var coveringSnapshotPollInterval: Duration = .milliseconds(10) /// **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` wherever no `GitHistoryProvider` is listening — which in practice is nowhere a committer /// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where /// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none and /// repo-nested boards alike have a native stack and no committer. @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] = [] /// **Whether a flush is running right now** — the housekeeper's gate (`GitHousekeeper`, /// 06 ▸ Repository hygiene). /// /// A read of the same flag the engine already uses to keep two flushes off each other, published /// rather than duplicated: the alternative — a second mutual-exclusion mechanism between the /// committer and optional maintenance — would put a new way to *not* commit into the one path /// that must always commit. The repack is safe beside a commit either way (`GitHousekeeping` ▸ /// Concurrency); this is what lets it be polite as well. public var isCommitInFlight: Bool { isFlushing } // 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 /// **Whether an app write has closed with no reload landed since** — the covering await's entry /// gate (`awaitCoveringSnapshot()`). /// /// Set at every write-bracket close and cleared by every landing, so it answers exactly "is /// `currentSnapshot` known to be behind the tree". Without it an explicit flush on a quiet board /// would wait out the whole deadline for a reload nothing has any reason to schedule. /// /// **The one corner it does not cover, recorded rather than discovered**: a reload that was /// already *in flight* when the write bracket closed walked the pre-write tree, and its landing /// clears this flag all the same — the store's landing signal carries no such distinction /// (`HistoryCommitSeam.reloadDidLand`). A flush inside that gap composes from a snapshot one walk /// behind, which is the pre-ruling behaviour for a window narrower than it used to be: the write /// bracket's own mandatory post-bracket reload is already scheduled and lands ~200 ms later, and /// closing the gap properly needs a fact only `BoardStore` has (whether a walk was running). @ObservationIgnored private var holdsUncoveredWrites = false /// Open **card-window 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 the window opened. @ObservationIgnored private var cardSessions: [UUID: @MainActor () -> URL?] = [:] @ObservationIgnored private var pending: Task? @ObservationIgnored private var isFlushing = false /// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`. @ObservationIgnored private var flushWaiters: [CheckedContinuation] = [] 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() { // **The ledger may already hold something, and exactly once it does.** An ordinary board's // ledger is empty here — the app has not written to it, which is the whole of the // launch-catch-up doctrine — so this harvest costs a dictionary copy of nothing. // // The exception is a board the **decision surface repaired** (01-storage-format.md // § Malformed input): those writes happened before this board had a store at all, and their // heal-marked receipts were adopted into the store's ledger a moment ago // (`EchoLedger.adopt`, `BoardWindowHost`). Without this line the only harvest is at a write // bracket's close, and no bracket has closed — so the debounce this arms would find the // repaired files unvouched-for and author the app's own repair `Lanework External`, which is // the one misattribution the mechanism exists to prevent. harvest() 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() // The snapshot the composer diffs is now known to be behind the tree until a reload lands — // see `holdsUncoveredWrites` and `awaitCoveringSnapshot()`. holdsUncoveredWrites = true 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 } holdsUncoveredWrites = false 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 { endFlushing() } 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: - Card-window sessions /// **Registers an open card window's folder** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31 — /// "Board history sees **card-window sessions, not gestures**"): /// /// > while a card's window is open, everything happening inside it — the body editor's ~700 ms /// > crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes — /// > stays **uncommitted**, and the committer **stages around the whole open card folder** (the /// > former Edit-session stage-around, widened; comments included). /// /// So the unit is the **window**, not the body's Edit session: the token is minted when the /// window joins its board and released when its session ends, and everything the window writes in /// between — body saves, comment posts and deletes, inline comment edits, the composer's draft, /// the `comments/.trash/` purge — is inside one folder that no interim flush can see. /// /// 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 /// everything else under it — a foreign write to the same card included, which is what makes the /// close flush's two-commit split the *first* moment that change can land (06 ▸ Rules ▸ /// Auto-commit: "The EchoLedger's two-commit split still applies at close when the held window /// mixes foreign changes to that card with the app's own"). /// /// - 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 beginCardSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) { cardSessions[token] = cardFolder } /// Ends one, and **nudges** — which is what makes "window close flushes the session as one /// commit" true: the session's writes committed nothing while the window stood, and this is the /// moment its whole diff becomes committable (06 ▸ Rules ▸ Auto-commit). /// /// Called after the session's own last writes have landed (`CardWindowSession.endSession()` runs /// to completion first — `AppModel.unregisterCardWindow`), so the diff this arms over is the /// session's *final* state rather than its second-to-last. public func endCardSession(_ token: UUID) { guard cardSessions.removeValue(forKey: token) != nil else { return } arm() } // MARK: - The pause, asked for /// **Re-reads the repository's state without attempting anything** — what the popover's git /// section calls when it appears (06 ▸ Rules ▸ Abnormal repo states: "the popover's git section /// names the state plainly"). /// /// The engine learns about a pause by *trying to commit* and being held, which is the right /// cadence for committing and the wrong one for a surface: a board opened into a detached HEAD /// would show live branch controls for as long as the debounce takes to fire. This is the same /// read the flush takes (`GitCommitOperation.reading`), asked by a surface instead of by a write, /// and it changes nothing else — no arming, no retry, no commit. /// /// A flush landing while this is in flight wins, which is correct: it read the repository later /// and it read it in order to write. public func refreshPause() async { let root = boardRoot pause = await Task.detached(priority: .userInitiated) { GitCommitOperation.reading(at: root).pause }.value } /// 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] { cardSessions.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 awaitCoveringSnapshot() // **Queued behind an in-flight flush, never skipped** — see `awaitFlushInFlight()`. await awaitFlushInFlight() await flush() } /// **Suspends until no flush is running** — what makes `flushNow()` a promise rather than an /// attempt (06-history-undo.md ▸ Rules ▸ Auto-commit: "nothing settled is ever left unsaved or /// uncommitted by closing"). /// /// ### The bug this exists for /// /// `flush()` skips when one is already running, which is exactly right for the **debounce** — a /// timer firing into a commit already in progress has nothing to add, and coalescing is the /// cadence rule. It was catastrophically wrong for the **explicit** flush, which is the close /// flush, the quit flush, the branch switch's pre-checkout flush and File ▸ Duplicate's: those /// callers are not asking for a commit *soon*, they are asking to be told when the pipeline is /// empty, and a `return` gave them that answer while it was still full. /// /// It was reachable, and by a *narrow* margin in one direction and a wide one in the other. The /// close sequence releases each session's stage-around and then nudges the committer /// (`endCardSession`), which arms a fresh debounce; `CloseFlushCoordinator` then spends up to its /// card-drain deadline before reaching `committerFlush`. With the two intervals both at two /// seconds the debounce fired *into* the drain's last moments about half the time — and the flush /// it started had, in the worst case, planned its commit while the session's folder was still /// staged around. So the in-flight flush committed nothing of the session, the close flush skipped /// behind it, and teardown stopped the committer: the window's whole session was left uncommitted, /// permanently, with no later flush anywhere that could have picked it up. Even in the benign /// interleaving `closeBoard` returned — and at quit, `applicationShouldTerminate` replied — while /// the commit was still detached work in flight. /// /// ### The shape /// /// A queue of waiters rather than a lock, `BoardStore.awaitQuiescence()`'s own shape and for its /// reason: this type is `@MainActor`, so there is no data race to exclude — only a *suspension* to /// wait out — and the thing a caller wants is "tell me when it is over", which is what a resumed /// continuation is. The loop re-checks rather than trusting one resumption, so a flush that armed /// another on its way out cannot slip between the resume and the caller's own attempt. private func awaitFlushInFlight() async { while isFlushing { await withCheckedContinuation { flushWaiters.append($0) } } } /// Ends one flush and releases whoever was queued behind it. The single exit for both flushing /// paths — the debounced one and the synchronous flush-before-overwrite — so a waiter can never be /// left suspended by a path that forgot it. private func endFlushing() { isFlushing = false let waiters = flushWaiters flushWaiters.removeAll() for waiter in waiters { waiter.resume() } } /// **The flush awaits the snapshot that covers it** (06-history-undo.md ▸ Rules ▸ Auto-commit, /// ruled 2026-07-31). /// /// > "The composer diffs `store.snapshot` against HEAD, so the close flush awaits a snapshot /// > generation covering its changed paths before the committer runs — the commit's subject can /// > never be outrun by its own reload; the cadence margin (2 s debounce vs 200 ms watcher) is the /// > practical cushion, never the guarantee." /// /// ### What "covering its changed paths" means to this store /// /// A reload is a **whole tree walk** — the store has no changed-path channel at all /// (02-architecture.md; `BoardStore.refreshCommentIndex`'s own note) — so a walk that *started* /// after this flush's writes were on disk covers every path they touched, by construction. There /// is nothing narrower to ask for and nothing narrower to wait on, and that is what makes the /// generation counter a sufficient answer rather than an approximation of one. /// /// Two steps, in this order, are what turn it into a guarantee: /// /// 1. **Quiesce.** A walk already in flight may have started *before* the writes, so its landing /// proves nothing. `BoardStore.awaitQuiescence()` returns when none is running and none is /// owed, which is the moment after which every walk is a walk that started later. /// 2. **Wait for one generation.** The write bracket that produced these changes already /// scheduled the reload that will supply it — unconditionally, whether or not FSEvents said /// anything (`FolderWatcher.endBracket`) — so this is a bounded wait on work already in the /// pipeline, not a hope. /// /// ### Why only the explicit flush /// /// This is `flushNow()`'s alone: the close and quit paths, the branch switch's pre-checkout flush, /// File ▸ Duplicate's pending-work step, and the undo restore's. Those are the flushes that run /// *because* something just finished, which is exactly when the snapshot can still be one walk /// behind. The debounced flush is re-armed by both the write and the reload and fires two seconds /// after the later of them — 06's own "practical cushion", doing the job it is enough for — and /// `noteWillWrite()` cannot await at all, being the synchronous flush-before-overwrite. private func awaitCoveringSnapshot() async { guard holdsUncoveredWrites, let read = landedReloads else { return } await awaitReloadQuiescence?() // Re-read the gate: the quiescence may itself have been the covering landing. guard holdsUncoveredWrites, let base = read() else { return } let started = ContinuousClock.now while let current = read(), current == base { guard ContinuousClock.now - started < coveringSnapshotDeadline else { Self.logger.notice("the covering reload did not land in time; composing from the snapshot in hand") return } try? await Task.sleep(for: coveringSnapshotPollInterval) } } /// 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() } } /// One flush. **Skipping when one is already running is the debounce's rule and only the /// debounce's** — an explicit `flushNow()` has already waited its turn (`awaitFlushInFlight()`) /// before it gets here, so this guard can only ever coalesce a timer. private func flush() async { guard !isFlushing else { return } isFlushing = true defer { endFlushing() } 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: stagedAroundKeys, 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? var commentTimestamps: [String: Date] = [:] } /// 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 } // **The chronology the bullets sort by** (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31) — the // one field of a comment the composer needs and the board snapshot cannot carry. Read beside // the guide's bytes, for the guide's reason, and only for a window that names a comment at all. if namesACard { composition.commentTimestamps = commentTimestamps(for: changed, boardRoot: input.boardRoot) } 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 } /// **When each comment this window touched was created**, keyed by its folder — the chronology /// `CommitMessageEngine` sorts a commit's comment bullets by (06 ▸ Rules ▸ Auto-commit, blessed /// 2026-07-31: "by the comments' own `created`, folder name on ties"). /// /// One `index.md` per touched comment folder, read off the **working tree** — which is the state /// this commit is about to stage, and the only place a comment's own fields exist at all. A folder /// this window *removed* (the close purge) has nothing left to read, and a comment whose /// frontmatter does not parse or carries no `created` answers nothing either: all three are /// absent from the map and sort after their dated siblings, which is `CommentThread.sorted`'s own /// fallback for the same field. Nothing here is a defect and nothing is reported — a commit /// message is the wrong place to discover one (`CommentThread.searchableBodies`' rule, kept). /// /// Internal rather than private so the composer's own suite can resolve the chronology exactly the /// way a flush does, instead of hand-assembling a map the flush could never produce /// (`WriterFixture.snapshot()`'s reason, restated one field down). nonisolated static func commentTimestamps( for changed: [GitChangedPath], boardRoot: URL ) -> [String: Date] { var timestamps: [String: Date] = [:] var seen: Set = [] for path in changed { guard let folder = CommitMessageEngine.commentFolder(of: path.path), seen.insert(folder).inserted else { continue } let index = boardRoot .appendingPathComponent(folder) .appendingPathComponent(IntegrityRules.indexFileName) guard let data = try? Data(contentsOf: index), let document = try? BoardLoader.parseDocument(data, path: folder), let created = document.created.value else { continue } timestamps[folder] = created } return timestamps } /// 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, commentTimestamps: composition.commentTimestamps ) } // **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 `Lanework Integrity `** (06 ▸ Commit // messages ▸ Healing mutations commit separately, ruled 2026-07-31): "a heal is a third // origin — not the user's gesture, not a foreign writer — and the separation exists for // audit, so the trail filters by author like every origin". This authored heals as the // *user* until that ruling, which left the separate commit filterable only by message // shape — and the shape vocabulary deliberately never says "healed". case .heal: authorship = .heal case .user: authorship = .user } // The committer stays the user throughout — 06's recorded-by convention, which is why // only the author varies here. let author: GitIdentity switch authorship { case let .foreign(identity): author = identity case .heal: author = CommitAttribution.integrityIdentity case .user: 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. dropHarvestOutsideOpenSessions() 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 dropHarvestOutsideOpenSessions() 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 } } /// **Forgets the receipts a flush has spent — and keeps the ones it could not** (06 ▸ Interaction /// with external writers: attribution "per file", off the ledger). /// /// A receipt is cleared because the commit it described has landed. Under the widened /// stage-around (`beginCardSession`) a flush routinely lands *without* the session folder, so its /// receipts have not been spent at all: they describe writes still sitting uncommitted on disk, /// waiting for the close flush. Clearing them wholesale is what would make the two-commit split at /// close wrong in exactly the case it exists for — the app's own body save and comment posts would /// arrive at the close unvouched-for and commit as `Lanework External`, blaming the outside world /// for the user's own session. /// /// So the drop is scoped to what the flush could see: everything outside every open session's /// folder goes, everything inside one stays until that session's own commit spends it. private func dropHarvestOutsideOpenSessions() { let open = stagedAroundKeys guard !open.isEmpty else { harvested.removeAll() return } harvested = harvested.filter { key, _ in open.contains { key == $0 || key.hasPrefix($0 + "/") } } } /// The open sessions' folders as `EchoLedger` keys — what both the staging exclusion and the /// harvest's scoped drop compare against, resolved in one place so they cannot disagree. private var stagedAroundKeys: [String] { cardSessions.values.compactMap { $0() }.map(EchoLedger.key) } }