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
+113 -31
View File
@@ -772,7 +772,14 @@ public final class AppModel {
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
// disk itself would be a second detection able to disagree with this one.
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
//
// **The ledger is the store's own** (06 Interaction with external writers: "the Writer/echo
// machinery the EchoLedger lets the auto-committer classify every observed change, per
// file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less
// callers (the add-git surface, unit tests), and a session that took that default would hand
// the committer a ledger nothing ever writes to: every commit this app made would classify
// foreign and be authored `Lanework External`. The default is a fallback, never this path's.
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes)
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards
@@ -875,7 +882,12 @@ public final class AppModel {
}
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
provider.resumeCommitting = { [weak git] in git?.committer?.start() }
// The stage-around the settle released comes back with the committer: a card window still open
// after the restore is still a session (`resumeCardSessionStaging(for:)`).
provider.resumeCommitting = { [weak self, weak git] in
git?.committer?.start()
self?.resumeCardSessionStaging(for: ref)
}
// **A restore that failed cleanly** (06 Interaction with external writers: "surfaces as a
// one-shot banner failure naming the operation and the error, the tree left as it was").
//
@@ -903,7 +915,11 @@ public final class AppModel {
// `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderName: folder)
}
return await gate.settle(touching: paths)
let outcome = await gate.settle(touching: paths)
// **Only on `.proceed`** a cancelled or failed settle leaves the board exactly as it
// was, sessions and their staging included.
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
return outcome
}
provider.seed()
@@ -929,7 +945,12 @@ public final class AppModel {
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
switcher.resumeCommitting = { [weak git] in git?.committer?.start() }
// As on the restore path: what the settle released is a session that has not ended, and the
// window is still open on the other side of the checkout.
switcher.resumeCommitting = { [weak self, weak git] in
git?.committer?.start()
self?.resumeCardSessionStaging(for: ref)
}
// **The undo/redo reseed** the provider's own API, which is the relaunch reseed by
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry redo
// starts empty".
@@ -974,7 +995,11 @@ public final class AppModel {
switcher?.noteDiscarded(cardFolderName: folder)
}
// Every open session, not the ones a diff reaches see `SessionSettleGate.settleAll`.
return await gate.settleAll()
let outcome = await gate.settleAll()
// The switch's flush runs next and must find a tree it can settle whole see
// `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough.
if outcome == .proceed { self.releaseCardSessionStaging(for: ref) }
return outcome
}
// **The own-leftovers check, at open** (06 Rules Abnormal repo states). Beside the
@@ -1097,47 +1122,94 @@ public final class AppModel {
}
sessions[ref.board]?.cardRefs.insert(ref)
cardSessions[ref] = session
// **The window *is* the commit unit** (06 Rules Auto-commit, widened 2026-07-31), so the
// stage-around opens here with the window rather than at the body's first EditPreview
// flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land
// in an interim commit.
setCardSession(true, for: ref)
}
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)
// **The close flush's release** and, for a window that left without its session ending (a
// crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a
// card folder excluded from staging forever. Both are the same line because both mean the same
// thing: this window is no longer holding its folder back.
setCardSession(false, for: ref)
}
/// Tokens the committer knows each card window's Edit session by. Beside `cardSessions` for its
/// Tokens the committer knows each card window's session by. Beside `cardSessions` for its
/// reason: this is the seam table's third column, written only here.
@ObservationIgnored
private var editSessionTokens: [CardWindowRef: UUID] = [:]
private var cardSessionTokens: [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").
/// **A card window's session opened or closed** (06-history-undo.md Rules Auto-commit: the
/// committer "stages around the whole open card folder").
///
/// 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.
/// This is the honest seam between the two halves of the rule: the host knows a window exists, 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) {
///
/// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume),
/// and closing an already-closed session reaches a committer that has nothing to remove.
func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) {
guard isOpen else {
// **The token goes whether or not there is anyone left to tell.** A card window's own
// teardown can land after its board's, and a token kept past the board it names would
// outlive everything that could ever release it.
guard let token = cardSessionTokens.removeValue(forKey: ref) else { return }
sessions[ref.board]?.git?.committer?.endCardSession(token)
return
}
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)
let token = cardSessionTokens[ref] ?? UUID()
cardSessionTokens[ref] = token
let cardID = ref.cardIdentity
committer.beginCardSession(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)
}
}
/// **The stage-around releases at the settle step** (06 Branch switching; Rules Undo restore
/// vs open Edit sessions) every open card window on this board, unconditionally.
///
/// ### Why unconditionally, rather than through the modal
///
/// The save-or-discard step asks about *buffers* "unsaved keystrokes, or on-disk ~700 ms saves
/// the session hasn't committed" and a window that is merely open, with a comment posted an hour
/// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around
/// that window is still holding its whole folder out of every commit, so leaving it held would
/// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with
/// sessions settled the restore runs on a settled tree it cannot fail dirty".
///
/// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate:
/// Save All's flush then carries the session's commit, and Discard's reverted bytes are
/// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is
/// why this runs *after* the gate has answered rather than before it.
func releaseCardSessionStaging(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
setCardSession(false, for: cardRef)
}
}
/// **The next session begins** the other half of `releaseCardSessionStaging(for:)`, run when the
/// operation behind the settle has finished with the tree.
///
/// A window that is still open after a restore or a branch switch is still a session, and its
/// folder must go back to being staged around. Idempotent, so the paths that resume without ever
/// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup.
func resumeCardSessionStaging(for ref: BoardWindowRef) {
for cardRef in sessions[ref]?.cardRefs ?? [] {
setCardSession(true, for: cardRef)
}
}
@@ -1353,6 +1425,15 @@ public final class AppModel {
},
endCardSession: { [weak self] cardRef in
await self?.cardSessions[cardRef]?.endSession()
// **The release, here rather than only at the host's unregister** (06 Rules
// Auto-commit). The unregister does release it that is what closes a window on its
// own but it arrives from the *window's* teardown, which this sequence waits for
// only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window
// was slow to disappear would then flush with the folder still staged around and leave
// a settled session uncommitted, which is precisely what "nothing settled is ever left
// ... uncommitted by closing" forbids. Ending the session is this step's own act, so
// releasing what the session held is too. Idempotent with the unregister.
self?.setCardSession(false, for: cardRef)
},
dismissCardWindow: { [weak self] cardRef in
self?.windowDismisser?(value: cardRef)
@@ -1372,8 +1453,9 @@ public final class AppModel {
// 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.
// session *and released its stage-around*, so each session body, comments, purge and
// all 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()
},
+17 -12
View File
@@ -634,14 +634,12 @@ struct CardWindowHost: View {
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)
}
// **No stage-around wire here any more** (06-history-undo.md Rules Auto-commit, widened
// 2026-07-31 recorded because its absence is the change): the EditPreview flip used to open
// and close the committer's exclusion, and the unit is now the *window*, so the exclusion is
// opened by `AppModel.registerCardWindow` and released by `unregisterCardWindow` after the
// session's own last writes. A flip that still moved it would un-hold the folder in the middle
// of a session whose comment posts and draft saves are supposed to be inside one commit.
Self.configureRawSource(
rawSource,
body: session.body,
@@ -949,15 +947,22 @@ struct CardWindowHost: View {
/// Leaves the session and lets the store go.
///
/// The release rides **behind** the session's end rather than beside it: a session that has
/// something to commit (m6) needs the store it is committing through, and a refcount that hit
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the
/// ordering costs one run-loop turn the point is that the shape is already right.
/// something to commit needs the store it is committing through, and a refcount that hit zero
/// first would have stopped the watcher underneath it.
///
/// **Unregistering rides behind it too** (06-history-undo.md Rules Auto-commit: "window close
/// flushes the session as one commit"), which is new in this milestone and is the whole ordering
/// the one-commit rule rests on: unregistering is what releases the committer's stage-around, and
/// releasing it before `endSession()` had written the body's last keystrokes, posted the draft and
/// purged `comments/.trash/` would leave a debounce free to fire over a half-finished session
/// two commits where the design promises one. The board's own close flush drives the same two
/// steps in the same order through `CloseFlushCoordinator`, one window at a time.
private func finish() {
guard case let .open(store) = phase else { return }
phase = .closing
appModel.unregisterCardWindow(ref)
Task { @MainActor in
await session.endSession()
appModel.unregisterCardWindow(ref)
appModel.storeRegistry.release(store)
}
}
+77 -12
View File
@@ -35,9 +35,35 @@ import Foundation
/// window still commits with its strays named.
enum CommitMessageEngine {
/// **06's own mixed-window fallback**: "genuinely mixed windows fall back to 'Update board'
/// always with a bulleted body naming every event".
static let mixedSubject = "Update board"
/// **A genuinely mixed window says so** (06 Commit messages, re-ruled 2026-07-31 "retiring
/// the bare 'Update board' fallback"):
///
/// > **"Mixed update N changes"**, always with a bulleted body naming every event, so the
/// > oneline log stays scannable and never dresses a grab-bag as one thing; when every event in
/// > the window shares one item the card-window session flush's usual shape the subject keeps
/// > the name: **"Mixed update N changes to card 'title'"**.
///
/// The named case is the reason this exists: a card window's close flush is *by construction* a
/// window of one card's changes, and "Update board" was the one subject that could not say which
/// card a whole session belonged to.
///
/// - Parameters:
/// - count: how many events the body will list the subject counts changes, not commits.
/// - item: the rendered noun phrase every event shares ("card 'Fix login'"), or `nil` when they
/// genuinely span the board.
static func mixedSubject(_ count: Int, item: String? = nil) -> String {
let changes = count == 1 ? "1 change" : "\(count) changes"
guard let item else { return "Mixed update — \(changes)" }
return "Mixed update — \(changes) to \(item)"
}
/// The subject a window with no describable event at all falls to.
///
/// Vanishingly rare and deliberately kept: the tree's commit condition is the *tree*, not the
/// snapshot diff, so a window whose every path composed nothing still commits rather than leaving
/// the tree dirty (06 Commit messages Non-snapshot files commit too). "0 changes" would be a
/// lie about a commit that does contain something; this says what it honestly is.
static let unnamedSubject = "Update board"
/// **An untitled item reads "(untitled)" never a bare `""`** (06 Commit messages: "a
/// pathfinder edge fixed, not carried").
@@ -65,7 +91,7 @@ enum CommitMessageEngine {
// commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is
// no last-committed snapshot to diff against."
guard !request.isRootCommit else { return GitRepository.initialCommitSubject }
return assemble(events(for: request))
return assemble(events(for: request), request: request)
}
/// Every event this commit is composed of model events in board reading order, then the
@@ -91,14 +117,16 @@ enum CommitMessageEngine {
/// - One event: its own subject, plus its detail line where it has one.
/// - Several: a subject chosen from the **headline** events, and a bullet naming *every* event
/// "so the oneline log stays scannable and the full message stays complete".
private static func assemble(_ events: [Event]) -> String {
guard !events.isEmpty else { return mixedSubject }
private static func assemble(_ events: [Event], request: CommitMessageRequest) -> String {
guard !events.isEmpty else { return unnamedSubject }
if events.count == 1 {
guard let detail = events[0].detail else { return events[0].subject }
return "\(events[0].subject)\n\n\(detail)"
}
let body = events.map { "- \($0.bullet)" }.joined(separator: "\n")
return "\(subject(for: headline(of: events)))\n\n\(body)"
let subject = subject(for: headline(of: events))
?? mixedSubject(events.count, item: sharedItem(of: events, request: request))
return "\(subject)\n\n\(body)"
}
/// The events allowed to *choose* the subject, in the order 06 ranks them.
@@ -116,15 +144,52 @@ enum CommitMessageEngine {
}
/// "A single event is the subject; several events of one kind fold into a plural subject, with
/// shared destinations preserved; genuinely mixed windows fall back to 'Update board'."
private static func subject(for headline: [Event]) -> String {
guard let first = headline.first else { return mixedSubject }
/// shared destinations preserved" or `nil`, which is the genuinely mixed window the caller names
/// with `mixedSubject(_:item:)`.
private static func subject(for headline: [Event]) -> String? {
guard let first = headline.first else { return nil }
if headline.count == 1 { return first.subject }
guard Set(headline.map(\.kind)).count == 1 else { return mixedSubject }
guard Set(headline.map(\.kind)).count == 1 else { return nil }
let destinations = Set(headline.compactMap(\.destination))
return first.kind.plural(headline.count, destination: destinations.count == 1 ? destinations.first : nil)
}
/// **The one item every event in this window belongs to, rendered** "card 'Fix login'" or
/// `nil` when they span more than one.
///
/// Read from the events' own **paths** rather than from a field each event would have to remember
/// to carry: an event's paths are the only thing in this engine that is always true about where it
/// came from, and a card's folder is the one component that survives a lane move. A window with a
/// path that belongs to no card at all the board's own `index.md`, a lane, a stray, the agent
/// guide is by definition not one card's window, so a single `nil` answer decides the whole
/// question.
private static func sharedItem(of events: [Event], request: CommitMessageRequest) -> String? {
var folder: String?
for event in events {
for path in event.paths {
guard let card = cardFolder(of: path) else { return nil }
if let folder, folder != card { return nil }
folder = card
}
}
guard let folder else { return nil }
let title = cardTitlesByPath(request)[folder] ?? untitledPlaceholder
return "card \(quotedSubject(title))"
}
/// The `<lane>/<card>` (or `.trash/<card>`) folder a board-root-relative path belongs to, or `nil`
/// when it belongs to no card the board's `index.md`, a lane's, a root stray.
///
/// Comment paths answer through `CommentPath`, which already knows the thread's two containers, so
/// the one rule about where a card lives is not spelled twice.
private static func cardFolder(of path: String) -> String? {
if let comment = CommentPath.classify(path) { return comment.cardPath }
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard components.count >= 2, BoardLoader.isUUIDShaped(components[1]) else { return nil }
guard components[0] == Paths.trashFolder || BoardLoader.isUUIDShaped(components[0]) else { return nil }
return "\(components[0])/\(components[1])"
}
// MARK: - The structural diff
private static func modelEvents(
@@ -983,7 +1048,7 @@ enum CommitMessageEngine {
case .relabelBoard: return "Relabel board"
case .assignBoard: return "Assign board"
case .dueBoard: return "Set due date on board"
case .updateBoard: return CommitMessageEngine.mixedSubject
case .updateBoard: return CommitMessageEngine.unnamedSubject
case .agentGuide: return "Update agent guide"
case .updatePath: return "Update \(count) files"
+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)
}
}
+218
View File
@@ -0,0 +1,218 @@
import Foundation
// MARK: - CardWindowUndo
/// **One card window's undo session** the second of 13-native-undo.md's two levels (re-ruled
/// 2026-07-31, the session-coarsening model).
///
/// > "the **board stack** is owned by the board session and shared by board surfaces; a **card window
/// > owns its own stack** for the session it represents every gesture issued in that window
/// > (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where
/// > undoable) registers there at fine grain, and `window.undoManager` answers with it (standard
/// > per-window AppKit scoping)."
///
/// ### Two jobs, and the second is why this is a type rather than a stored provider
///
/// **The fine stack**: an ordinary `NativeHistoryProvider`, in *both* tiers. The steps a card window
/// registers are values-based inverses at the Writer boundary the same shape whatever substrate the
/// board's own history has so a Pro git board's card window still walks its own gestures with the
/// native grammar, and only the *coarse* close unit is tier-split ("one native board step, or one
/// commit" 06-history-undo.md Undo routing).
///
/// **The fold**: window close registers "one coarse step ... whose undo restores the card subtree to
/// its session-start state ... and whose redo reapplies the net effect". That net effect is exactly
/// the steps still on this stack, composed see `netEffect()`.
///
/// ### Why the session-start capture is distributed rather than a subtree snapshot
///
/// A single snapshot of the card folder taken at window open, diffed against disk at close, was the
/// obvious shape and is the wrong one: it would sweep in **every** change to the card during the
/// window's life, an agent's included, and 13 Rules is explicit that "foreign writes never join the
/// stack". A disk diff cannot tell the user's gesture from somebody else's write; a stack of the
/// user's gestures never has to.
///
/// So the capture lives where it already lived each fine step carries the value its write
/// overwrote (`CardBodyEditSession`'s session-origin bytes, `CommentEditSession.sessionStart`, the
/// prior style fields) and the coarse step is their composition. That also makes the coarse step's
/// *after*-values the values the app itself wrote, so a foreign edit landing between a gesture and
/// the close makes the step stale (13's field-level predicate) instead of being quietly reverted.
@MainActor
public final class CardWindowUndo {
// MARK: The fine stack
/// This window's own steps. `NativeHistoryProvider` verbatim: the grammar a window needs one
/// register is one step, a stale step is skipped and falls through, a failed one stays is the
/// grammar that type already implements and this milestone had no reason to fork.
public let stack = NativeHistoryProvider()
/// Whether the board is refusing writes, wired by the host once the window has joined its board
/// (`BoardUndoManager.isReadOnly`'s closure, one level down): the lock disables Undo and Redo in
/// a card window exactly as it does on the board (13 Rules locks).
public var isReadOnly: @MainActor () -> Bool = { false }
/// **What this window hands back from `windowWillReturnUndoManager`** the AppKit face over the
/// stack above, so the Edit menu's rows light up, disable and retitle from *this window's*
/// gestures.
///
/// There is deliberately **no fall-through**: this manager never consults the board's stack, so
/// "exhausting the window stack beeps; it never reaches board history" (06 Undo routing) is a
/// property of what the window answers with rather than a rule someone has to remember to apply.
public private(set) lazy var manager: BoardUndoManager = BoardUndoManager(
history: stack,
isReadOnly: { [weak self] in self?.isReadOnly() ?? false }
)
// MARK: The writes behind the steps
/// One gesture's write, in the raw terms a fold needs: the two halves and what each of them
/// leaves behind.
///
/// It is the *unwrapped* pair `BoardStore.registerStep`'s arguments before that method wraps
/// them in validation and a `performWrite` bracket. The coarse step has to compose the writes
/// themselves, because a composition of wrapped steps would validate and bracket each component
/// separately, which is precisely the partial session revert 13 forbids ("any stale component
/// skips the whole step never a partial session revert").
struct Write {
let undoExpects: [HistoryExpectation]
let redoExpects: [HistoryExpectation]
let undo: @MainActor (BoardStore) throws -> Void
let redo: @MainActor (BoardStore) throws -> Void
}
/// The raw writes, keyed by the step they belong to. Keyed rather than appended so that a gesture
/// undone inside the window drops out of the fold for free: membership is `stack.pendingSteps`',
/// and this is only the lookup.
private var writes: [UUID: Write] = [:]
public init() {}
/// Records the raw write behind a step this window is about to register. Called by
/// `BoardStore.registerStep`, which is the one place both halves are in hand.
func record(_ id: UUID, _ write: Write) {
writes[id] = write
}
// MARK: - The fold
/// **The session's net effect, or `nil` when there is none** what the window's close registers
/// on the board stack as one coarse step, and what "a session with no net change registers
/// nothing" means in code.
///
/// ### The composition
///
/// - **undo** every live step's undo, newest first. Replaying the session backwards lands on the
/// state it started from, deleted comments included: their backing is still in
/// `comments/.trash/` because this step's own existence is what defers the purge.
/// - **redo** every live step's redo, oldest first. The session, replayed.
/// - **the undo's expectations** the state the session's writes left, folded **last-write-wins**
/// per field: what must still be true for the whole step to be safe to cross.
/// - **the redo's expectations** the state the coarse undo leaves, folded **first-write-wins**:
/// the mirror, for the same reason.
///
/// One `HistoryStep` carrying every component's expectations is what makes validation
/// transactional without a word of new machinery: `BoardStore.cross` already checks the whole list
/// before writing anything, so any stale component skips the whole step with the ordinary
/// info-tone banner.
///
/// ### "No net change" is an equality, not a step count
///
/// A body typed away and typed back across two Edit sessions is two steps whose folded before and
/// after say the same thing, and 13 says that session registers nothing. Comparing the two folds
/// is the whole test and it is exact, because both sides are the values the app itself wrote.
func netEffect() -> Write? {
// Every step on this stack was recorded here as it was registered, and nothing is ever
// removed a step undone inside the window may still be redone, so its write has to survive
// being off the undo stack. The table is therefore complete by construction and dies with the
// window; pruning it against one stack would silently empty a redone step's half of the fold.
let live = stack.pendingSteps.compactMap { writes[$0.id] }
guard !live.isEmpty else { return nil }
let after = Self.fold(live.map(\.undoExpects))
let before = Self.fold(live.reversed().map(\.redoExpects))
guard after != before else { return nil }
return Write(
undoExpects: after.expectations,
redoExpects: before.expectations,
undo: { store in
for write in live.reversed() { try write.undo(store) }
},
redo: { store in
for write in live { try write.redo(store) }
}
)
}
// MARK: Folding
/// The merged expectations of a sequence of writes, **later entries winning** so folding a list
/// in registration order yields the state the last write left, and folding it reversed yields the
/// state the first write found.
///
/// Merging is per target *and* per field: two style gestures that set different dimensions of one
/// card fold to one target carrying both, while two that set the same dimension fold to one value.
/// Presence is whole-target and takes the later answer, which is what makes a post-then-delete of
/// one comment fold to "in the trash" rather than to two contradictory claims.
static func fold(_ lists: [[HistoryExpectation]]) -> Fold {
var fold = Fold()
for list in lists {
for expectation in list { fold.merge(expectation) }
}
return fold
}
/// A set of expectations being merged, in first-seen target order.
struct Fold: Equatable {
private struct Target: Equatable {
var presence: HistoryExpectation.Presence
var fields: [ExpectedField.Kind: ExpectedField]
}
private var targets: [URL: Target] = [:]
/// First-seen order, so the folded list a step carries is stable rather than hash-ordered
/// a skip banner and a test both read better when the card comes before its comments.
///
/// **Deliberately outside equality** (see `==`): the two folds a net-effect test compares are
/// built by walking the same writes in opposite directions, so their orders differ by
/// construction while the question being asked did anything actually change is about the
/// values alone.
private var order: [URL] = []
static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets }
/// **A later `.absent` clears what earlier writes said about the path**, which is how a move
/// inside one session folds correctly: a comment edited and then deleted leaves nothing at its
/// live path, and carrying the edit's body expectation there would make the session's own step
/// stale the moment it was registered. Every move-shaped step declares both of its paths
/// precisely so this is expressible (`BoardStore.deleteComment`, `postComment`).
mutating func merge(_ expectation: HistoryExpectation) {
let key = expectation.folder.standardizedFileURL
if targets[key] == nil {
order.append(key)
targets[key] = Target(presence: expectation.presence, fields: [:])
}
if expectation.presence == .absent {
targets[key] = Target(presence: .absent, fields: [:])
return
}
targets[key]?.presence = expectation.presence
for field in expectation.fields {
targets[key]?.fields[field.kind] = field
}
}
/// The fold, back in the currency `HistoryStep` speaks. Fields are ordered by the kind's own
/// declaration order so two equal folds always render identically.
var expectations: [HistoryExpectation] {
order.compactMap { key in
guard let target = targets[key] else { return nil }
let fields = Self.fieldOrder.compactMap { target.fields[$0] }
return HistoryExpectation(folder: key, presence: target.presence, fields: fields)
}
}
private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body]
}
}
+10 -9
View File
@@ -113,16 +113,17 @@ public final class CardBodyEditSession {
/// **The session boundary, announced** called with `true` when an Edit session opens and
/// `false` when it ends, and with nothing in between.
///
/// `CardWindowHost` points it at the board's auto-committer, which registers the card's folder to
/// stage around while the session stands and **nudges** when it ends (06-history-undo.md Rules
/// Auto-commit: the EditPreview flip is "the effective Save button", and raw-source entry and
/// window close end the session too). That nudge is what turns a session's several debounced
/// saves into exactly one commit: they commit nothing while the folder is excluded, and the
/// whole diff becomes committable at once when it is not.
/// **No longer the committer's stage-around boundary** (06-history-undo.md Rules Auto-commit,
/// widened 2026-07-31 "Board history sees card-window sessions, not gestures"): the exclusion
/// used to open and close with this flip, and it now opens with the *window* and releases when its
/// session ends, so a comment posted with the body in Preview is inside the same one commit as the
/// body. `CardWindowHost` therefore no longer wires this to anything, and the EditPreview flip is
/// a save point rather than a commit point.
///
/// A closure for `save`'s reason exactly this type is a buffer and a clock, and it stays
/// testable by having no idea what a repository is. `nil` (the free tier, a storeless test) means
/// nothing is listening, which is the same shape every other seam here takes.
/// The seam stays, unwired, because it is the only announcement of the boundary this type makes
/// and the ordering it carries that the flush precedes the announcement is a property worth
/// keeping proved (`AutoCommitTests`). A closure for `save`'s reason exactly: this type is a
/// buffer and a clock, and it stays testable by having no idea what a repository is.
@ObservationIgnored
public var editSessionDidChange: ((_ isEditing: Bool) -> Void)?