Build the auto-commit engine
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
This commit is contained in:
@@ -432,6 +432,18 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
public weak var history: (any HistoryProviding)?
|
||||
|
||||
/// **Where Pro's auto-committer meets the write and reload paths** (06-history-undo.md ▸ Rules
|
||||
/// ▸ Auto-commit), or `nil` on every board there is no committer for — which is every free-tier
|
||||
/// board and every Pro board without a repository at its root.
|
||||
///
|
||||
/// Injected like `watcherBrackets` and `history`, and for their reason: the committer belongs to
|
||||
/// the *session* (`HistoryStore.committer`), and a store that reached for one would be a second
|
||||
/// answer to which committer a board has. `nil` keeps every method below behaving exactly as it
|
||||
/// did before this milestone — which is what makes the free tier's inert posture structural
|
||||
/// rather than conditional.
|
||||
@ObservationIgnored
|
||||
public var commitSeam: HistoryCommitSeam?
|
||||
|
||||
// MARK: Reload machinery
|
||||
|
||||
/// Monotonic id of the most recently *started* reload — and therefore also the number of tree
|
||||
@@ -713,6 +725,13 @@ public final class BoardStore: HealHost {
|
||||
facts.lockBefore = readOnlyLock
|
||||
facts.breakageBefore = reloadFailure
|
||||
|
||||
// **Whether this reload revealed anything the app does not vouch for** — the one bit the
|
||||
// auto-committer's flush-before-overwrite gate turns on (06 ▸ Rules ▸ Flush-before-overwrite).
|
||||
// A failed reload counts as foreign, conservatively: a file the loader could not read is one
|
||||
// the app certainly did not write, and the safe direction is to let the next app write commit
|
||||
// what is there before overwriting it.
|
||||
var sawForeignChange = false
|
||||
|
||||
switch outcome {
|
||||
case let .success(result):
|
||||
// **What changed, who changed it, and what it cost the cursor** — all three computed
|
||||
@@ -744,6 +763,7 @@ public final class BoardStore: HealHost {
|
||||
includingTrash: shownTrash
|
||||
)
|
||||
facts.diff = verdicts.foreign
|
||||
sawForeignChange = verdicts.foreign.boardChanged || !verdicts.foreignItems.isEmpty
|
||||
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
|
||||
// "deleted *externally*", which would be a lie about an app-mediated delete — whose
|
||||
// own command already chose a successor (04-interactions.md ▸ The map's ⌫ rule) and
|
||||
@@ -854,6 +874,7 @@ public final class BoardStore: HealHost {
|
||||
if endsWholesaleOperation, readOnlyLock == nil {
|
||||
readOnlyLock = .bracketedReloadFailed
|
||||
}
|
||||
sawForeignChange = true
|
||||
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -864,6 +885,18 @@ public final class BoardStore: HealHost {
|
||||
facts.lockAfter = readOnlyLock
|
||||
facts.breakageAfter = reloadFailure
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
|
||||
// **The auto-commit debounce, armed by every landing** (06 ▸ Rules ▸ Auto-commit; ▸
|
||||
// Interaction with external writers: "Agent and hand edits arrive through the watcher like
|
||||
// any change and get auto-committed on the same debounce").
|
||||
//
|
||||
// Here rather than at the watcher, deliberately: a reload landing means the tree walk is
|
||||
// over, so the committer never races the loader for the same files. **Unconditional on what
|
||||
// changed**, equally deliberately — a reload lands whether or not the snapshot moved, and the
|
||||
// committer's condition is the *tree*, not the snapshot diff, so a window that touched only
|
||||
// strays or only `CLAUDE.md` still commits (06 ▸ Commit messages ▸ Non-snapshot files commit
|
||||
// too). A landing that finds nothing to commit is the silent no-op, not a wasted trip.
|
||||
commitSeam?.reloadDidLand(sawForeignChange)
|
||||
}
|
||||
|
||||
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
|
||||
@@ -1118,10 +1151,25 @@ public final class BoardStore: HealHost {
|
||||
if let readOnlyLock {
|
||||
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
|
||||
}
|
||||
// **Flush-before-overwrite** (06-history-undo.md ▸ Rules), before the bracket rather than
|
||||
// inside it: what the committer may need to do here is *commit*, and a commit taken with the
|
||||
// watcher suspended would be a commit whose own reload never arrives. It is a no-op unless
|
||||
// the window holds a change the app does not vouch for — see `GitAutoCommitter.noteWillWrite`
|
||||
// for the gate, and for the two costs it is recorded as carrying.
|
||||
commitSeam?.willWrite()
|
||||
watcherBrackets?.begin()
|
||||
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
|
||||
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
|
||||
defer { watcherBrackets?.end() }
|
||||
//
|
||||
// **The receipt harvest rides the same defer**, and after `end()` deliberately: the committer
|
||||
// copies the ledger's receipts here because the landing reload *consumes* them, and this is
|
||||
// the last moment they still describe a completed write nothing has classified yet
|
||||
// (`EchoLedger.outstandingEntries`). A partway failure harvests too — bytes that reached disk
|
||||
// are bytes the next commit will carry, whoever they belong to.
|
||||
defer {
|
||||
watcherBrackets?.end()
|
||||
commitSeam?.writeBracketDidClose()
|
||||
}
|
||||
// **The receipt seam** (02-architecture.md ▸ Components ▸ EchoLedger). Binding the ledger
|
||||
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
|
||||
// the same bullet requires: the Writer's disk primitives drop receipts into whichever
|
||||
|
||||
@@ -268,6 +268,26 @@ public final class EchoLedger: Sendable {
|
||||
receipts.withLock { $0[path]?.receipt }
|
||||
}
|
||||
|
||||
/// **Every receipt the ledger holds right now, with its heal mark — read, never consumed.**
|
||||
///
|
||||
/// Pro's auto-committer's one call (`GitAutoCommitter.harvest`), and it has to be a copy rather
|
||||
/// than a read at commit time for an ordering reason worth stating here: receipts are *consumed*
|
||||
/// by the landing reload that classifies them ("one write, one echo"), and the committer asks its
|
||||
/// question a debounce later — by which time the receipt for the user's own card edit is long
|
||||
/// gone, and reading the live ledger would attribute the user's own work to `Lanework External`.
|
||||
/// So the committer copies at the close of each write bracket, when a receipt describes a
|
||||
/// completed write and nothing has yet had a chance to retire it, and re-applies the satisfaction
|
||||
/// rule against disk itself (`CommitAttribution`).
|
||||
///
|
||||
/// Nothing is retired here, which is what makes this safe to call on every bracket: the
|
||||
/// announcer's consumption still decides what speaks, and the committer's copy still decides what
|
||||
/// each commit is authored by.
|
||||
func outstandingEntries() -> [String: HarvestedReceipt] {
|
||||
receipts.withLock { store in
|
||||
store.mapValues { HarvestedReceipt(receipt: $0.receipt, isHeal: $0.isHeal) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the receipt at this path is heal-marked — `false` for a path with no receipt at all,
|
||||
/// which is the same shrug every other read here gives an unknown path.
|
||||
public func isHeal(at url: URL) -> Bool {
|
||||
|
||||
Reference in New Issue
Block a user