Every settled change on a git-mode board commits, debounced 2s past drag/typing churn, staged whole-root with .gitignore respected. GitCommitOperation reaches the vendored libgit2 directly (same 1.9.2 pin SwiftGitX resolves — importable, not duplicated) for signature-capable commits; add-git's config materialization is gone, identity resolves at commit time (repo-local config, else derived default) per the 2026-07-31 ruling in 06. CommitAttribution classifies per file off EchoLedger receipts: user identity on app-mediated windows, Lanework External <[email protected]> on foreign, the modified-by refinement (<slug>@agents.lanework .invalid) when every foreign file agrees, heal-marked receipts split into their own commit — window split foreign → heal → user. Edit-session granularity: ~700ms saves stay uncommitted, staging excludes open session folders (closure-resolved so mid-session moves stage around the new location), session end nudges the debounce so each session lands exactly one body commit. Flush-before-overwrite gates on known-foreign windows and commits synchronously ahead of the write; close/quit flush the pipeline via CloseFlushCoordinator's committerFlush. index.lock backs off briefly then re-debounces silently; clean tree no-ops; genuine failures ride the standing history-suspension banner and retry next debounce. Abnormal repo states (detached HEAD, merge/rebase/cherry-pick in progress) hold the engine with a 15s re-check; unborn HEAD commits "Initial board state" whole-tree; dirty tree at open catches up through the same engine. Message seam (CommitMessageComposing) ships interim — the semantic composer is the next card. Discovery diffs HEAD against an in-memory index with rename detection (git status alone never pairs a bare mv), and a failed survey reads as "could not look", never "nothing changed". 46 new tests / 8 suites, all real repositories via bundled libgit2. 2240 tests / 383 suites green; InertGitTests untouched. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
512 lines
24 KiB
Swift
512 lines
24 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
// 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 the semantic composer plugs into (next card).
|
|
@ObservationIgnored
|
|
public var composer: any CommitMessageComposing = InterimCommitMessage()
|
|
|
|
/// The board as the app last read it, for the composer's "current" half. `nil` where no store is
|
|
/// attached, which is every storeless test.
|
|
@ObservationIgnored
|
|
public var currentSnapshot: (@MainActor () -> BoardModel?)?
|
|
|
|
/// **A genuine commit failure** — disk full, repo corruption (06: "files stay safe on disk but
|
|
/// history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing, retried
|
|
/// on the next debounce"). Wired to `BannerCenter.suspendHistory(reason:)`.
|
|
///
|
|
/// Deliberately **not** called for lock contention, which "is never an error", nor for a held
|
|
/// repository, whose surface is the popover's badge (the branch-switching card's).
|
|
@ObservationIgnored
|
|
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
|
|
|
/// History is advancing again — the standing suspension's clearing rule
|
|
/// (`BannerCenter.clearHistorySuspension`), which is "the ordinary shape of commit succeeded".
|
|
@ObservationIgnored
|
|
public var reportRecovery: (@MainActor () -> Void)?
|
|
|
|
// MARK: - Observable state
|
|
|
|
/// **The repository state the engine is holding for**, or `nil` when it is free to commit
|
|
/// (06 ▸ Rules ▸ Abnormal repo states).
|
|
///
|
|
/// Queryable rather than merely internal because the *UI* surface of the pause — the popover's
|
|
/// badge and its plain-language explanation, and disabling Undo/Redo, the branch controls, Pull
|
|
/// and Push with it — is the branch-switching card's, and it needs exactly this fact. What this
|
|
/// card owns is the hold itself.
|
|
public private(set) var pause: GitRepositoryPause?
|
|
|
|
/// The last genuine failure, or `nil` if history is advancing. Beside `pause` for the popover's
|
|
/// sake, and because `HistoryStore.lastFailure` is add-git's, not this.
|
|
public private(set) var lastFailure: GitOperationFailure?
|
|
|
|
/// Commits this committer has landed, and the newest OID — the debounce's own testimony, which a
|
|
/// test would otherwise have to infer from a commit walk.
|
|
public private(set) var commitCount = 0
|
|
public private(set) var lastCommitOIDs: [String] = []
|
|
|
|
// MARK: - Private state
|
|
|
|
/// Receipts copied out of the ledger at bracket close, keyed by absolute path. Cleared when a
|
|
/// flush commits them — the window is over, and a stale receipt would vouch for the next window's
|
|
/// changes.
|
|
@ObservationIgnored
|
|
private var harvested: [String: HarvestedReceipt] = [:]
|
|
|
|
/// **Whether this window holds a change nobody vouched for** — the flush-before-overwrite gate.
|
|
@ObservationIgnored
|
|
private var holdsForeignChanges = false
|
|
|
|
/// Open Edit sessions, each answering with the folder to stage around *right now*.
|
|
///
|
|
/// A closure per session rather than a stored URL, because a card can move lane, or into the
|
|
/// trash, in the middle of a session — its folder is a fact about the current snapshot, not
|
|
/// about when Edit was entered.
|
|
@ObservationIgnored
|
|
private var editSessions: [UUID: @MainActor () -> URL?] = [:]
|
|
|
|
@ObservationIgnored
|
|
private var pending: Task<Void, Never>?
|
|
|
|
@ObservationIgnored
|
|
private var isFlushing = false
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
|
|
|
init(boardRoot: URL, ledger: EchoLedger) {
|
|
self.boardRoot = boardRoot
|
|
self.ledger = ledger
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
/// **Launch catch-up** (06 ▸ Commit messages: "changes found pending at board open … through the
|
|
/// same composer, instead of committing blind").
|
|
///
|
|
/// One armed debounce and nothing else: a board that opens clean spends one `git status` and
|
|
/// commits nothing, and a board that opens dirty commits through the ordinary engine — same
|
|
/// split, same authorship, same message seam. Everything found pending classifies **foreign**,
|
|
/// which is not a shortcut but the doctrine: the ledger is empty because the app was not running,
|
|
/// and "the app never vouches for changes it didn't witness".
|
|
public func start() {
|
|
arm()
|
|
}
|
|
|
|
/// Stops the engine and forgets the window. Called at teardown so a closed board's debounce
|
|
/// cannot fire against a store that has gone.
|
|
public func stop() {
|
|
pending?.cancel()
|
|
pending = nil
|
|
}
|
|
|
|
// MARK: - Inbound signals
|
|
|
|
/// **A write bracket closed** — harvest, then arm.
|
|
///
|
|
/// The harvest is the whole reason this signal exists separately from the reload: receipts
|
|
/// describe a completed write and are consumed by the landing reload that classifies them, so
|
|
/// this is the only moment at which the committer can still see them (`HarvestedReceipt`).
|
|
public func noteWriteBracketClosed() {
|
|
harvest()
|
|
arm()
|
|
}
|
|
|
|
/// **A reload landed** — the tree settled, and here is whether any of it was somebody else's.
|
|
///
|
|
/// - Parameter sawForeignChange: what the landing reload's own `EchoLedger.verdicts` concluded.
|
|
/// It arms flush-before-overwrite and nothing else; the commit split re-derives provenance per
|
|
/// *file* at flush time, because this is one bit about a whole reload.
|
|
public func noteReloadLanded(sawForeignChange: Bool) {
|
|
if sawForeignChange { holdsForeignChanges = true }
|
|
arm()
|
|
}
|
|
|
|
/// **Flush-before-overwrite** (06 ▸ Rules): "before an app write overwrites on-disk state that
|
|
/// differs from the last-loaded snapshot … the pending auto-commit is flushed so the external
|
|
/// version enters history first. *Both versions exist as commits* is thereby a guarantee, not a
|
|
/// likelihood."
|
|
///
|
|
/// ### The gate
|
|
///
|
|
/// It fires **only when the window holds a change the app does not vouch for**. That is exactly
|
|
/// the condition under which overwriting can bury someone else's uncommitted version; a window
|
|
/// of nothing but the app's own writes has nothing to protect, and flushing there would commit
|
|
/// once per gesture and make the cadence constraint's "unbearable shared log" come true.
|
|
///
|
|
/// ### The two costs, recorded
|
|
///
|
|
/// **It runs on the main actor, synchronously.** `performWrite` is synchronous — it is a
|
|
/// gesture's write path — so an ordering guarantee *before* it can only be kept by a synchronous
|
|
/// commit. 02's hang-avoidance doctrine and 06's ordering guarantee genuinely conflict here, and
|
|
/// the guarantee wins for an operation that is rare (foreign change pending), bounded (one
|
|
/// stage-and-commit over a board-sized tree), and load-bearing (the alternative is losing a
|
|
/// version of somebody's file with no commit to recover it from).
|
|
///
|
|
/// **A foreign write the watcher has not delivered yet is invisible to it.** The gate learns
|
|
/// about foreign changes from landed reloads, so a write that lands inside the watcher's own
|
|
/// debounce is not yet known to be pending. Bounded by that debounce, and the same window
|
|
/// 05-card-window.md's dirty-buffer rule already calls last-writer-wins — but it is a real gap in
|
|
/// "guarantee", and it is recorded here rather than discovered later.
|
|
public func noteWillWrite() {
|
|
guard holdsForeignChanges, !isFlushing, let input = makeInput() else { return }
|
|
isFlushing = true
|
|
defer { isFlushing = false }
|
|
pending?.cancel()
|
|
pending = nil
|
|
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
|
|
// the foreign version commits on the next quiet debounce instead — which is the same
|
|
// "re-debounce" answer contention gets everywhere else.
|
|
apply(Self.execute(input))
|
|
}
|
|
|
|
// MARK: - Edit sessions
|
|
|
|
/// **Registers an open Edit session's card folder** (06 ▸ Rules ▸ Auto-commit: "The committer
|
|
/// stages around open Edit sessions: a board change committing mid-session excludes the session
|
|
/// card's folder from staging, so a lane move never sweeps half-typed body text into its
|
|
/// commit").
|
|
///
|
|
/// The exclusion is absolute where it applies: "whole-root staging widening *what* commits, never
|
|
/// overriding the exclusion" (06 ▸ Commit messages ▸ Non-snapshot files commit too). A stray
|
|
/// dropped inside the session card's folder therefore waits for the session to end, along with
|
|
/// the body.
|
|
///
|
|
/// - Parameters:
|
|
/// - token: the window's identity, so ending twice is idempotent.
|
|
/// - cardFolder: asked at every flush rather than stored, so a card moved mid-session is staged
|
|
/// around at wherever it now is.
|
|
public func beginEditSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) {
|
|
editSessions[token] = cardFolder
|
|
}
|
|
|
|
/// Ends one, and **nudges** — which is what makes "exactly one body commit per session" true:
|
|
/// the session's debounced saves committed nothing while it was open, and this is the moment its
|
|
/// whole diff becomes committable (06 ▸ Rules ▸ Auto-commit: the Edit→Preview flip is "the
|
|
/// effective Save button"; raw-source entry and window close end the session too).
|
|
public func endEditSession(_ token: UUID) {
|
|
guard editSessions.removeValue(forKey: token) != nil else { return }
|
|
arm()
|
|
}
|
|
|
|
/// Whether a card window's folder is currently staged around — the stage-around rule, made
|
|
/// assertable without reaching into private state.
|
|
public var stagedAroundFolders: [URL] {
|
|
editSessions.values.compactMap { $0() }
|
|
}
|
|
|
|
// MARK: - Flushing
|
|
|
|
/// **Commits now**, cancelling the debounce — the close/quit path, and File ▸ Duplicate's
|
|
/// pending-work step.
|
|
///
|
|
/// 02-architecture.md § Windows fixes where it sits: "closing a board window (and app quit) first
|
|
/// closes the board's card windows — each open Edit session ends with its normal session commit —
|
|
/// then flushes pending debounced work, editor saves before the pending auto-commit, before the
|
|
/// store tears down". `CloseFlushCoordinator.committerFlush` is this, and by the time it runs the
|
|
/// sessions have ended, so nothing is staged around any more.
|
|
public func flushNow() async {
|
|
await flush()
|
|
}
|
|
|
|
/// Arms (or re-arms) the debounce. Every signal funnels through here, so "debounced past drag and
|
|
/// typing churn" is one timer rather than a rule each call site remembers.
|
|
private func arm(after interval: Duration? = nil) {
|
|
pending?.cancel()
|
|
let delay = interval ?? debounceInterval
|
|
pending = Task { [weak self] in
|
|
try? await Task.sleep(for: delay)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.pending = nil
|
|
await self.flush()
|
|
}
|
|
}
|
|
|
|
private func flush() async {
|
|
guard !isFlushing else { return }
|
|
isFlushing = true
|
|
defer { isFlushing = false }
|
|
pending?.cancel()
|
|
pending = nil
|
|
|
|
guard let input = makeInput() else { return }
|
|
|
|
// The brief backoff. Off the main actor for the git work, on it for the sleep, so a held
|
|
// lock costs a couple of suspended turns rather than a blocked UI.
|
|
for attempt in 0...max(0, lockRetryAttempts) {
|
|
let outcome = await Task.detached(priority: .utility) { Self.execute(input) }.value
|
|
if case .locked = outcome, attempt < max(0, lockRetryAttempts) {
|
|
try? await Task.sleep(for: lockRetryDelay)
|
|
continue
|
|
}
|
|
apply(outcome)
|
|
return
|
|
}
|
|
}
|
|
|
|
// MARK: - The plan
|
|
|
|
/// Everything one flush needs, as values — so the whole of it can cross to a detached task.
|
|
private struct FlushInput: Sendable {
|
|
let boardRoot: URL
|
|
let excludedFolders: [String]
|
|
let receipts: [String: HarvestedReceipt]
|
|
let composer: any CommitMessageComposing
|
|
let snapshot: BoardModel?
|
|
}
|
|
|
|
private func makeInput() -> FlushInput? {
|
|
FlushInput(
|
|
boardRoot: boardRoot,
|
|
excludedFolders: editSessions.values.compactMap { $0() }.map(EchoLedger.key),
|
|
receipts: harvested,
|
|
composer: composer,
|
|
snapshot: currentSnapshot?()
|
|
)
|
|
}
|
|
|
|
/// **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) -> GitCommitOutcome {
|
|
let reading = GitCommitOperation.reading(at: input.boardRoot)
|
|
if let pause = reading.pause { return .held(pause) }
|
|
if reading.isIndexLocked { return .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 .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 .nothingToCommit }
|
|
|
|
return GitCommitOperation.perform(
|
|
at: input.boardRoot,
|
|
commits: plan(changed, reading: reading, input: input)
|
|
)
|
|
}
|
|
|
|
/// 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
|
|
) -> [PlannedCommit] {
|
|
let user = GitCommitOperation.userIdentity(at: input.boardRoot)
|
|
|
|
// **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: CommitMessageRequest(
|
|
boardRoot: input.boardRoot,
|
|
changedPaths: changed,
|
|
authorship: .user,
|
|
isRootCommit: true,
|
|
snapshot: input.snapshot
|
|
)),
|
|
author: user,
|
|
committer: user
|
|
)]
|
|
}
|
|
|
|
let split = CommitAttribution.split(changed, under: input.boardRoot, receipts: input.receipts)
|
|
return split.ordered.map { group in
|
|
let authorship: CommitAuthorship
|
|
switch group.kind {
|
|
case .foreign:
|
|
authorship = .foreign(
|
|
CommitAttribution.foreignIdentity(for: group.paths, under: input.boardRoot)
|
|
)
|
|
// **A heal is authored by the user**, recorded as a judgment call: DESIGN fixes that a
|
|
// heal's paths commit *separately* and says nothing about who they are by. The healer is
|
|
// the app acting on the user's behalf — its writes are app-mediated, receipt and all — so
|
|
// authoring them as the user is the honest reading, and authoring them as `Lanework
|
|
// External` would blame the outside world for the app's own repair.
|
|
case .heal: authorship = .heal
|
|
case .user: authorship = .user
|
|
}
|
|
let author: GitIdentity
|
|
if case let .foreign(identity) = authorship { author = identity } else { author = user }
|
|
return PlannedCommit(
|
|
paths: group.paths.map(\.path),
|
|
message: input.composer.message(for: CommitMessageRequest(
|
|
boardRoot: input.boardRoot,
|
|
changedPaths: group.paths,
|
|
authorship: authorship,
|
|
isRootCommit: false,
|
|
snapshot: input.snapshot
|
|
)),
|
|
author: author,
|
|
committer: user
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
switch outcome {
|
|
case let .committed(oids):
|
|
pause = nil
|
|
lastFailure = nil
|
|
commitCount += oids.count
|
|
lastCommitOIDs = oids
|
|
// The window is over: its receipts have said everything they can say, and keeping them
|
|
// would let them vouch for the *next* window's changes to the same paths.
|
|
harvested.removeAll()
|
|
holdsForeignChanges = false
|
|
reportRecovery?()
|
|
Self.logger.debug("auto-commit landed \(oids.count, privacy: .public) commit(s)")
|
|
|
|
case .nothingToCommit:
|
|
// **The happy path, not a malfunction** (06): an agent committed its own work, or the
|
|
// whole window was staged around. Silent, and the window closes either way.
|
|
pause = nil
|
|
lastFailure = nil
|
|
harvested.removeAll()
|
|
holdsForeignChanges = false
|
|
reportRecovery?()
|
|
|
|
case .locked:
|
|
// "No banner, no log-worthy failure: a held lock is another writer doing its job." The
|
|
// changes are still pending and the harvest is still held, so the next quiet moment
|
|
// commits them with their provenance intact.
|
|
Self.logger.debug("index.lock held — re-debouncing")
|
|
arm()
|
|
|
|
case let .held(reason):
|
|
pause = reason
|
|
Self.logger.notice("auto-commit held: \(reason.rawValue, privacy: .public)")
|
|
arm(after: holdRecheckInterval)
|
|
|
|
case let .failed(failure):
|
|
pause = nil
|
|
lastFailure = failure
|
|
Self.logger.error("auto-commit failed: \(failure.description, privacy: .public)")
|
|
reportFailure?(failure)
|
|
arm()
|
|
}
|
|
}
|
|
|
|
// MARK: - Harvest
|
|
|
|
/// Copies the ledger's current receipts into this window's own record.
|
|
///
|
|
/// Whole-ledger rather than bracket-scoped, deliberately: the Writer's primitives drop receipts
|
|
/// without telling anyone which paths they were, and a diff of key sets would miss a
|
|
/// *supersession* (the same key, newer bytes) — which is exactly the case that must not be
|
|
/// missed, since the newest write is the one disk will be compared against. Copying is cheap:
|
|
/// the ledger holds tens of entries, and the harvest happens once per gesture.
|
|
private func harvest() {
|
|
for (path, entry) in ledger.outstandingEntries() {
|
|
harvested[path] = entry
|
|
}
|
|
}
|
|
}
|