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:
2026-07-31 14:10:55 -04:00
parent 189af238a1
commit 3c07c26fda
17 changed files with 3009 additions and 60 deletions
+49 -1
View File
@@ -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
+20
View File
@@ -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 {