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
+72 -1
View File
@@ -745,6 +745,25 @@ public final class AppModel {
// at the moment of each load rather than at composition.
if let git {
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
// **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
// Rules Auto-commit). Called on every Pro session and not only on git-mode ones,
// because add-git can flip a board mid-session and the committer it builds then must land
// in exactly this shape `activateAutoCommit` remembers the wiring for that.
git.activateAutoCommit { [weak store] committer in
guard let store else { return }
committer.currentSnapshot = { [weak store] in store?.snapshot }
// 02-architecture.md Write-failure surfacing, through the strip the board window
// already renders: a genuine commit failure means "your edits are saved, history has
// stopped advancing", which is exactly what the standing suspension row says. Lock
// contention and a held repository never reach here neither is a failure.
committer.reportFailure = { [weak store] failure in
store?.banners.suspendHistory(reason: failure.message)
}
committer.reportRecovery = { [weak store] in
store?.banners.clearHistorySuspension()
}
store.commitSeam = .binding(to: committer)
}
}
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
// store is that boundary every app-mediated mutation goes out through one of its write
@@ -791,6 +810,43 @@ public final class AppModel {
func unregisterCardWindow(_ ref: CardWindowRef) {
sessions[ref.board]?.cardRefs.remove(ref)
cardSessions[ref] = nil
// A window that left without its session ending a crash-shaped teardown, or a dismissal
// that raced the flush must not leave its card folder excluded from staging forever.
setEditSession(false, for: ref)
}
/// Tokens the committer knows each card window's Edit session by. Beside `cardSessions` for its
/// reason: this is the seam table's third column, written only here.
@ObservationIgnored
private var editSessionTokens: [CardWindowRef: UUID] = [:]
/// **A card window's Edit session opened or closed** (06-history-undo.md Rules Auto-commit:
/// the committer "stages around open Edit sessions").
///
/// This is the honest seam between the two halves of the rule: `CardBodyEditSession` knows a
/// session is open, the committer knows what staging is, and only the app model knows which board
/// a card window belongs to and how to reach its committer. A board with no committer the free
/// tier, a Pro board with no repository records nothing, which is the same `nil` every other
/// git seam takes.
///
/// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be
/// moved into the trash, in the middle of a session, and what must be staged around is wherever
/// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans
/// both containers, which is exactly why the body save uses it too.
func setEditSession(_ isOpen: Bool, for ref: CardWindowRef) {
guard let session = sessions[ref.board], let committer = session.git?.committer else { return }
if isOpen {
let token = editSessionTokens[ref] ?? UUID()
editSessionTokens[ref] = token
let cardID = ref.cardIdentity
committer.beginEditSession(token) { [weak store = session.store] in
guard let store,
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
return path.folder(under: store.rootURL)
}
} else if let token = editSessionTokens.removeValue(forKey: ref) {
committer.endEditSession(token)
}
}
// MARK: - Launch failures
@@ -1017,7 +1073,17 @@ public final class AppModel {
// 02-architecture.md puts them ("each open Edit session ends with its normal session
// commit", then pending work). The slot stays for a board-level editor with no card
// window of its own the raw-source buffer is the candidate so that the order
// relative to `committerFlush` (m7) is already decided when one arrives.
// relative to `committerFlush` is already decided when one arrives.
//
// **`committerFlush` is filled now** (06-history-undo.md Rules Auto-commit: "Board
// window close and app quit flush the pipeline any pending editor save, then the
// pending auto-commit before teardown; nothing settled is ever left unsaved or
// uncommitted by closing"). By the time it runs, step 1 has ended every card window's
// Edit session, so nothing is staged around and each session's body lands in exactly one
// commit. `nil` on every board with no committer, which is the whole free tier.
committerFlush: { [weak self] in
await self?.sessions[ref]?.git?.committer?.flushNow()
},
recordClose: { [weak self] in
guard let self, let session = sessions[ref] else { return }
let counts = Self.liveCounts(of: session.store.snapshot)
@@ -1036,6 +1102,11 @@ public final class AppModel {
},
tearDown: { [weak self] in
guard let self, let session = sessions.removeValue(forKey: ref) else { return }
// The committer dies with the session it commits for, `history.clear()`'s reason
// exactly: its debounce holds a closure over the store this line is about to release,
// and a timer that outlived its board would fire against a repository nobody is
// looking at. Its pending work has already been flushed by `committerFlush` above.
session.git?.stopAutoCommit()
// Session-only persistence, the other half of `beginSession` (13-native-undo.md
// Rules): "the stack ... dies at close/quit", so reopening the board starts empty.
// Cleared rather than merely dropped because the steps hold closures over the store
+11
View File
@@ -523,6 +523,17 @@ struct CardWindowHost: View {
bodyPresentation.flushEdits = { [session] in
session.body.endEditSession()
}
bodyPresentation.beginEdits = { [session] in
session.body.beginEditSession()
}
// **The stage-around registry's one wire** (06-history-undo.md Rules Auto-commit). The
// buffer announces its session boundary, the app model knows which board this card belongs
// to, and the committer knows what staging is; this line is the join, and it is the only
// place all three are in scope. A free-tier board or any board with no repository has no
// committer, so `setEditSession` records nothing and the buffer never learns the difference.
session.body.editSessionDidChange = { [appModel, ref] isEditing in
appModel.setEditSession(isEditing, for: ref)
}
Self.configureRawSource(
rawSource,
body: session.body,