Make the card window the commit unit on Pro boards

Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.

Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.

2444 tests in 422 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 20:23:45 -04:00
parent 9119aa1e9a
commit a381fac742
11 changed files with 1709 additions and 105 deletions
+65 -21
View File
@@ -208,13 +208,13 @@ public final class GitAutoCommitter {
@ObservationIgnored
private var holdsForeignChanges = false
/// Open Edit sessions, each answering with the folder to stage around *right now*.
/// Open **card-window 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.
/// about when the window opened.
@ObservationIgnored
private var editSessions: [UUID: @MainActor () -> URL?] = [:]
private var cardSessions: [UUID: @MainActor () -> URL?] = [:]
@ObservationIgnored
private var pending: Task<Void, Never>?
@@ -317,32 +317,46 @@ public final class GitAutoCommitter {
apply(result.outcome, healPaths: result.healPaths)
}
// MARK: - Edit sessions
// MARK: - Card-window 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").
/// **Registers an open card window's folder** (06 Rules Auto-commit, widened 2026-07-31
/// "Board history sees **card-window sessions, not gestures**"):
///
/// > while a card's window is open, everything happening inside it the body editor's ~700 ms
/// > crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes
/// > stays **uncommitted**, and the committer **stages around the whole open card folder** (the
/// > former Edit-session stage-around, widened; comments included).
///
/// So the unit is the **window**, not the body's Edit session: the token is minted when the
/// window joins its board and released when its session ends, and everything the window writes in
/// between body saves, comment posts and deletes, inline comment edits, the composer's draft,
/// the `comments/.trash/` purge is inside one folder that no interim flush can see.
///
/// 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.
/// everything else under it a foreign write to the same card included, which is what makes the
/// close flush's two-commit split the *first* moment that change can land (06 Rules
/// Auto-commit: "The EchoLedger's two-commit split still applies at close when the held window
/// mixes foreign changes to that card with the app's own").
///
/// - 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
public func beginCardSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) {
cardSessions[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 EditPreview 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 }
/// Ends one, and **nudges** which is what makes "window close flushes the session as one
/// commit" true: the session's writes committed nothing while the window stood, and this is the
/// moment its whole diff becomes committable (06 Rules Auto-commit).
///
/// Called after the session's own last writes have landed (`CardWindowSession.endSession()` runs
/// to completion first `AppModel.unregisterCardWindow`), so the diff this arms over is the
/// session's *final* state rather than its second-to-last.
public func endCardSession(_ token: UUID) {
guard cardSessions.removeValue(forKey: token) != nil else { return }
arm()
}
@@ -370,7 +384,7 @@ public final class GitAutoCommitter {
/// 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() }
cardSessions.values.compactMap { $0() }
}
// MARK: - Flushing
@@ -436,7 +450,7 @@ public final class GitAutoCommitter {
private func makeInput() -> FlushInput? {
FlushInput(
boardRoot: boardRoot,
excludedFolders: editSessions.values.compactMap { $0() }.map(EchoLedger.key),
excludedFolders: stagedAroundKeys,
receipts: harvested,
composer: composer,
snapshot: currentSnapshot?()
@@ -639,7 +653,7 @@ public final class GitAutoCommitter {
reportLanded?(GitLandedWindow(commits: landed, healPaths: healPaths))
// 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()
dropHarvestOutsideOpenSessions()
holdsForeignChanges = false
reportRecovery?()
Self.logger.debug("auto-commit landed \(oids.count, privacy: .public) commit(s)")
@@ -649,7 +663,7 @@ public final class GitAutoCommitter {
// whole window was staged around. Silent, and the window closes either way.
pause = nil
lastFailure = nil
harvested.removeAll()
dropHarvestOutsideOpenSessions()
holdsForeignChanges = false
reportRecovery?()
@@ -688,4 +702,34 @@ public final class GitAutoCommitter {
harvested[path] = entry
}
}
/// **Forgets the receipts a flush has spent and keeps the ones it could not** (06 Interaction
/// with external writers: attribution "per file", off the ledger).
///
/// A receipt is cleared because the commit it described has landed. Under the widened
/// stage-around (`beginCardSession`) a flush routinely lands *without* the session folder, so its
/// receipts have not been spent at all: they describe writes still sitting uncommitted on disk,
/// waiting for the close flush. Clearing them wholesale is what would make the two-commit split at
/// close wrong in exactly the case it exists for the app's own body save and comment posts would
/// arrive at the close unvouched-for and commit as `Lanework External`, blaming the outside world
/// for the user's own session.
///
/// So the drop is scoped to what the flush could see: everything outside every open session's
/// folder goes, everything inside one stays until that session's own commit spends it.
private func dropHarvestOutsideOpenSessions() {
let open = stagedAroundKeys
guard !open.isEmpty else {
harvested.removeAll()
return
}
harvested = harvested.filter { key, _ in
open.contains { key == $0 || key.hasPrefix($0 + "/") }
}
}
/// The open sessions' folders as `EchoLedger` keys what both the staging exclusion and the
/// harvest's scoped drop compare against, resolved in one place so they cannot disagree.
private var stagedAroundKeys: [String] {
cardSessions.values.compactMap { $0() }.map(EchoLedger.key)
}
}