1021 lines
55 KiB
Swift
1021 lines
55 KiB
Swift
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<String>
|
|
|
|
public init(commits: [GitLandedCommit], healPaths: Set<String>) {
|
|
self.commits = commits
|
|
self.healPaths = healPaths
|
|
}
|
|
|
|
/// The oids of the heal-class commits — the ones the undo pointer passes over.
|
|
public var healOIDs: Set<String> {
|
|
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)?
|
|
|
|
/// **The repository became unreadable, or readable again** — the standing breakage banner's
|
|
/// raise and heal (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure, ruled
|
|
/// 2026-07-31: "a standing breakage-class banner at detection … the banner clears when a later
|
|
/// open or reload finds the repo readable").
|
|
///
|
|
/// Called on the *transition only*, with the new answer — so a board that stands unreadable for
|
|
/// an hour posts one row rather than one per 15 s re-read, and a repository repaired in a
|
|
/// terminal heals the row on the first re-read that opens it.
|
|
///
|
|
/// Separate from `reportFailure` because the two conditions are different rows saying different
|
|
/// things: a failed commit is "history stopped advancing, here is the error" (a retry away),
|
|
/// while this is "there is no repository the app can read at all". Wired by
|
|
/// `AppModel.beginSession` to `BoardStore.noteRepositoryUnreadable(_:)`.
|
|
@ObservationIgnored
|
|
public var reportRepositoryUnreadable: (@MainActor (Bool) -> 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<Void, Never>?
|
|
|
|
@ObservationIgnored
|
|
private var isFlushing = false
|
|
|
|
/// Explicit flushes suspended behind the one in flight, resumed together by `endFlushing()`.
|
|
@ObservationIgnored
|
|
private var flushWaiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
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
|
|
let read = await Task.detached(priority: .userInitiated) {
|
|
GitCommitOperation.reading(at: root).pause
|
|
}.value
|
|
setPause(read)
|
|
}
|
|
|
|
/// **The detection-time probe's answer, seeded before anything has been attempted**
|
|
/// (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure: "a standing breakage-class
|
|
/// banner **at detection**").
|
|
///
|
|
/// The engine's ordinary way of learning a pause is to try to commit and be held, which is the
|
|
/// right cadence for committing and far too late for this one: the ruling's whole point is that
|
|
/// the failure is loud at the open rather than discovered a debounce later — or, worse, only in
|
|
/// the popover. `HistoryStore`'s composition probes (`GitRepository.canOpen`) and calls this.
|
|
///
|
|
/// It is deliberately the *same* state a held flush would have reached, not a parallel flag: one
|
|
/// pause, one surface, and the first re-read either confirms it or heals it.
|
|
public func noteRepositoryUnreadable() {
|
|
setPause(.unreadable)
|
|
}
|
|
|
|
/// The one place `pause` is assigned, so the raise-and-heal seam cannot be forgotten by a path
|
|
/// that sets it (`reportRepositoryUnreadable`). Fires on the transition only — entering
|
|
/// `.unreadable` from anything else, or leaving it for anything else, `nil` included.
|
|
private func setPause(_ new: GitRepositoryPause?) {
|
|
let was = pause == .unreadable
|
|
pause = new
|
|
let now = new == .unreadable
|
|
guard was != now else { return }
|
|
reportRepositoryUnreadable?(now)
|
|
}
|
|
|
|
/// 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<String> = []
|
|
}
|
|
|
|
/// **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<String> = []
|
|
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 <[email protected]>`** (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<String> = []) {
|
|
switch outcome {
|
|
case let .committed(landed):
|
|
let oids = landed.map(\.oid)
|
|
setPause(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.
|
|
setPause(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):
|
|
setPause(reason)
|
|
Self.logger.notice("auto-commit held: \(reason.rawValue, privacy: .public)")
|
|
// **The standing pause's own re-read** (06 ▸ Rules ▸ Abnormal repo states, blessed
|
|
// 2026-07-31) — and the mid-session healing path for the unreadable repository too: the
|
|
// watcher never delivers `.git`, so a repository repaired in a terminal has no other way
|
|
// to be noticed before the next open.
|
|
arm(after: holdRecheckInterval)
|
|
|
|
case let .failed(failure):
|
|
setPause(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)
|
|
}
|
|
}
|