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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user