The seams unbind — the provider is always native, and the git stack compiles dead

Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 11:25:41 -04:00
parent f6a24132b6
commit cdba512512
24 changed files with 281 additions and 2567 deletions
+90 -585
View File
@@ -344,11 +344,12 @@ public final class AppModel {
/// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller.
///
/// **Dormant since PIVOT 2026-08-07** (12-editions.md git left the paywall): the answer is
/// still read and still recorded on the session (`BoardSession.tier`), and **nothing git-shaped
/// consults it any more** `HistoryStore.compose` and `makeHistoryProvider` both lost the axis.
/// The seam is kept unchanged, mechanics and all, because the entitlement is correct for
/// whatever the next base/Pro split turns out to gate; what it does not do is decide undo.
/// **Dormant since PIVOT 2026-08-07** (12-editions.md git left the paywall, and the 2026-08-08
/// ruling then excised git entirely): the answer is still read and still recorded on the session
/// (`BoardSession.tier`), and **nothing consults it any more** `makeHistoryProvider` lost the
/// axis, and the stack it used to gate has gone. The seam is kept unchanged, mechanics and all,
/// because the entitlement is correct for whatever the next base/Pro split turns out to gate;
/// what it does not do is decide undo.
@ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free }
@@ -357,68 +358,39 @@ public final class AppModel {
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a
/// board session's undo stack is built by, called once per board as its session begins.
///
/// **The provider follows the board and, since PIVOT 2026-08-07, the board alone**
/// (re-ruled 2026-07-31 12 The provider seam; 13-native-undo.md's header; 06 Rules; then
/// 12 PIVOT 2026-08-07, git leaves the paywall). A board's substrate is decided by what the
/// board *is*. The 2026-07-31 re-ruling took the tier out of every row but one it still decided
/// whether git was on the table at all and the pivot took it out of that one too: "every tier
/// composes the git stack on git-mode boards exactly as Pro did". The rule the re-ruling replaced
/// bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, which made
/// subscribing *remove* undo from a mode-none board an upgrade that takes a feature away.
/// **Every board gets the native stack, and the seam has one answer**
/// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 the app-managed
/// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board
/// decides this any more: not its tier (12 PIVOT 2026-08-07 took the last row the tier decided),
/// not whether it sits inside somebody's repository, not what is on disk beside it. A board
/// nobody has done anything special to and a board living in a user's git repo bind the same
/// stack, which was already true before this ruling and is now true by construction.
///
/// It takes the store because that is what a provider is a history *of*: the git provider needs
/// the board root it is a repository at, and the native one's steps are computed from the same
/// store's snapshots. A property rather than an initializer argument so a test
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
/// ### Why it is still a seam
///
/// ### The two answers, and the two `nil`s that are no longer among them
/// Deliberately kept, and the excision plan says so in as many words ( Reversibility: "the seams
/// the git stack plugged into are all nil-safe/default-native and are **kept**, so a future
/// provider journal, ops service, or even git again re-binds without re-plumbing"). Two things
/// it buys today: a test binds a fake substrate or a substrate-less board without a second
/// `AppModel` initializer, and the day a second provider exists it arrives as a different default
/// here rather than as a branch threaded through the session.
///
/// - **Mode `git`** the git provider: undo as forward restore commits over HEAD's first-parent
/// ancestry (06). **In every tier** since the pivot; this row used to read "Pro only no other
/// tier composes a git state", because `HistoryStore.compose` answered `nil` off Pro without so
/// much as a `stat`.
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** the **native stack**. "Boards
/// without app-managed git repo-nested included bind 13-native-undo.md's native stack in
/// **every** tier" (03-board-ui.md Toolbar Catalog, re-ruled 2026-07-31 twice; 12 The
/// provider seam; 13's header). `unverifiable` joins the same branch structurally a denial
/// can never be told apart from a repository actually being there, so it takes `repoNested`'s
/// posture, undo included (06 Rules Detection). This is where a board nobody opted git in
/// for lands, permanently: opt-in per board is exactly what the pivot left standing (13's
/// header, read through 12 PIVOT 2026-08-07).
/// - **No `HistoryStore` at all** the argument stays optional for the seam's own reason (a test
/// binds a git-stateless board through it), and no session composes one: `compose` returns a
/// store for every board there is. It answers the native stack, which is what it always
/// answered for the free tier that used to arrive this way.
/// It takes the store because that is what a provider is a history *of*: the native stack's steps
/// are computed from that store's snapshots, and a provider that needed the board root would find
/// it there too. `@ObservationIgnored` because nothing renders from it.
///
/// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone
/// stance "concerns *git*, and this stack never touches git memory-only, journal-free,
/// session-scoped so what repo-nested denies is app-managed history, never Z" (13's header).
/// It also made the Pro upgrade story exceptional, which was the other half of the same defect:
/// the free tier could not tell such a board from a plain one and bound the native stack anyway,
/// so subscribing *removed* undo from exactly the boards it left alone. Edit Undo/Redo and the
/// toolbar pair now disable only under a lock and on an empty stack.
///
/// This closure therefore never answers `nil`, and the seam stays optional for the seam's own
/// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`).
///
/// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why
/// `beginSession` composes the git state *before* the provider: which substrate a board gets is a
/// question about its repository, and a root that had to ask the disk itself would be a second
/// detection.
/// **The production closure never answers `nil`**, and the optionality is the seam's rather than a
/// board's: `nil` means "no undo at all", which no board the app composes is
/// (`BoardUndoManager.history` answers the empty way over an absent substrate, which is what makes
/// a test able to bind one).
///
/// ### Consumers
///
/// `beginSession` calls it once per board, and `bindHistoryProvider(for:)` calls it again on the
/// one event that changes a board's answer under an open session add-git's commanded flip,
/// which swaps `none`'s native stack for `git`'s trail.
/// `beginSession`, once per board. Nothing re-binds a live session's substrate: the one event that
/// used to add-git's commanded mid-session mode flip went with the git stack.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore, HistoryStore?) -> (any HistoryProviding)? = { store, git in
guard let git else { return NativeHistoryProvider() }
switch git.mode {
case .git: return GitHistoryProvider(boardRoot: store.rootURL)
case .none, .repoNested, .unverifiable: return NativeHistoryProvider()
}
public var makeHistoryProvider: (BoardStore) -> (any HistoryProviding)? = { _ in
NativeHistoryProvider()
}
// MARK: Sessions
@@ -440,23 +412,21 @@ public final class AppModel {
/// window). It lives here for the store's reason exactly: the session is what every window
/// over this board shares, and "undo is board-local".
///
/// Which implementation it is, is the board's answer and nobody else's
/// (12-editions.md The provider seam, read through PIVOT 2026-08-07) see
/// `AppModel.makeHistoryProvider`.
/// Which implementation it is comes from one place see `AppModel.makeHistoryProvider`,
/// which since the 2026-08-08 git excision has exactly one answer for every board.
///
/// **`nil` is a board with no undo at all, and no board the app composes is one any more**
/// (re-ruled 2026-07-31 see `AppModel.makeHistoryProvider`): boards without app-managed git,
/// repo-nested included, bind the native stack in every tier, and git boards bind the git
/// provider. What keeps the optionality is the seam rather than a board: a test binds a
/// substrate-less session through `makeHistoryProvider`, and a store with no session at all
/// registers nothing (`BoardStore.registerStep`). The command surface disables through
/// `undoManager`, which answers the empty way over an absent substrate.
/// **`nil` is a board with no undo at all, and no board the app composes is one**. What keeps
/// the optionality is the seam rather than a board: a test binds a substrate-less session
/// through `makeHistoryProvider`, and a store with no session at all registers nothing
/// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which
/// answers the empty way over an absent substrate.
///
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
/// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips
/// native out, git in, the in-session steps discarded with it
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it `tier` has no setter,
/// and since PIVOT 2026-08-07 it has no say in this either.
/// A `var` rather than a `let` beside `tier`, and now for no event at all: the one sanctioned
/// mid-session substrate swap was add-git's commanded mode flip, which went with the git
/// stack. It stays a `var` because a second provider is a live possibility
/// (`strategy/01-git-excision.md` Reversibility) and because nothing is bought by tightening
/// it; a tier lapse could never touch it `tier` has no setter, and since PIVOT 2026-08-07 it
/// has no say in this either.
public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement) recorded,
@@ -467,46 +437,14 @@ public final class AppModel {
/// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
/// mid-session is a fact about the *next* open and about nothing already on screen.
///
/// **Nothing git-shaped reads it.** Git left the paywall (12 PIVOT 2026-08-07), so the
/// substrate, the committer, the branch controls and the History surfaces are all decided by
/// the board's mode alone. It stays recorded because the entitlement's machinery stays built
/// and correct for whatever the re-decided base/Pro split gates, and because the fact a board
/// opened under is a composition-time answer the way the provider binding is: whatever asks
/// next must get what this board opened with, never what the entitlement says at render time.
/// **Nothing reads it.** Git left the paywall (12 PIVOT 2026-08-07) and then left the app
/// (`strategy/01-git-excision.md`, ruled 2026-08-08), so no surface anywhere is decided by
/// this. It stays recorded because the entitlement's machinery stays built and correct for
/// whatever the re-decided base/Pro split gates, and because the fact a board opened under is
/// a composition-time answer the way the provider binding is: whatever asks next must get what
/// this board opened with, never what the entitlement says at render time.
public let tier: Tier
/// **This board's git state** (06-history-undo.md Rules; 02-architecture.md Components
/// HistoryStore) the detected mode, the repository behind it in git mode, and the
/// add-git action the popover offers on a board that has none.
///
/// **Never `nil` on a session the app composes**, since PIVOT 2026-08-07 (12-editions.md):
/// `HistoryStore.compose` runs detection for every board in every tier and returns a store.
/// It used to answer `nil` under the free tier the inert-`.git` posture made structural
/// rather than remembered and that posture is retired. The optionality survives for the
/// seam's sake alone: a test binds a git-stateless session through `makeHistoryProvider`.
///
/// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is
/// settled at composition and cannot change under an open session. What can change is the
/// mode *inside* it, by add-git alone the one commanded mid-session flip 06 allows.
public let git: HistoryStore?
/// The mode this board is being edited in, `none` when there is no git state at all which
/// no session the app composes is any more (see `git` above), and which a board nobody has
/// added git to detects anyway.
///
/// **Its first consumer is the provider seam** `makeHistoryProvider` reads exactly this to
/// know whether the board has a repository to be an undo stack for, and it is the *mode* and
/// nothing else that decides (re-ruled 2026-07-31; the tier's last say went with PIVOT
/// 2026-08-07): `git` binds the git provider, `none` and `repoNested` alike the native stack.
/// The popover's git section is the other reader and the one place the two gitless modes
/// still differ, since add-git is offered on one and explained away on the other.
///
/// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing
/// type's isolation, and everything that asks a session what mode it is in is main-actor
/// work anyway (a menu, a popover, a provider being composed).
@MainActor
public var gitMode: BoardGitMode { git?.mode ?? .none }
/// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's
/// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows
/// and the toolbar's pair resolve to *this* board through the ordinary responder chain.
@@ -903,115 +841,15 @@ public final class AppModel {
// gains no network dependency." Synchronous, over facts already in memory. It is the *only*
// time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
//
// **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the
// paywall). This line used to sit on the same line as the git state it gated; the gate is
// gone, and what is left is a dormant fact kept for the base/Pro split still to be ruled.
// **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the paywall;
// `strategy/01-git-excision.md`, ruled 2026-08-08 git left the app). This line used to sit
// on the same line as the git state it gated; both the gate and the state are gone, and what
// is left is a dormant fact kept for the base/Pro split still to be ruled.
let tier = currentTier()
// **Mode detection** (06-history-undo.md Rules Detection: "checked at every board
// open") unconditionally, in every tier. One `stat` per open, freshly, so a board that
// gained or lost a `.git` since its last open opens in the mode it now has. Until the
// 2026-08-07 pivot this call took the tier and answered `nil` under `.free`, which bought
// the free tier a *zero*-stat open (the inert-`.git` posture); that promise is retired with
// the posture, and every open now pays what Pro's opens always paid.
//
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
//
// **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.
//
// **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, 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 a git-mode board
// it is instead the repository's own trail, which survives everything (06 Rules Undo
// survives relaunch) the seam's whole point.
let history = makeHistoryProvider(store, git)
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md Fractal
// layout Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
// load wants its own see `BoardStore.makeIdentityHistoryRanker` and because add-git
// flips the mode mid-session, which this closure picks up for free by asking the git state
// at the moment of each load rather than at composition.
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 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 }
// **The flush awaits the snapshot that covers it** (06 Rules Auto-commit, ruled
// 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush
// awaits a snapshot generation covering its changed paths before the committer runs
// the commit's subject can never be outrun by its own reload". Both halves of that
// await are reads of the store the composer is already diffing, which is why they are
// wired here rather than reached for: the engine holds the *policy* (when to wait, how
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
//
// The generation the gate counts is `landedReloads` completed *walks* rather than
// applied snapshots because a value-equal reload skips the assignment and its
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found
// anything to change (`BoardStore.landedReloads`).
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.landedReloads = { [weak store] in store?.landedReloads }
// 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()
}
// **The corrupt-`.git` loud failure's standing row** (06-history-undo.md Rules,
// ruled 2026-07-31): a repository the app cannot open pauses the whole git surface
// and says so on the strip, "announced per 10-accessibility.md" and the same seam
// heals it, since the paused engine's own 15 s re-read is what notices a repository
// repaired in a terminal. Distinct from the suspension above: that row is history
// failing to advance and retrying, this one is there being nothing to advance into.
committer.reportRepositoryUnreadable = { [weak store] unreadable in
store?.noteRepositoryUnreadable(unreadable)
}
store.commitSeam = .binding(to: committer)
// **The undo stack's ear on the committer** every commit this engine lands, and
// which of it was heal work (06 Rules The stack is HEAD's first-parent ancestry,
// live; Heal commits are transparent to undo). Bound here rather than in
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
// `activateAutoCommit` remembers on its behalf.
committer.reportLanded = { [weak self, ref] window in
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
provider.noteLanded(window)
}
}
// **Add-git swaps the undo substrate too** (06 Rules Detection the one commanded
// mid-session mode flip; 13-native-undo.md's header "discards the in-session native
// stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`.
git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref)
}
// **The form-anchored posture's fallback half** (06 Interaction with external writers,
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead
// when that form has been dismissed before the answer arrived "inline is the primary
// surface, never a silence trap". The banner enumeration is the same one branch switch and
// undo restore post into, one row per failure.
git.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.addGit, reason: failure.message)
}
// **The detection-time answer, published once** (06 Rules: "a standing breakage-class
// banner **at detection**"). The probe ran inside `compose` above before this session
// existed, and therefore before the seam that carries its transitions was wired so a
// board that opened into an unreadable repository raises its row here rather than
// waiting for the first debounce to rediscover what composition already knows.
store.noteRepositoryUnreadable(git.isRepositoryUnreadable)
// the board session and dies at close/quit ... standard macOS behavior".
let history = makeHistoryProvider(store)
// **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
// methods so it is the store that computes each inverse and registers it. What it cannot
@@ -1024,7 +862,6 @@ public final class AppModel {
recordID: recordID,
history: history,
tier: tier,
git: git,
// The lock's enablement half (13-native-undo.md Rules): Undo and Redo disable with the
// other mutating commands while the board refuses writes, and the stack survives to
// resume when it clears. Weak, so the adapter is never the reason a closed board's store
@@ -1036,183 +873,19 @@ public final class AppModel {
cardRefs: [],
access: access
)
wireGitUndo(history, store: store, git: git, ref: ref)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
// MARK: - The git provider's wiring
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of and does nothing
/// at all for any other substrate.
///
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason the provider
/// stays a thing that knows about commits, and the model stays the only object that knows what a
/// window is.
private func wireGitUndo(
_ history: (any HistoryProviding)?,
store: BoardStore,
git: HistoryStore?,
ref: BoardWindowRef
) {
guard let provider = history as? GitHistoryProvider, let git else { return }
provider.flushPendingCommit = { [weak git] in
await git?.committer?.flushNow()
}
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
// 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")
// now literally that, at the failure rank in the error tone (02 The banner surface, settled
// 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise
// this line used to carry is retired: a Z that didn't happen is an action that didn't
// happen, not content that didn't arrive.
//
// The closure passes the *direction* and libgit2's own message and stops there "Undo
// failed " is BannerCenter's sentence, from the closed `GitOperation` vocabulary.
provider.reportFailure = { [weak store] direction, failure in
store?.banners.postGitFailure(.restore(direction), reason: failure.message)
}
provider.runBracketed = { [weak store] subject, work in
guard let store else { return await work() }
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
// own subject the sentence the trail now carries, spoken once when the reload lands.
try? await store.performWholesale(announcing: subject) { await work() }
}
provider.settleSessions = { [weak self, weak provider] paths in
guard let self, let provider else { return .proceed }
let gate = self.settleGate(for: ref) { [weak provider] folder in
// The card's uncommitted on-disk saves are reverted by the restore itself, which
// compares this folder against the working tree rather than against HEAD see
// `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderName: folder)
}
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()
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
}
/// **The branch controls' seams** (06-history-undo.md Branch switching) the five things the
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
/// interrupted switch recognizable as this app's.
///
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
/// same board's git session seen from two sides and because both must be re-wired on exactly the
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
private func wireBranchSwitching(
git: HistoryStore,
store: BoardStore,
provider: GitHistoryProvider,
ref: BoardWindowRef
) {
guard let switcher = git.switcher else { return }
let recordID = sessions[ref]?.recordID
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() }
// 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".
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
switcher.runBracketed = { [weak store] announcement, work in
guard let store else { return await work() }
try? await store.performWholesale(announcing: announcement) { await work() }
}
switcher.beginProgress = { [weak store] label in
store?.banners.beginOperation(label: label) ?? UUID()
}
switcher.updateProgress = { [weak store] id, label in
store?.banners.updateOperation(id, label: label)
}
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
// The failure rank's git shape, as on the restore path above: a switch that didn't happen is
// an action that didn't happen ("Couldn't switch branches ", BannerCenter's words from
// the operation alone).
switcher.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.branchSwitch, reason: failure.message)
}
// **The recovery notice stays a loss row**, and the ruling is explicit about why (02 The
// banner surface): "recovery notices report a success, not a failure, and stay warning-tone".
// "A branch switch was interrupted the previous state is restored" is the app tidying up
// after itself, with nothing for the user to do the loss class's own register.
switcher.reportRecovery = { [weak store] message in
store?.banners.postLoss(message)
}
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
// record a store-level test simply carries no stamp, and recovery then has nothing to
// recognize, which is the honest answer for a board the app has no state for.
switcher.readStamp = { [weak self] in
guard let self, let recordID else { return nil }
return self.boardRegistry.gitOperationStamp(id: recordID)
}
switcher.writeStamp = { [weak self] stamp in
guard let self, let recordID else { return }
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
}
switcher.settleSessions = { [weak self, weak switcher] in
guard let self, let switcher else { return .proceed }
let gate = self.settleGate(
for: ref,
message: SessionSettleStep.branchSwitchMessage
) { [weak switcher] folder in
switcher?.noteDiscarded(cardFolderName: folder)
}
// Every open session, not the ones a diff reaches see `SessionSettleGate.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.
//
// **And the fine undo stacks go with it** (06 Branch switching, ruled 2026-07-31): the
// same `.proceed`, the same seam, for the same reason one rung up what a window is
// holding describes the branch being left. "Cancel keeps the current branch and the
// sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly
// as it releases nothing.
if outcome == .proceed {
self.releaseCardSessionStaging(for: ref)
self.discardCardWindowUndoStacks(for: ref)
}
return outcome
}
// **The own-leftovers check, at open** (06 Rules Abnormal repo states). Beside the
// committer's start, which is where a pause first becomes knowable, and before anything the
// user does can land on top of a half-finished checkout.
Task { await switcher.recoverInterruptedOperation() }
}
/// **The three buttons, as a seam** `SessionSettleStep.ask(message:)` in production.
///
/// `SessionSettleGate` already keeps the presentation behind a closure for its own reason
/// ("presenting three buttons is AppKit's job and cannot be asserted without a display the
/// presentation is a seam and the decision is testable"), and every gate this model builds pointed
/// that closure straight at the alert so the *composition* around the gate, which is what
/// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only
/// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test
/// answer Save All, Discard and Cancel over real card windows without a modal on screen.
/// `discardCardWindowUndoStacks(for:)` hangs off, could only be exercised by a board with nothing
/// to settle. Lifting the ask one level up is what lets a test answer Save All, Discard and Cancel
/// over real card windows without a modal on screen.
///
/// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam
/// rather than API: production never assigns it.
@@ -1222,18 +895,20 @@ public final class AppModel {
}
/// **The save-or-discard step for one board**, built from its open card windows
/// (06-history-undo.md Rules Undo restore vs open Edit sessions; Branch switching).
/// (`SessionSettleGate`).
///
/// Built per ask rather than stored, because its whole content is "which card windows are open
/// right now" a set that changes under any operation slow enough to need the step at all.
///
/// **No production caller today** the git restore and branch switch were the two, and both went
/// with the git stack (`strategy/01-git-excision.md`). The composition is kept beside the gate it
/// composes, for the gate's own reason: it is what the next wholesale operation binds to.
///
/// - Parameters:
/// - message: what the step says it is about. The two callers describe different consequences
/// a restore changes the cards being edited, a switch replaces them and 06 gives the step to
/// both without giving either the other's wording.
/// - message: what the step says it is about. Different operations describe different
/// consequences, and a step that described the wrong one would be a worse modal than none.
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
/// into its plan; the switch reverts before it flushes).
/// gate can reconcile that folder's already-written bytes its own way.
func settleGate(
for ref: BoardWindowRef,
message: String = SessionSettleStep.message,
@@ -1272,62 +947,6 @@ public final class AppModel {
)
}
/// **Swaps the board's undo substrate onto an already-open session** add-git's one caller.
///
/// 06 Rules Detection sanctions exactly one mid-session mode flip, the app's own add-git:
/// "clicking it flips the open board into git mode immediately the popover flows straight into
/// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what
/// that does to undo: "**Add-git swaps the substrate mid-session** the commanded flip discards
/// the in-session native stack and seeds the git trail from the root commit, the branch-switch
/// discard-and-reseed precedent applied".
///
/// ### The discard is the whole of it there is no migration
///
/// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real
/// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are
/// in-memory inverse operations against a board that has just acquired a commit trail, and
/// replaying one after the swap would walk the board back across a change the root commit already
/// records as the baseline. The branch-switch precedent says the same thing about the same
/// question "the undo/redo stack does not survive a switch. It is discarded and reseeded from
/// the new HEAD's first-parent ancestry redo starts empty" (06 Branch switching) so the old
/// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`)
/// walks a trail whose only commit is the root, which is the stack's floor and not a step: Z is
/// correctly empty the instant the flip lands.
///
/// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git
/// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard
/// has none; what the user sees is the Edit rows and the toolbar pair revalidating through
/// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement
/// change on this board rides. Nothing here posts a banner, and nothing should.
///
/// ### Why live rather than at the next open
///
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at
/// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit`
/// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
/// the provider it composed" was a rule about a **tier** lapsing, which never could change a
/// running session and since PIVOT 2026-08-07 no longer changes the *next* open either, since
/// a git board composes the git provider in every tier; and a board that visibly starts
/// accumulating commits while Z answers from a
/// stack the repository knows nothing about would read as a defect rather than as a policy.
///
/// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
/// `none`, and flips to `.git` before it fires `didAddGit`.
func bindHistoryProvider(for ref: BoardWindowRef) {
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
guard let history = makeHistoryProvider(session.store, git) else { return }
guard history !== session.history else { return }
// Before the reassignment, while `session.history` is still the substrate being replaced: the
// in-flight native steps go with it, and any closure that outlives this line finds an empty
// stack rather than inverses against a pre-repository board.
session.history?.clear()
session.history = history
sessions[ref] = session
session.store.history = history
session.undoManager.history = history
wireGitUndo(history, store: session.store, git: git, ref: ref)
}
/// Registers a card window with its board's session, so the close flush can find it.
///
/// A card window whose board has no session is a card window with no board the ownership rule
@@ -1340,112 +959,32 @@ 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
// **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 session by. Beside `cardSessions` for its
/// reason: this is the seam table's third column, written only here.
@ObservationIgnored
private var cardSessionTokens: [CardWindowRef: UUID] = [:]
/// **A card window's session opened or closed** (06-history-undo.md Rules Auto-commit: the
/// committer "stages around the whole open card folder").
/// **A wholesale operation's settle empties every open card window's fine undo stack.**
///
/// 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 any board nobody has added git
/// to, which since PIVOT 2026-08-07 is the only such board there is (12-editions.md) records
/// nothing, which is the same `nil` every other git seam takes.
/// The rule the branch switch established, kept as a rule about *any* operation that replaces the
/// tree under an open window: pre-operation steps describe a state that has gone, so Save All and
/// Discard alike end with every window's stack empty the board-stack discard-and-reseed
/// precedent one level down; the windows stay open with fresh stacks. Cancel clears nothing, which
/// is why this hangs off a `.proceed` at the call site rather than off the ask.
///
/// 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.
/// **The board stack is not touched here**, and it is not an omission: an operation that replaces
/// the tree reseeds the board's stack itself, after the write that decides what to reseed *from*.
/// Doing it here would be the same discard at the wrong moment.
///
/// 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 }
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.
/// **A diff-shaped operation deliberately does not call this.** One that materializes only a diff
/// leaves a window's steps describing a state that is still mostly there; what protects them is
/// 13-native-undo.md's field-level staleness predicate, a per-step question rather than a
/// wholesale one.
///
/// ### 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 branch switch's settle empties every open card window's fine undo stack**
/// (06-history-undo.md Branch switching, ruled 2026-07-31).
///
/// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe
/// > the branch being left Save All and Discard alike end with every window's stack empty, the
/// > board-stack discard-and-reseed precedent one level down; the windows stay open, following
/// > their cards onto the new branch with fresh stacks."
///
/// ### Why here, beside the staging release
///
/// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of
/// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both
/// are true of a session whose branch is about to be replaced under it. Running them from one
/// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule
/// two call sites have to keep in step (`wireBranchSwitching`).
///
/// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds
/// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket
/// (`GitBranchSwitcher.reseedUndo` `GitHistoryProvider.reseed`). Doing it here would be the same
/// discard, one level up, at the wrong moment before the checkout that decides what to reseed
/// *from*.
///
/// **The restore path deliberately does not call this.** An undo restore materializes a diff and
/// leaves the branch where it is, so a window's steps still describe the branch they were made on;
/// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a
/// per-step question rather than a wholesale one.
/// **No production caller today** it went with the branch switch (`strategy/01-git-excision.md`)
/// and it is kept beside `settleGate(for:message:didDiscard:)` for that gate's reason.
///
/// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close
/// flush's* seam end the session, say whether it holds unsaved content, offer the settle's two
@@ -1457,18 +996,6 @@ public final class AppModel {
}
}
/// **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)
}
}
// MARK: - Launch failures
/// Records a board that could not be opened. Deliberately additive and never cleared on success:
@@ -1629,11 +1156,10 @@ public final class AppModel {
///
/// ### Declining costs nothing, today least of all
///
/// A board that says Not Now keeps the substrate it composed with, and since the provider follows
/// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either
/// way declining costs undo nothing at all, and costs a git board only the trail it would have
/// gained at its next open. The offer exists because "subscribe takes effect at each board's next
/// open" (12 The entitlement) needs one, not because anything breaks without it.
/// A board that says Not Now keeps the substrate it composed with, and since every board composes
/// the same one (`makeHistoryProvider`), that costs undo nothing at all. The offer exists because
/// "subscribe takes effect at each board's next open" (12 The entitlement) needs one, not
/// because anything breaks without it.
public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path }
@@ -1681,15 +1207,6 @@ 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)
@@ -1705,17 +1222,10 @@ public final class AppModel {
// window of its own the raw-source buffer is the candidate so that the order
// 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
// 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
// every board nobody has added git to (12 PIVOT 2026-08-07 the tier no longer
// decides; opt-in per board still does).
committerFlush: { [weak self] in
await self?.sessions[ref]?.git?.committer?.flushNow()
},
// **`committerFlush` stays nil too**, and now for want of a committer rather than by
// policy: the auto-committer it drove went with the git stack
// (`strategy/01-git-excision.md`). The slot is 02's ordering statement pending editor
// saves, then whatever records history and it keeps that place for the successor.
recordClose: { [weak self] in
guard let self, let session = sessions[ref] else { return }
let counts = Self.liveCounts(of: session.store.snapshot)
@@ -1734,11 +1244,6 @@ 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
+2 -2
View File
@@ -162,8 +162,8 @@ final class CardWindowSession: CardSessionFlushing {
},
discard: { [self] in
// The buffer goes back to what disk says; the *disk* goes back to the target state as
// part of the restore itself, which reconciles this card's folder against the working
// tree rather than against HEAD (`GitRestoreOperation.plan`).
// part of the wholesale operation itself, which is the only party that knows what it
// is reconciling this card's folder towards (`SessionSettleGate`).
body.discardBuffer()
rawSourceCancel?()
}
+54 -47
View File
@@ -3,9 +3,9 @@ import Foundation
// MARK: - Vocabulary
/// What the user chose at the save-or-discard step (06-history-undo.md Branch switching: "**Save
/// All** ends every session with its normal commit , **Discard** reverts buffers and uncommitted
/// saves to HEAD, **Cancel** keeps the current branch and the sessions").
/// What the user chose at the save-or-discard step: **Save All** ends every session with its normal
/// save, **Discard** reverts buffers and leaves the wholesale operation to reconcile what already
/// reached disk, **Cancel** keeps everything exactly as it is.
public enum SessionSettleChoice: Sendable, Equatable {
case saveAll
case discard
@@ -21,10 +21,9 @@ public enum SessionSettleOutcome: Sendable, Equatable {
/// The user chose Cancel. "Cancel keeps everything" nothing was written, nothing reverted.
case cancelled
/// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer
/// that fails validation cancels the whole switch with focus on the offending window, nothing
/// half-switched" (06 Branch switching). The payload is that window's session id, already
/// focused by the gate.
/// **Save All met a raw-source buffer that would not validate.** Since Apply validates, a buffer
/// that fails validation cancels the whole operation with focus on the offending window, nothing
/// half-done. The payload is that window's session id, already focused by the gate.
case failed(String)
}
@@ -79,7 +78,7 @@ public struct SettleableSession {
///
/// Matched component-wise against the paths a wholesale operation would write, which is what
/// makes the match survive a lane move: a card's own folder component never changes, only the
/// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason).
/// lane above it (`SessionSettleGate.path(_:isInsideFolderNamed:)`).
public let cardFolderName: String
/// Whether this session is holding state a wholesale tree operation would disturb: unsaved
@@ -91,10 +90,9 @@ public struct SettleableSession {
/// the raw buffer. `false` means the raw buffer failed validation the whole operation is off.
public let saveAll: @MainActor () -> Bool
/// **Discard** for this one session: revert the buffer and leave raw source without writing. The
/// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's
/// folder against the working tree rather than against HEAD for exactly that reason
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
/// **Discard** for this one session: revert the buffer and leave raw source without writing. What
/// already reached disk is reconciled by the wholesale operation behind the gate, which is the
/// only party that knows the state it is writing towards.
public let discard: @MainActor () -> Void
public init(
@@ -117,23 +115,22 @@ public struct SettleableSession {
/// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md Rules
/// Undo restore vs open Edit sessions; Branch switching).
///
/// ### One machinery, two callers, by design
/// ### One machinery, every caller, by design
///
/// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same
/// one by name: "When the diff *does* touch a session card, the restore **gates on the branch-switch
/// save-or-discard step** (Branch switching below Save All / Discard / Cancel, same machinery, same
/// rationale)." So this object is written for both from the start; the undo provider is its first
/// caller and the branch controls will be its second, passing the paths a checkout would write
/// instead of the paths a restore would.
/// The design never described a gate per operation: it described one save-or-discard step Save All
/// / Discard / Cancel and handed it to whatever wants to move the tree wholesale under an open
/// editor. So this object is written for all of them. **Nothing calls it today** (`strategy/
/// 01-git-excision.md` What is deleted: the git restore and branch switch were its two callers and
/// went with the stack); it is kept because it is the settled answer to a question the next wholesale
/// operation will ask on its first day.
///
/// ### The diff decides whether it appears at all
/// ### The paths decide whether it appears at all
///
/// "A restore materializes only the diff between the current tree and the target state, so a card
/// whose open Edit session the diff doesn't touch is simply unaffected its uncommitted ~700 ms
/// saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all."
/// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a
/// yes/no: a modal that appeared on every Z because *some* window somewhere was in Edit would be a
/// different, much worse feature.
/// An operation that materializes only a diff leaves a card whose open Edit session the diff doesn't
/// touch simply unaffected that card's uncommitted ~700 ms saves continue undisturbed. That is
/// `settle(touching:)`'s first line, and it is why the gate takes paths rather than a yes/no: a modal
/// that appeared on every gesture because *some* window somewhere was in Edit would be a different,
/// much worse feature. `settleAll()` is the other shape, for an operation that moves everything.
///
/// ### Why the ask is a closure
///
@@ -174,17 +171,16 @@ public struct SessionSettleGate {
await decide(over: Self.reached(by: paths, among: sessions()))
}
/// **Settles every open session, whatever the operation writes** the branch switch's gate
/// (06 Branch switching).
/// **Settles every open session, whatever the operation writes** the gate for an operation that
/// replaces the tree wholesale rather than materializing a diff.
///
/// The path filter above is the *restore's* narrowing and belongs to it alone: "a restore
/// materializes only the diff between the current tree and the target state, so a card whose open
/// Edit session the diff doesn't touch is simply unaffected". A branch switch has no such
/// property. It moves the whole tree at once, and the raw-source hazard 06 singles out "an
/// unsettled raw buffer its Apply later writes the *entire* pre-switch `index.md` byte-for-byte
/// onto the new branch's card" is about the buffer belonging to the old branch, not about
/// whether the checkout happened to rewrite that card. So this asks about every session that is
/// holding something, and about no path at all.
/// The path filter above is a diff-shaped operation's narrowing and belongs to it alone: a card
/// whose open Edit session the diff doesn't touch is simply unaffected. A wholesale replacement
/// has no such property, and the raw-source hazard is the reason it cannot borrow one an
/// unsettled raw buffer's Apply later writes the *entire* pre-operation `index.md` byte-for-byte
/// over whatever now stands there, which is about the buffer describing a state that has gone, not
/// about whether the operation happened to rewrite that card. So this asks about every session
/// that is holding something, and about no path at all.
public func settleAll() async -> SessionSettleOutcome {
await decide(over: sessions())
}
@@ -225,9 +221,21 @@ public struct SessionSettleGate {
) -> [SettleableSession] {
guard !paths.isEmpty else { return [] }
return sessions.filter { session in
paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) }
paths.contains { path($0, isInsideFolderNamed: session.cardFolderName) }
}
}
/// Whether `path` lies inside a folder named `name` component-exact, so a card whose id is a
/// prefix of another's cannot be dragged into a step it has no business in.
///
/// Moved here from the git history walk when the git stack was unbound (`strategy/
/// 01-git-excision.md` Sequencing Seam unbind); the walk was its only other caller.
///
/// `dropLast()` because the *containing* folders are the question: a path that ends in the folder
/// name is a file called that, not a file inside it.
static func path(_ path: String, isInsideFolderNamed name: String) -> Bool {
path.split(separator: "/").dropLast().contains { $0 == name }
}
}
// MARK: - The presented step
@@ -236,12 +244,11 @@ public struct SessionSettleGate {
///
/// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s
/// reason exactly: the operation behind it cannot proceed until the user has decided what happens to
/// text no commit protects, and there is no non-modal shape for a question whose three answers are
/// text no save protects, and there is no non-modal shape for a question whose three answers are
/// mutually exclusive and immediate.
///
/// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed
/// reflexively at a dialog nobody read must be the answer that changes nothing, and both other
/// answers write.
/// The default is **Cancel**, deliberately: a Return pressed reflexively at a dialog nobody read must
/// be the answer that changes nothing, and both other answers write.
public enum SessionSettleStep {
public static let title = "Unsaved card edits"
@@ -251,12 +258,12 @@ public enum SessionSettleStep {
Save them, discard the changes, or cancel.
"""
/// The same three buttons, asked for the other caller. **One sentence differs, deliberately**: the
/// consequence a user is deciding about is not the same one a restore would change the cards
/// being edited, while a switch takes every card to a different branch and a step that described
/// the wrong operation would be a worse modal than no wording at all.
public static let branchSwitchMessage = """
Switching branches would replace the cards you are editing. \
/// The same three buttons, asked for an operation that replaces the tree rather than editing part
/// of it. **One sentence differs, deliberately**: the consequence a user is deciding about is not
/// the same one, and a step that described the wrong operation would be a worse modal than no
/// wording at all. Kept unused beside the gate, for the gate's reason.
public static let wholesaleReplacementMessage = """
This would replace the cards you are editing. \
Save them, discard the changes, or cancel.
"""
+10 -11
View File
@@ -64,15 +64,15 @@ public struct ChangeNarrationRequest: Sendable {
public let snapshot: BoardModel?
/// **The last-committed half** of the "last-committed vs. current" diff supplied by the caller
/// by contract; the narrator itself never reads this from anywhere. The git stack supplied it from
/// HEAD's tree (`GitHeadSnapshot`), the former git-backed supplier; the journal will supply it from
/// memory/snapshots.
/// by contract; the narrator itself never reads this from anywhere. The retired git substrate
/// materialized it from HEAD's tree; the journal will supply it from memory/snapshots.
///
/// `nil` when the caller has nothing to diff against the git stack's case was an unborn HEAD
/// (where `isRootCommit` already says everything) or a HEAD whose tree did not load as a board. The
/// git stack read it from the repository rather than carrying it forward from the last commit the
/// app made, because the app is not the only writer and because launch catch-up has no carried
/// value to offer: see `GitHeadSnapshot` for the whole of that argument.
/// `nil` when the caller has nothing to diff against a first window (where `isRootCommit`
/// already says everything), or a stored state that did not load as a board.
///
/// **A supplier reads it rather than carrying it forward** from the last window it described, and
/// deliberately: the app is not the only writer, and catch-up after a period of not watching has
/// no carried value to offer.
public let previousSnapshot: BoardModel?
/// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it the one
@@ -92,8 +92,7 @@ public struct ChangeNarrationRequest: Sendable {
/// comments' own `created`, folder name on ties" (06 Rules Auto-commit, blessed 2026-07-31),
/// and `created` lives in a comment's own `index.md` because comments are window-scoped and the
/// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves
/// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure
/// function of values.
/// it once, off disk, and the engine stays a pure function of values.
///
/// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit
/// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
@@ -141,7 +140,7 @@ public protocol ChangeNarrating: Sendable {
// MARK: - The wired composer
/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`).
/// **The semantic composer**, and the seam's default.
///
/// A one-line conformance over `ChangeNarrator`, deliberately: the vocabulary is worth a file of
/// its own and nothing about it should have to know that a protocol exists. The type stays because
+17 -9
View File
@@ -24,10 +24,10 @@ import Foundation
/// ### Purity, and where the impurity went
///
/// Nothing in here reads a file, opens a repository, or asks what time it is. Both snapshots and the
/// guide's text arrive as values on `ChangeNarrationRequest`; `GitAutoCommitter` resolves them once per
/// flush, the last-committed one through `GitHeadSnapshot`. That is what makes the whole vocabulary
/// every subject form, every fold, every trash reading testable with two snapshots and no
/// repository at all.
/// guide's text arrive as values on `ChangeNarrationRequest`, resolved once per window by whoever is
/// driving the narration the previous snapshot included. That is what makes the whole vocabulary
/// every subject form, every fold, every trash reading testable with two snapshots and no store at
/// all.
///
/// ### The changed-path list is a filter, not a hint
///
@@ -93,6 +93,14 @@ enum ChangeNarrator {
/// says *added* without a second sentence shape for it.
static let absentValuePlaceholder = "(none)"
/// **The subject the one snapshot-less window gets** (06 Rules Abnormal repo states): a first
/// window with no previous snapshot to diff against describes the whole board as arriving, never a
/// folded diff-from-empty.
///
/// Defined here rather than borrowed from a substrate, so the narrator stays a pure function of
/// two snapshots (`strategy/01-git-excision.md` What is harvested).
static let rootSubject = "Initial board state"
// MARK: - Entry point
/// One request in, one whole message out a subject, and a body when there is more to say.
@@ -100,7 +108,7 @@ enum ChangeNarrator {
// **The one commit with a subject of its own** (06 Rules Abnormal repo states): "it
// 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 }
guard !request.isRootCommit else { return rootSubject }
return assemble(events(for: request), request: request)
}
@@ -910,7 +918,7 @@ enum ChangeNarrator {
/// inside a thread at all.
///
/// Shared with the flush that reads each of those folders' `created`
/// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed*
/// (`ChangeNarrationRequest.commentTimestamps`), so the key a timestamp is *filed*
/// under and the key it is *looked up* by have one definition. Everything about where a thread
/// lives is still `CommentPath.classify`'s; this only says how many of the path's components that
/// classification consumed.
@@ -1281,9 +1289,9 @@ enum ChangeNarrator {
/// Whether a path could make the *snapshot* differ at all every path the model speaks for,
/// plus attachments, whose names it carries.
///
/// The flush asks it before it materializes HEAD's tree: a window of nothing but strays and
/// the agent guide composes path-shaped events, and reading two whole boards to describe a
/// changed `.gitignore` would be work with no reader (`GitAutoCommitter.composition`).
/// The flush asks it before it materializes the previous snapshot: a window of nothing but
/// strays and the agent guide composes path-shaped events, and reading two whole boards to
/// describe a changed `.gitignore` would be work with no reader.
static func mightAffectSnapshot(_ path: String) -> Bool {
isModelSilent(path) || path.split(separator: "/").contains(Substring(attachmentsFolder))
}
+1 -2
View File
@@ -160,8 +160,7 @@ extension BoardStore {
/// - Returns: whether the purge now has an owner a live step holding it until the substrate
/// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling,
/// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it
/// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller
/// still owes the purge.
/// already. `false` means nothing was registered and the caller still owes the purge.
@discardableResult
func registerCardSession(
_ window: CardWindowUndo,
+3 -3
View File
@@ -363,9 +363,9 @@ public protocol HistoryProviding: AnyObject {
extension HistoryProviding {
/// **A substrate that keeps no steps holds no backing** the git provider's honest answer
/// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides
/// the close flush" true on Pro), and a test fake's.
/// **A substrate that keeps no steps holds no backing** the honest answer for any provider that
/// retires every step on arrival (which is what makes "purge rides the close flush" true over such
/// a substrate), and a test fake's.
///
/// A default on the requirement rather than a free function, so the dispatch is the substrate's:
/// an implementation that *does* keep steps overrides it and every caller through `any
+46 -311
View File
@@ -36,79 +36,6 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable {
}
}
/// **The one-shot failure class's second shape** (02-architecture.md § The banner surface, settled
/// 2026-07-31): "the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation
/// failure** the operation named in the user's words plus the underlying error, phrasing still
/// BannerCenter's because failures rank by what they are, not by which error vocabulary threw
/// them".
///
/// It is a row of its own rather than a `BoardWriteError` with a git-shaped case because "git
/// operations stay off the closed `WriteOperation` vocabulary only the banner tier learns the
/// second shape": a `WriteOperation` is something the Writer does to a file the model knows, and a
/// checkout is not one. What the two shapes *do* share is everything the strip cares about the
/// error tone, the failure rank, and the one-shot's dismissable, untimed lifecycle which is why
/// they sit in one precedence class rather than in two (`BannerCenter.rows(...)`).
///
/// It replaces the shipped compromise, and the compromise is worth naming because its reasoning is
/// the mirror of this one's: a failed restore used to post a `LossBanner` the nearest class with a
/// free-form message which read as warning-tone content-didn't-arrive when what actually happened
/// was an action that didn't happen at all. "A failed undo restore, branch switch, or (pro-m2)
/// pull/push presents in the error tone at the failure rank, never as a warning-tone loss row."
public struct GitFailureBanner: Identifiable, Sendable, Equatable {
public let id: UUID
/// Which operation didn't happen a closed vocabulary, so the sentence is composed here rather
/// than carried in from the git layer (`GitOperation`).
public let operation: GitOperation
/// The underlying error, verbatim libgit2's own message, the free-form tail the phrasing rules
/// allow ("could not write to '': Permission denied"). Never the banner's verb.
public let reason: String
/// When it failed the sort key for "newest first within a class", shared with the write
/// failures it ranks beside.
public let occurredAt: Date
public init(id: UUID = UUID(), operation: GitOperation, reason: String, occurredAt: Date = Date()) {
self.id = id
self.operation = operation
self.reason = reason
self.occurredAt = occurredAt
}
}
/// **The git operations the banner can name** the closed vocabulary behind `GitFailureBanner`,
/// and the git half of the rule the `WriteOperation` enum already carries for writes: "the banner
/// owns all user-facing phrasing and localization from that vocabulary", and an operation added
/// without a sentence to say about it is a compile-time hole rather than a silent default
/// (`BannerCenter.actionPhrase(for:)` switches over it exhaustively).
///
/// Deliberately small. 02 names the members: "a failed undo restore, branch switch, or (pro-m2)
/// pull/push" the first three are here because they have producers today
/// (`GitHistoryProvider`, `GitBranchSwitcher`), and pull/push join by adding a case and a sentence
/// when the remote half ships.
///
/// No associated values: a branch switch's target is in the in-progress row the failure replaces
/// ("Switching to 'main'"), and a failure that named it again would be the only member of this
/// vocabulary carrying a payload a case can grow one the day a sentence genuinely needs it.
public enum GitOperation: Sendable, Equatable, CaseIterable {
case undo
case redo
case branchSwitch
/// **Add-git, when the form that asked has been dismissed** (06-history-undo.md Interaction with
/// external writers, ruled 2026-07-31): "if the sheet has been dismissed before the answer
/// arrives, the failure falls back to the one-shot banner above inline is the primary surface,
/// never a silence trap." Inline is `HistoryStore.lastFailure`; this is the fallback.
case addGit
/// The pair Z and Z map to so a caller that knows a `HistoryDirection` (the provider's
/// crossing) never has to spell the mapping itself, and never gets it backwards.
public static func restore(_ direction: HistoryDirection) -> GitOperation {
switch direction {
case .undo: .undo
case .redo: .redo
}
}
}
/// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner
/// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28)
/// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin.
@@ -218,19 +145,18 @@ public struct InfoSignpost: Identifiable, Sendable, Equatable {
}
/// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface,
/// "In-progress operations are info rows"): bracketed git operations ("Pulling", "Switching to
/// 'main'") and long non-git work (big-board Duplicate, template instantiation, large attachment
/// imports).
/// "In-progress operations are info rows"): long copy-shaped work big-board Duplicate, template
/// instantiation, large attachment imports and whatever wholesale operation joins them.
///
/// `label` is the caller's, deliberately: unlike a failure whose vocabulary is the closed
/// `WriteOperation` enum precisely so the banner can own every word an in-progress row names an
/// operation the banner layer has no enum for, and inventing one would mean a new git verb could
/// not describe itself without touching this file. The tradeoff is stated rather than hidden.
/// operation the banner layer has no enum for, and inventing one would mean a new verb could not
/// describe itself without touching this file. The tradeoff is stated rather than hidden.
///
/// **Cancel appears on safe copies only** (settled): copy-shaped work attachment imports,
/// Duplicate, template instantiation carries `cancel`, meaning "remove the partial copy, nothing
/// lost". Git brackets pass `nil`: they are seconds long, and aborting a rebase mid-flight is a
/// repair job, not a cancel.
/// lost". An operation that cannot be abandoned halfway passes `nil`: unwinding it is a repair job,
/// not a cancel.
public struct InProgressOperation: Identifiable, Sendable {
public let id: UUID
public let label: String
@@ -252,19 +178,16 @@ public struct InProgressOperation: Identifiable, Sendable {
/// One row in a window's banner strip.
///
/// The eight cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// divide into three lifecycles that the view renders differently and that the ordering rule
/// treats as classes:
///
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `repositoryUnreadable`,
/// `historySuspended`. They describe
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
/// ongoing state and carry no dismiss control "an error never evaporates unread" has a twin,
/// "a condition is never dismissed while it is still true". Each leaves when the thing it
/// describes stops being true.
/// - **One-shots dismiss**: `oneShot`, `gitFailure`, `loss`, and `signpost`. Each reports something
/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as
/// `oneShot` in a second shape (settled 2026-07-31) same tone, same rank, same lifecycle, a
/// message where the other carries a `BoardWriteError`. `loss` shares the lifecycle deliberately
/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something
/// that already happened, so only the user can clear it. `loss` shares the lifecycle deliberately
/// (settled 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the
/// harm, so it never auto-expires" is the same reasoning that keeps a one-shot from evaporating
/// unread, aimed at a row that isn't an error at all.
@@ -284,35 +207,18 @@ public enum BannerRow: Identifiable, Sendable {
/// the **whole** aggregate one row either way, but its headline names the first defect and
/// counts the rest rather than pretending the walk found only one.
case reloadBreakage(BoardLoadFailure)
/// **This board has a `.git` the app cannot open** (06-history-undo.md Rules, "A `.git` that
/// isn't a valid repository still reads as git mode and fails loudly", ruled 2026-07-31).
/// Condition, error tone, standing at the breakage rank the ruling's own class ("a standing
/// breakage-class banner at detection").
///
/// **No payload**, which is the ruling read literally: the sentence is fixed
/// (`BannerCenter.repositoryUnreadableMessage`, 06's words verbatim), and there is nothing to
/// diagnose past "libgit2 will not open it" a `reason` tail would be an invitation to leak
/// developer prose into a line the design already wrote. `HistorySuspension` carries one because
/// *its* tail genuinely varies ("disk full"); this row's cannot.
///
/// It heals rather than being dismissed, like every condition: "the banner clears when a later
/// open or reload finds the repo readable."
case repositoryUnreadable
/// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner)
/// A git operation that did not happen an undo restore, a branch switch, and (pro-m2) a pull
/// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure
/// class's second shape (settled 2026-07-31, see `GitFailureBanner`).
case gitFailure(GitFailureBanner)
/// Content that didn't arrive though nothing failed folders skipped from a Finder drop, the
/// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it,
/// above the ambient notices below it (settled 2026-07-28, see `LossBanner`).
case loss(LossBanner)
/// History has stopped advancing. Condition, warning tone the files are safe, only the undo
/// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
/// trail is degraded, which is a warning rather than an error. No producer today; the row waits
/// for whatever substrate can stall.
case historySuspended(HistorySuspension)
/// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse.
/// (m5's copy-shaped work and m7's git brackets drive it.)
/// (m5's copy-shaped work drives it.)
case inProgress(InProgressOperation)
/// A calm notice the passive half of the info tone, ranking last of all. (m6's card window
/// drives it, as 07-sync-collab.md's remote-change signpost.)
@@ -322,9 +228,7 @@ public enum BannerRow: Identifiable, Sendable {
switch self {
case .readOnlyLock: "read-only-lock"
case .reloadBreakage: "reload-breakage"
case .repositoryUnreadable: "repository-unreadable"
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
case let .gitFailure(banner): "git-failure:\(banner.id.uuidString)"
case let .loss(loss): "loss:\(loss.id.uuidString)"
case .historySuspended: "history-suspension"
case let .inProgress(operation): "operation:\(operation.id.uuidString)"
@@ -334,15 +238,9 @@ public enum BannerRow: Identifiable, Sendable {
public var tone: BannerTone {
switch self {
// A git operation that failed is an action that didn't happen, so it takes the failure
// tone with the write failures it ranks beside "never as a warning-tone loss row"
// (02-architecture.md § The banner surface, settled 2026-07-31).
// The unreadable repository takes the **error** tone rather than the history suspension's
// warning, and the two sit either side of a real line: a suspension is history failing to
// advance and retrying every debounce, while this is a repository the app cannot open at
// all nothing it does will fix it, and 06 asks for a "breakage-class" row that "fails
// loudly". The reload breakage is the precedent the ruling names, and it is an error.
case .readOnlyLock, .reloadBreakage, .repositoryUnreadable, .oneShot, .gitFailure: .error
case .readOnlyLock, .reloadBreakage, .oneShot: .error
// A suspension is history failing to advance and retrying, which is degraded rather than
// broken the files are safe either way, so it takes the warning tone the loss row does.
case .historySuspended, .loss: .warning
case .inProgress, .signpost: .info
}
@@ -361,9 +259,7 @@ public enum BannerRow: Identifiable, Sendable {
switch self {
case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
case let .reloadBreakage(error): BannerCenter.headline(for: error)
case .repositoryUnreadable: BannerCenter.repositoryUnreadableMessage
case let .oneShot(banner): BannerCenter.headline(for: banner.error)
case let .gitFailure(banner): BannerCenter.headline(for: banner)
case let .loss(loss): loss.message
case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
case let .inProgress(operation): operation.label
@@ -377,10 +273,9 @@ public enum BannerRow: Identifiable, Sendable {
public var dismissID: UUID? {
switch self {
case let .oneShot(banner): banner.id
case let .gitFailure(banner): banner.id
case let .loss(loss): loss.id
case let .signpost(signpost): signpost.id
case .readOnlyLock, .reloadBreakage, .repositoryUnreadable, .historySuspended, .inProgress: nil
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
}
}
@@ -464,12 +359,11 @@ public enum BannerRowControl: Identifiable, Sendable {
///
/// ### What lives here and what does not
///
/// A center holds the state nothing else does: dismissable one-shot failures in both shapes (write
/// and git-operation), loss rows, the history suspension, in-progress operations, and passive
/// signposts. It deliberately does **not** hold the read-only lock or the reload breakage those
/// are `BoardStore`'s truths, and copying them here would create a second place for them to be
/// stale. `BoardStore.bannerRows` composes both halves through
/// `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:gitFailures:)`, which is a
/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the
/// history suspension, in-progress operations, and passive signposts. It deliberately does **not**
/// hold the read-only lock or the reload breakage those are `BoardStore`'s truths, and copying them
/// here would create a second place for them to be stale. `BoardStore.bannerRows` composes both
/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a
/// *pure function* precisely so the precedence rule can be tested without a store, a window, or a
/// filesystem.
///
@@ -480,10 +374,7 @@ public enum BannerRowControl: Identifiable, Sendable {
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
/// suspension: their user-facing lines are here, not on the error types. **And the git operations
/// too** (settled 2026-07-31): `GitOperation` is that vocabulary's git-side twin "the operation
/// named in the user's words plus the underlying error, phrasing still BannerCenter's" which is
/// why `GitOperationFailure.operation`, a developer-facing string, never reaches a row.
/// suspension: their user-facing lines are here, not on the error types.
///
/// ### One center per window, not per board
///
@@ -502,37 +393,14 @@ public final class BannerCenter {
/// turn still order deterministically.
public private(set) var oneShots: [OneShotBanner] = []
/// The failure class's git-shaped half, newest first like `oneShots` a failed undo restore,
/// a failed branch switch, and (pro-m2) a failed pull or push (settled 2026-07-31, see
/// `GitFailureBanner`).
///
/// **A second array rather than a second case inside `OneShotBanner`**: the two shapes have no
/// payload in common one carries the closed `WriteOperation` vocabulary and a path, the other
/// a git operation and libgit2's message and every reader of a write failure would have to
/// start asking whether it was one. They meet where the design says they meet, in the strip's
/// order: `rows(...)` merges them into one precedence class, newest first across both.
public private(set) var gitFailures: [GitFailureBanner] = []
/// Newest first, like `oneShots` content that didn't arrive though nothing failed,
/// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see
/// `LossBanner`).
public private(set) var losses: [LossBanner] = []
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
/// The standing "history isn't advancing" condition, or `nil` when history is keeping up.
public private(set) var historySuspension: HistorySuspension?
/// **The standing "this board's git repository can't be read" condition** (06-history-undo.md
/// Rules, ruled 2026-07-31) `false` on every board whose repository opens, and on every board
/// that has none.
///
/// It lives here rather than on `BoardStore` where the lock and the breakage live because it
/// is not the store's truth: the fact belongs to the board's git state
/// (`HistoryStore.isRepositoryUnreadable`, itself the committer's pause), and the store learns it
/// through the same kind of seam the history suspension already arrives by
/// (`BoardStore.noteRepositoryUnreadable(_:)`). One condition, one owner, no second copy to go
/// stale.
public private(set) var isRepositoryUnreadable = false
/// Work in flight, newest first for the same reason `oneShots` is.
public private(set) var operations: [InProgressOperation] = []
@@ -552,19 +420,6 @@ public final class BannerCenter {
oneShots.insert(OneShotBanner(error: error), at: 0)
}
/// **Records a git operation that didn't happen** the failure class's second shape (settled
/// 2026-07-31, see `GitFailureBanner`): an undo or redo restore that failed cleanly
/// (06-history-undo.md Interaction with external writers), a branch switch that could not run
/// (06 Branch switching), and the remote pair when pro-m2 wires them.
///
/// `reason` is the underlying error and nothing else libgit2's own message, or the app's own
/// diagnosis of a repository it could not read. **Every user-facing word is composed here**
/// (`headline(for:)`), which is what keeps a caller from inventing a verb: the git layer's
/// `GitOperationFailure.operation` is a developer-facing string and never reaches the strip.
public func postGitFailure(_ operation: GitOperation, reason: String) {
gitFailures.insert(GitFailureBanner(operation: operation, reason: reason), at: 0)
}
/// Posts a loss row content that didn't arrive though nothing failed (settled 2026-07-28, see
/// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with.
///
@@ -804,32 +659,29 @@ public final class BannerCenter {
nonisolated static let mixedTrashDragMessage =
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
/// Removes a dismissable row: a one-shot failure in either shape, a loss row, or a signpost.
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost.
/// **An id that names an in-progress operation is ignored** rather than ending it, because
/// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do
/// the other.
public func dismiss(_ id: UUID) {
oneShots.removeAll { $0.id == id }
gitFailures.removeAll { $0.id == id }
losses.removeAll { $0.id == id }
signposts.removeAll { $0.id == id }
}
/// Removes every dismissable row both failure shapes, losses, and signposts alike. The strip's
/// Removes every dismissable row failures, losses, and signposts alike. The strip's
/// own "clear all" affordance later; today it is what a window uses when it re-homes its rows
/// elsewhere (m6).
public func dismissAllDismissableRows() {
oneShots.removeAll()
gitFailures.removeAll()
losses.removeAll()
signposts.removeAll()
}
// MARK: History suspension
/// Raises (or refreshes) the "changes aren't being recorded to history" condition m7's
/// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md
/// covers the lock itself, which is deliberately *not* a banner).
/// Raises (or refreshes) the "changes aren't being recorded to history" condition for whatever
/// substrate can stall while the files themselves are landing fine. No producer today.
///
/// A second call while already suspended keeps the original `since` and takes the newer
/// `reason`: the condition never stopped being true, so restarting its clock would misreport
@@ -838,39 +690,18 @@ public final class BannerCenter {
historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date())
}
/// Clears it, on the first successful commit. Idempotent clearing a condition that is not
/// standing is not an error, it is the ordinary shape of "commit succeeded".
/// Clears it, the moment history catches up. Idempotent clearing a condition that is not
/// standing is not an error, it is the ordinary shape of "it worked".
public func clearHistorySuspension() {
historySuspension = nil
}
// MARK: The unreadable repository
/// **Raises the standing "this board's git repository can't be read" condition**
/// (06-history-undo.md Rules, ruled 2026-07-31) the detection-time probe's answer, and any
/// later read that reaches the same conclusion.
///
/// Idempotent, and it deliberately records nothing about *when*: unlike the history suspension
/// whose `since` exists so a later "suspended for 4 minutes" reading could be built there is
/// nothing about this condition's duration a user could act on. The repository is unreadable or
/// it is not.
public func raiseRepositoryUnreadable() {
isRepositoryUnreadable = true
}
/// Clears it "the banner clears when a later open or reload finds the repo readable" (06).
/// Idempotent, `clearHistorySuspension()`'s rule: clearing a condition that is not standing is
/// the ordinary shape of a repository that was fine all along.
public func clearRepositoryUnreadable() {
isRepositoryUnreadable = false
}
// MARK: In-progress operations
/// Starts an info row with a spinner and hands back its id.
///
/// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the
/// partial copy, nothing lost" (02, settled). Git brackets pass `nil`.
/// partial copy, nothing lost" (02, settled). Everything else passes `nil`.
@discardableResult
public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID {
let operation = InProgressOperation(label: label, cancel: cancel)
@@ -880,9 +711,9 @@ public final class BannerCenter {
/// **Relabels a running operation** the same row, still spinning, now saying something else.
///
/// It exists for one sentence in 06-history-undo.md Interaction with external writers:
/// "contention outlasting the brief retry surfaces as a *waiting* state in the operation's
/// in-progress banner row ('waiting for another writer's git lock'), retrying on its cadence".
/// It exists for the waiting state a long operation can fall into contention outlasting a brief
/// retry surfaces in the operation's *own* in-progress row ("waiting for another writer"),
/// retrying on its cadence.
/// The waiting state is explicitly *the operation's own row*, not a second row and not a
/// replacement the operation has not restarted, it is explaining itself so the id is stable
/// and the view neither churns nor re-animates.
@@ -937,25 +768,14 @@ public final class BannerCenter {
/// a condition and a one-shot are not comparable by recency in any way a user would read as
/// order the condition's `since` is when it *started* being true, not when it happened
/// so they are ordered by kind, and recency orders only the one-shots among themselves.
/// - **The failure rank holds both shapes, interleaved by recency** (settled 2026-07-31):
/// "failures rank by what they are, not by which error vocabulary threw them", so a failed
/// undo posted a second ago sits above a failed move from a minute ago and below one from a
/// second before it. Ties two rows sharing a `Date` to the microsecond put the write
/// failure first; the two shapes are posted from different call sites, so a tie is an
/// accident of the clock rather than an order anyone can read.
/// - **The failure rank is one class, ordered by recency** (settled 2026-07-31): "failures rank
/// by what they are, not by which error vocabulary threw them". The rank held two row shapes
/// while the git stack was wired, interleaved by `occurredAt`; the merge went with the second
/// shape and the rank is ready to take another the day one arrives.
///
/// - **The unreadable repository stands in the breakage class, just under the reload breakage**
/// (06-history-undo.md Rules, ruled 2026-07-31: "a standing breakage-class banner"). Under,
/// and not over, because the two describe different things going wrong and one of them is
/// about the user's content: a reload breakage means the board on screen is not the board on
/// disk, while an unreadable repository leaves every file exactly as it is and pauses only the
/// history over them. Both outrank every one-shot, which is what "breakage-class" buys.
///
/// `signposts`, `gitFailures` and `repositoryUnreadable` carry defaults: the first because its
/// producer is m6's card window, the other two because a center that hosts no git surface (a card
/// window's own) can never hold either. Every other class is spelled out at every call site
/// `losses` included, since a Finder drop that skipped folders already posts one
/// (`postSkippedFolders`).
/// `signposts` carries a default because its producer is m6's card window. Every other class is
/// spelled out at every call site `losses` included, since a Finder drop that skipped folders
/// already posts one (`postSkippedFolders`).
public nonisolated static func rows(
lock: ReadOnlyLockReason?,
breakage: BoardLoadFailure?,
@@ -963,9 +783,7 @@ public final class BannerCenter {
losses: [LossBanner],
suspension: HistorySuspension?,
operations: [InProgressOperation],
signposts: [InfoSignpost] = [],
gitFailures: [GitFailureBanner] = [],
repositoryUnreadable: Bool = false
signposts: [InfoSignpost] = []
) -> [BannerRow] {
var rows: [BannerRow] = []
@@ -977,15 +795,9 @@ public final class BannerCenter {
if let breakage {
rows.append(.reloadBreakage(breakage))
}
if repositoryUnreadable {
rows.append(.repositoryUnreadable)
}
let ordered = newestFirst(oneShots, by: \.occurredAt)
rows.append(contentsOf: failureRank(
writes: ordered.filter { !$0.isAttachmentImport },
git: newestFirst(gitFailures, by: \.occurredAt)
))
rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))
rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss))
@@ -998,40 +810,13 @@ public final class BannerCenter {
return rows
}
/// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and
/// the git-operation failures, merged into the one precedence class they share "failures rank
/// by what they are, not by which error vocabulary threw them".
///
/// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the
/// write failure first, which is arbitrary and says so (`rows(...)`).
private nonisolated static func failureRank(
writes: [OneShotBanner],
git: [GitFailureBanner]
) -> [BannerRow] {
var merged: [BannerRow] = []
var writes = writes[...]
var git = git[...]
while let write = writes.first, let failure = git.first {
if failure.occurredAt > write.occurredAt {
merged.append(.gitFailure(failure))
git = git.dropFirst()
} else {
merged.append(.oneShot(write))
writes = writes.dropFirst()
}
}
merged.append(contentsOf: writes.map(BannerRow.oneShot))
merged.append(contentsOf: git.map(BannerRow.gitFailure))
return merged
}
/// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop
/// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every
/// `post` maintains newest-first on insertion so a tie renders in the order it was posted
/// rather than in whatever order the sort happened to leave.
///
/// One function over a date key rather than one per class: the three dismissable classes order
/// by exactly the same rule, and three copies of it were three places for it to drift.
/// One function over a date key rather than one per class: the dismissable classes order
/// by exactly the same rule, and a copy per class was a place for it to drift.
private nonisolated static func newestFirst<Row>(_ rows: [Row], by occurredAt: KeyPath<Row, Date>) -> [Row] {
rows
.enumerated()
@@ -1056,39 +841,6 @@ public final class BannerCenter {
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// The user-facing line for a git operation that didn't happen: what the app could not do, then
/// why the write failure's shape exactly (settled 2026-07-31), because the two are one class.
///
/// The cause is the underlying error verbatim, on `causePhrase(for:)`'s reasoning read one layer
/// down: libgit2's messages are specific in a way no re-phrasing of ours would be, and the
/// alternative to showing one is a shrug. An empty or whitespace-only reason leaves the action
/// clause alone rather than trailing a dash into nothing.
public nonisolated static func headline(for failure: GitFailureBanner) -> String {
let action = actionPhrase(for: failure.operation)
let cause = trimmed(failure.reason)
return cause.isEmpty ? action : "\(action)\(cause)"
}
/// **Exhaustive by construction no `default`**, the `WriteOperation` rule applied to the git
/// vocabulary: an operation added to `GitOperation` without a sentence here fails to compile.
///
/// The undo pair is named by **the command the user pressed** the skipped-step row's rule
/// (`skippedStepMessage`), and for its reason: Z is the gesture, and "Couldn't restore an
/// earlier state" would describe machinery the user has no model of. The branch switch is named
/// by the control they used (the popover's branch picker) and stays plural "branches" rather
/// than naming the target, which the in-progress row this failure replaces already said
/// ("Switching to 'main'") and which `GitOperation` deliberately doesn't carry.
private nonisolated static func actionPhrase(for operation: GitOperation) -> String {
switch operation {
case .undo: "Undo failed"
case .redo: "Redo failed"
case .branchSwitch: "Couldn't switch branches"
// Named by the control the user pressed, like the pair above "Add Git" is the button, and
// the row is only ever seen by someone who pressed it and looked away.
case .addGit: "Couldn't add git to this board"
}
}
/// **Exhaustive by construction no `default`.** A `WriteOperation` case added without a
/// sentence here fails to compile, which is the settled contract ("a new Writer operation
/// without a banner rendering is a compile-time hole, not a silent default").
@@ -1521,25 +1273,8 @@ public final class BannerCenter {
return "\(verb) skipped — '\(subject)' changed outside Lanework"
}
/// **The unreadable repository's line 06-history-undo.md Rules' own sentence, verbatim**
/// (ruled 2026-07-31): "a standing breakage-class banner at detection ('This board's git
/// repository can't be read history is paused; Lanework leaves the repository untouched')".
///
/// A `static let` rather than a `headline(for:)` overload because the row carries nothing to
/// compose from: three of its four sibling conditions take a payload and phrase around it, and
/// this one is one fixed sentence. It stays here rather than on the git layer for the standing
/// reason "the banner owns all user-facing phrasing" which is also why the git layer's own
/// clause for the same state (`GitRepositoryPause.unreadable.explanation`, a fragment for
/// failure tails) never reaches the strip.
///
/// The three clauses are the ruling's and each is load-bearing: what is wrong, what it costs
/// (history is paused not the board, which loads and edits normally), and the promise that
/// makes waiting safe (the app will not try to repair a repository it cannot read).
public nonisolated static let repositoryUnreadableMessage =
"This board's git repository can't be read \u{2014} history is paused; Lanework leaves the repository untouched"
/// The suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// flush-before-overwrite guarantee are degraded rather than the mechanics, and carries
/// the diagnosis as its tail.
public nonisolated static func headline(for suspension: HistorySuspension) -> String {
let reason = trimmed(suspension.reason)
-26
View File
@@ -244,21 +244,6 @@ public enum BoardAnnouncer {
public var breakageBefore: BoardLoadFailure?
public var breakageAfter: BoardLoadFailure?
/// **The unreadable-repository condition before and after** (06-history-undo.md Rules,
/// ruled 2026-07-31: the standing breakage-class banner, "announced per
/// 10-accessibility.md"). Booleans rather than a payload for the row's own reason the
/// sentence is fixed and a pair rather than a single flag for the lock's: what is
/// announced is the *transition*, in either direction.
///
/// No reload ever sets these. The condition is detected at board open and healed by the
/// paused engine's own re-read, neither of which is a reload so its producer is
/// `BoardStore.noteRepositoryUnreadable(_:)`, exactly as the writability probe's lock is
/// `announceLockChange(from:)`'s. They live on this value anyway because the ladder is where
/// "one sentence, chosen by precedence" is decided, and a second announcer would be a second
/// voice.
public var repositoryUnreadableBefore = false
public var repositoryUnreadableAfter = false
public init() {}
}
@@ -319,14 +304,6 @@ public enum BoardAnnouncer {
if let breakage = facts.breakageAfter, breakage != facts.breakageBefore {
return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage))
}
// Last of the raised conditions, matching the strip's own precedence: the two above it
// describe the board's files, this one describes the history over them.
if facts.repositoryUnreadableAfter, !facts.repositoryUnreadableBefore {
return AccessibilityPhrases.bannerLabel(
tone: .error,
headline: BannerCenter.repositoryUnreadableMessage
)
}
return nil
}
@@ -339,9 +316,6 @@ public enum BoardAnnouncer {
if facts.breakageBefore != nil, facts.breakageAfter == nil {
return AccessibilityPhrases.reloadBreakageCleared
}
if facts.repositoryUnreadableBefore, !facts.repositoryUnreadableAfter {
return AccessibilityPhrases.repositoryUnreadableCleared
}
return nil
}
}
+1 -36
View File
@@ -158,16 +158,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
/// `UserDefaults`.
public var remoteLocationWarned: Bool
/// **A bracketed git operation this app started and has not finished** (06-history-undo.md Rules
/// Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board
/// registry) before touching the repo").
///
/// `nil` for every board that is not mid-operation, which is every board almost all of the time:
/// the stamp is written immediately before the repository is touched and cleared as soon as the
/// operation is over, so finding one at open means the app died in between. See
/// `GitOperationStamp` for why it lives here rather than in the board folder or under `.git`.
public var gitOperationStamp: GitOperationStamp?
public init(
id: UUID = UUID(),
bookmark: Data,
@@ -182,8 +172,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
pushOnCommit: Bool = false,
remoteLocationWarned: Bool = false,
icon: String? = nil,
iconColor: String? = nil,
gitOperationStamp: GitOperationStamp? = nil
iconColor: String? = nil
) {
self.id = id
self.bookmark = bookmark
@@ -199,7 +188,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
self.remoteLocationWarned = remoteLocationWarned
self.icon = icon
self.iconColor = iconColor
self.gitOperationStamp = gitOperationStamp
}
// MARK: Codable
@@ -227,7 +215,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
case iconColor
case pushOnCommit
case remoteLocationWarned
case gitOperationStamp
}
public init(from decoder: any Decoder) throws {
@@ -246,11 +233,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
// Tolerant twice over: absent on every record written before this key existed, and absent
// again rather than fatal if a future build's stamp `Kind` is one this build cannot name.
// A stamp that cannot be read is a stamp that cannot recover anything, which degrades to the
// pause-and-defer stance rather than to a quarantined registry.
gitOperationStamp = try? container.decodeIfPresent(GitOperationStamp.self, forKey: .gitOperationStamp)
}
public func encode(to encoder: any Encoder) throws {
@@ -269,7 +251,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
try container.encodeIfPresent(iconColor, forKey: .iconColor)
try container.encode(pushOnCommit, forKey: .pushOnCommit)
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
try container.encodeIfPresent(gitOperationStamp, forKey: .gitOperationStamp)
// An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's
// forward tolerance is a decoding property, and nothing here preserves what it cannot read.
}
@@ -599,22 +580,6 @@ public final class BoardRegistry {
update(id) { $0.remoteLocationWarned = true }
}
/// **Records or clears the bracketed git operation this app is about to run**
/// (`GitOperationStamp`).
///
/// It saves the file synchronously like every other setter here, and that is load-bearing rather
/// than incidental: the whole value of the stamp is that it is on disk *before* the repository is
/// touched, so a crash a millisecond later is still recognizable as this app's. `save()` writes
/// atomically, so the file a next launch reads is either the old one or this one.
public func setGitOperationStamp(id: UUID, _ stamp: GitOperationStamp?) {
update(id) { $0.gitOperationStamp = stamp }
}
/// The stamp this board is carrying, if any read once per session, at open.
public func gitOperationStamp(id: UUID) -> GitOperationStamp? {
record(id: id)?.gitOperationStamp
}
// MARK: - Reading
/// Every known board, most recently opened first, each classified by whether its bookmark
+11 -120
View File
@@ -288,10 +288,9 @@ public final class BoardStore: HealHost {
/// still refresh the card window's thread and board search's comment index. Keying either on
/// `snapshotGeneration` would make the value-equal skip a freshness bug rather than an
/// optimization.
/// - **The auto-committer's covering gate** (`GitAutoCommitter.awaitCoveringSnapshot`): what
/// covers a flush is a *walk* that started after its writes hit disk, and a completed walk
/// covers them whether or not the tree turned out to differ. A gate waiting on the applied
/// counter would spin out its whole deadline on any value-equal landing.
/// - **Any covering gate** a consumer waiting for a walk that started after its writes hit
/// disk. A completed walk covers them whether or not the tree turned out to differ, so a gate
/// waiting on the applied counter would spin out its whole deadline on any value-equal landing.
///
/// A failed reload bumps neither counter: it produced no snapshot, so it covers nothing and
/// refreshes nothing the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
@@ -471,41 +470,10 @@ public final class BoardStore: HealHost {
losses: banners.losses,
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts,
gitFailures: banners.gitFailures,
repositoryUnreadable: banners.isRepositoryUnreadable
signposts: banners.signposts
)
}
/// **The unreadable repository, raised or healed** (06-history-undo.md Rules, "A `.git` that
/// isn't a valid repository still reads as git mode and fails loudly", ruled 2026-07-31) the
/// session's one call for a condition that is the *git state's* truth rather than this store's
/// (`HistoryStore.isRepositoryUnreadable`, wired in `AppModel.beginSession`).
///
/// It does two things because the ruling asks for two: the row stands on the strip, and it is
/// **announced** "announced per 10-accessibility.md", which makes a standing banner "an
/// accessibility element announced when it appears and when it clears". This condition never
/// arrives on a reload (it is detected at open and healed by the paused engine's own re-read), so
/// it takes `announceLockChange(from:)`'s path exactly: through `BoardAnnouncer`'s ladder rather
/// than posting directly, so the sentence a user hears and the sentence they read off the row are
/// one string.
///
/// Idempotent, and silent when nothing changed: the 15 s re-read that keeps confirming an
/// unreadable repository must not say so every 15 s.
public func noteRepositoryUnreadable(_ unreadable: Bool) {
guard banners.isRepositoryUnreadable != unreadable else { return }
if unreadable {
Self.logger.error("this board's git repository could not be opened — history is paused")
banners.raiseRepositoryUnreadable()
} else {
banners.clearRepositoryUnreadable()
}
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableBefore = !unreadable
facts.repositoryUnreadableAfter = unreadable
announce(BoardAnnouncer.speech(for: facts))
}
// MARK: Wiring
/// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and
@@ -569,18 +537,6 @@ 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: The open card windows
/// **Which of this board's cards have a card window open right now** one fact, kept for one
@@ -761,28 +717,6 @@ public final class BoardStore: HealHost {
@ObservationIgnored
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
/// **Where git path history reaches the loader** (01-storage-format.md Fractal layout
/// Rules, the duplicate-id winner rule; `BoardLoader.IdentityHistoryRanker`) `nil` on every
/// board the app manages no git for, which is every free-tier board and every Pro board without
/// a repo at its root.
///
/// A **provider** rather than a ranker, for two reasons that point the same way. Each load wants
/// its own ranker, so that a load never answers from a history that has moved since the last one
/// (the ranker caches internally, once, per load). And add-git flips a board into git mode
/// mid-session, which a closure asked at load time absorbs by construction while a value handed
/// over at composition never could.
///
/// `@MainActor` because it is called here, on the main actor, at the head of each reload; what
/// it returns is `Sendable` and does its git work off-main, inside the walk that consults it.
///
/// **The board's first load predates this** `init` runs inside `BoardStoreRegistry.acquire`,
/// before a session exists to compose the git state that supplies it so an opening board's
/// duplicate-id ladder falls through to birth date, and every reload after it consults history.
/// Deliberate, and the narrow cost of composing the git state where the design puts it
/// (`AppModel.beginSession`) rather than where the first walk happens to run.
@ObservationIgnored
var makeIdentityHistoryRanker: (@MainActor () -> BoardLoader.IdentityHistoryRanker?)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
// MARK: - Init
@@ -904,10 +838,6 @@ public final class BoardStore: HealHost {
let generation = reloadGeneration
let root = rootURL
let barrier = loadBarrier
// Asked once per load, on the main actor, and answered off it: what comes back is a lazy
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
// break a tie for. `nil` everywhere the app manages no git.
let historyRanker = makeIdentityHistoryRanker?()
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
// decision stands for the session, so a reload sees the board the user chose to open.
let skipping = skippedPaths
@@ -928,7 +858,6 @@ public final class BoardStore: HealHost {
outcome = .success(try BoardLoader.load(
boardRoot: root,
skipping: skipping,
historyRanker: historyRanker,
memo: memo,
counter: counter
))
@@ -982,13 +911,6 @@ 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
@@ -1020,7 +942,6 @@ 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
@@ -1170,7 +1091,6 @@ 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)")
}
@@ -1181,18 +1101,6 @@ 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
@@ -1448,24 +1356,11 @@ 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.
//
// **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
@@ -1535,25 +1430,21 @@ public final class BoardStore: HealHost {
try operation()
} catch let error as BoardWriteError {
// Same honesty rule as `performWrite`, applied to the one error type the banner has
// phrasing for. A wholesale operation is usually git's (m7), whose own failure
// vocabulary is not `BoardWriteError` and whose surfacing the suspended-history
// condition, the in-progress row swapping for an error is the committer's to drive;
// but a `BoardWriteError` escaping here is an ordinary failed write and may no more
// bypass the strip than one from `performWrite`.
// phrasing for. A wholesale operation may carry a failure vocabulary of its own, whose
// surfacing is that operation's to drive; but a `BoardWriteError` escaping here is an
// ordinary failed write and may no more bypass the strip than one from `performWrite`.
banners.post(error)
throw error
}
}
/// The same bracket over work that **awaits** the undo restore (06-history-undo.md) and, next,
/// the branch switch.
/// The same bracket over work that **awaits**.
///
/// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the
/// synchronous version above exists because `performWrite`-shaped work is synchronous, while a
/// git operation is a detached libgit2 task the main actor must not block on
/// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion
/// phrase in one place; the distinct argument label is what keeps overload resolution from having
/// to guess which one a trailing closure meant.
/// wholesale operation can be a detached task the main actor must not block on. Both keep the
/// bracket, the reload floor and the completion phrase in one place; the distinct argument label
/// is what keeps overload resolution from having to guess which one a trailing closure meant.
///
/// The refusal, the ordering and the arming are the synchronous version's, unchanged see its
/// doc comment for all three.
+16 -14
View File
@@ -318,22 +318,24 @@ public final class EchoLedger: Sendable {
/// **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`).
/// **The attribution surface** the seam a provenance consumer harvests through, and it has to be
/// a copy rather than a read at attribution time for an ordering reason worth stating here:
/// receipts are *consumed* by the landing reload that classifies them ("one write, one echo"), and
/// a consumer 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 an
/// external writer. So a consumer 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.
///
/// 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.
// The harvest speaks `HarvestedReceipt` committer-side vocabulary from
// Git/CommitAttribution.swift, which the phone target doesn't compile (the mobile MVP has
// no git). The recording side above is what BoardWriter needs on every platform; this copy
// has exactly one caller and it is Mac-only.
/// announcer's consumption still decides what speaks, and a harvested copy still decides what each
/// batch is attributed to.
///
/// **Kept with no live consumer** (`strategy/01-git-excision.md` What is kept): the harvest is
/// the foundation the foreign-change journal will be built on, so the surface stays even though
/// the git consumer that drove it has gone.
// Mac-only because `HarvestedReceipt` is, and the phone target doesn't compile it. The recording
// side above is what BoardWriter needs on every platform; this copy is Mac's alone.
#if os(macOS)
func outstandingEntries() -> [String: HarvestedReceipt] {
receipts.withLock { store in
+1 -1
View File
@@ -1546,7 +1546,7 @@ public struct LoadResult: Sendable {
/// keeps the loader a pure function whose caller holds the whole of what the next call may reuse.
///
/// A caller that ignores it gets a cold walk every time, which is exactly what
/// `TemplateEngine`, `GitHeadSnapshot` and every first load do.
/// `TemplateEngine` and every first load do.
public var memo: BoardLoader.ParseMemo = BoardLoader.ParseMemo()
/// **The typed defect stream** everything this walk found that is pending *work*
-9
View File
@@ -432,13 +432,4 @@ enum AccessibilityPhrases {
/// Reload breakage clearing the board is reading its files again, which is a smaller claim
/// than the lock's and is deliberately phrased as one.
static let reloadBreakageCleared = "The board is loading again"
/// The unreadable repository clearing (06-history-undo.md Rules, ruled 2026-07-31: "the banner
/// clears when a later open or reload finds the repo readable").
///
/// Stated as the regained capability, `readOnlyLockCleared`'s rule: what the user was waiting on
/// is history advancing again, not libgit2 changing its mind about a folder. It stays the
/// smaller claim of the two nothing about editing the board was ever blocked by this
/// condition, which is precisely what its own row says.
static let repositoryUnreadableCleared = "History is recording again"
}
+5 -7
View File
@@ -212,14 +212,12 @@ public final class CardBodyEditSession {
}
/// **Throws the buffer away and takes disk's word for it** the Discard branch of the
/// save-or-discard step (06-history-undo.md Branch switching: "Discard reverts buffers and
/// uncommitted saves to HEAD").
/// save-or-discard step (13-native-undo.md Undo routing; `SessionSettleGate`).
///
/// It reverts the *buffer* and ends the session; the uncommitted on-disk saves are the operation
/// behind the step's to undo, because only that operation knows which state it is restoring to
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)` reconciles the card's folder
/// against the working tree for exactly this reason). Splitting it that way is what keeps the two
/// halves from being two answers able to disagree: one pass writes the card's files, once.
/// It reverts the *buffer* and ends the session; any uncommitted on-disk saves are the wholesale
/// operation behind the step's to reconcile, because only that operation knows which state it is
/// restoring to. Splitting it that way is what keeps the two halves from being two answers able to
/// disagree: one pass writes the card's files, once.
///
/// The pending debounce is cancelled first, which is the load-bearing half a surviving timer
/// would write the discarded text back over the restored card a moment later.
-138
View File
@@ -62,49 +62,6 @@ private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef
return ref
}
/// The same board with a **real repository** at its root, root commit and all what a Pro session
/// detects as mode `git`, and therefore the only shape that composes a branch switcher to test the
/// settle step's seams through.
@MainActor
private func makeProGitBoard() throws -> WriterFixture {
let fixture = try makeMixedBoard()
guard case .success = GitRepository.create(at: fixture.root) else {
fixture.tearDown()
Issue.record("could not initialize a repository for the fixture board")
throw CocoaError(.fileWriteUnknown)
}
return fixture
}
/// Opens a card window the way its host does registered with the board's session and leaves it
/// holding one fine step and an open Edit session, so it both *has* a stack to lose and answers the
/// save-or-discard step's `needsSettling` with `true`.
///
/// The step is registered through `BoardStore.registerStep` rather than pushed onto the provider, so
/// it is routed by the same line production routes a card-window gesture with (`on:` the window's
/// stack) and carries the raw write the close fold would look for.
@MainActor
@discardableResult
private func openCardWindow(
_ model: AppModel,
board: BoardWindowRef,
card id: String,
store: BoardStore
) -> CardWindowSession {
let window = CardWindowSession()
model.registerCardWindow(CardWindowRef(board: board, cardID: ItemID(rawValue: id)), session: window)
window.body.beginEditSession()
store.registerStep(
"Edit Card",
on: window.undo,
undoExpects: [.present(.card(ItemID(rawValue: id)), .body("after\n"))],
redoExpects: [.present(.card(ItemID(rawValue: id)), .body("before\n"))],
undo: { _ in },
redo: { _ in }
)
return window
}
// MARK: - Tests
@MainActor
@@ -319,101 +276,6 @@ struct AppModelTests {
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
}
// MARK: The branch switch's settle
/// **"The settle also clears each open card window's fine undo stack"** (06-history-undo.md
/// Branch switching, ruled 2026-07-31): "pre-switch steps describe the branch being left Save
/// All and Discard alike end with every window's stack empty the windows stay open, following
/// their cards onto the new branch with fresh stacks."
///
/// This is an `AppModel` test rather than a `GitBranchSwitcher` one because the clear is a fact
/// about the **composition**: the switcher's settle seam, the card-window registry and the stacks
/// themselves only meet in `wireBranchSwitching`, and a switcher wired by hand would be a test
/// asserting its own wiring (the `a381fac` lesson, applied one card later).
@Test(
"The branch switch's settle empties every open card window's fine stack",
arguments: [SessionSettleChoice.saveAll, .discard]
)
func theSettleClearsEveryFineStack(answering choice: SessionSettleChoice) async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let windows = [Ident.card1, Ident.card2].map { id in
openCardWindow(model, board: ref, card: id, store: session.store)
}
#expect(windows.allSatisfy { $0.undo.stack.canUndo })
#expect(windows.allSatisfy { $0.undo.netEffect() != nil }, "a session with a net effect to fold")
model.settleAsk = { _ in choice }
#expect(await switcher.settleSessions?() == .proceed)
for window in windows {
#expect(!window.undo.stack.canUndo, "the stack describes the branch being left")
#expect(!window.undo.manager.canUndo, "and ⌘Z in that window answers with it")
}
// "The windows stay open, following their cards onto the new branch with fresh stacks."
#expect(model.session(for: ref)?.cardRefs.count == 2)
}
/// **Closing the stack is not closing the window.** The coarse step a card window owes its board is
/// registered at *close*, folded from this stack (13-native-undo.md Rules "Window close
/// coarsens"); a settle clear registers nothing at all, which is exactly what
/// `registerCardSession` answering `false` and the deferred purge staying the caller's says.
@Test("A settle clear registers no coarse step — the fold that would have run finds nothing")
func theClearRegistersNoCoarseStep() async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
model.settleAsk = { _ in .saveAll }
#expect(await switcher.settleSessions?() == .proceed)
#expect(window.undo.netEffect() == nil, "nothing left to fold")
var purged = false
let registered = session.store.registerCardSession(
window.undo,
inCard: ItemID(rawValue: Ident.card1),
retiring: { purged = true }
)
#expect(!registered, "a close arriving right after the switch registers nothing")
#expect(!purged, "and the deferred purge is still the caller's, not a step's")
}
/// "**Cancel** keeps the current branch and the sessions" and now their stacks with them. The
/// same `if` that withholds the staging release withholds this.
@Test("Cancel clears nothing")
func cancelKeepsTheFineStacks() async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
model.settleAsk = { _ in .cancel }
#expect(await switcher.settleSessions?() == .cancelled)
#expect(window.undo.stack.canUndo)
#expect(window.undo.netEffect() != nil)
}
// MARK: Launch restoration
/// App Settings's "Restore open boards at launch" (11-command-nexus.md) gates the flagged set
-122
View File
@@ -962,128 +962,6 @@ struct AutoCommitCompositionTests {
}
}
// MARK: - The composition root
/// **The wired-at-`beginSession` seams, pinned where they are wired** (02-architecture.md Layering;
/// 12-editions.md The provider seam).
///
/// Every suite above composes its own committer by hand, which is what makes them readable and is
/// exactly why they cannot see the defect this suite exists for: `AppModel.beginSession` once composed
/// the committer *without* the store's `EchoLedger` (`HistoryStore.compose`'s default is a fresh one,
/// for the store-less callers), so every unit layer passed while every production commit misattributed
/// the app's own writes arriving unvouched-for and authored `Lanework External`. It was fixed in
/// `a381fac` by passing `store.echoes`, and nothing but a test that opens a board *through the model*
/// could have caught it or can keep it caught.
///
/// So the assertions here are about the **composition** and never about the units: not "the ledger
/// classifies" (`AutoCommitAttributionTests`) and not "a bracket announces at completion"
/// (`BoardAnnouncerTests`), but that a board opened the way a window opens one has those two wires in
/// it.
@MainActor
@Suite("Auto-commit ▸ the composition root")
struct AutoCommitCompositionRootTests {
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home `AppModelTests`' own fixture, for its reason.
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("AutoCommitCompositionTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
model.currentTier = { .pro }
return (model, { try? FileManager.default.removeItem(at: folder) })
}
/// Opens a board the way `BoardWindowHost` does record, acquire, flag, begin so what is under
/// test is the real `beginSession` and not a hand-assembled session.
private func openBoard(_ model: AppModel, at url: URL) throws -> AppModel.BoardSession {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return try #require(model.session(for: ref))
}
/// **(a) The committer is composed with the session store's own ledger** the same instance the
/// store's writes drop receipts into (`BoardStore.echoes`).
///
/// Asserted through the one thing the ledger decides: **authorship**. An ordinary app-mediated
/// write through the store, committed by the session's own committer, is authored by this
/// machine's user. Composed with any *other* ledger it would be authored `Lanework External`
/// which is not a hypothetical shape, it is what `AutoCommitAttributionTests`'
/// `foreignIsLaneworkExternal` pins for a write nobody vouched for, and what this board's every
/// commit did before `a381fac`.
@Test("beginSession composes the committer with the store's own EchoLedger")
func theCommitterIsComposedWithTheStoresLedger() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
let committer = try #require(session.git?.committer)
// Only the explicit flush commits, and it does not sit out a watcher that a temp directory may
// or may not deliver events for: this test is about *who* the commit is by.
committer.stop()
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotDeadline = .milliseconds(50)
committer.coveringSnapshotPollInterval = .milliseconds(5)
// An ordinary write through the store the Writer boundary, receipt and all. Nothing here
// touches the ledger by hand, which is the whole point: the receipt has to travel from the
// store's own ledger to the committer's, and there is only one way for that to be true.
let outcome = session.store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "By the app.\n")
#expect(outcome == .written)
await committer.flushNow()
let head = try #require(try history(at: fixture.root).first)
#expect(head.authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email)
#expect(
head.authorEmail != CommitAttribution.externalAuthorEmail,
"a committer composed over any other ledger would blame the outside world for this write"
)
}
/// **(b) The announcer outlet is bound** the undo restore's bracket runs through the store's
/// `performWholesale(announcing:)`, so its subject reaches `BoardStore.announce`.
///
/// `GitHistoryProvider.runBracketed` is optional and "`nil` runs the work bare, which is what a
/// repository-level test wants" so an unwired seam is silent rather than broken, and every
/// repository-level suite in this file would keep passing over one. What a session owes it is the
/// store's bracket: the watcher suspension, the reload floor that locks the board if the closing
/// reload fails, and 10-accessibility.md's one sentence at completion.
@Test("beginSession binds the restore's bracket to the board's announcer outlet")
func theRestoreBracketReachesTheAnnouncer() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
session.git?.committer?.stop()
let store = session.store
let provider = try #require(session.history as? GitHistoryProvider, "a git board binds the git provider")
var spoken: [String] = []
store.announce = { if let phrase = $0 { spoken.append(phrase) } }
let bracket = try #require(provider.runBracketed, "the restore has a bracket to run inside")
await bracket("Undid 'Add card'") {
try? fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Restored"))
}
// The bracket's closing reload the one it armed, whichever way the operation went.
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
#expect(spoken == ["Undid 'Add card'"], "the trail's sentence and the spoken one are the same one")
#expect(store.readOnlyLock == nil, "the closing reload succeeded, so nothing is locked")
}
}
// MARK: - The Edit-session flag
/// What the card body still owes the commit model after the stage-around widened to the whole window
+15 -277
View File
@@ -85,15 +85,8 @@ struct BannerCenterOrderingTests {
message: "Pasted 'Fix login' without its 3 attachments",
occurredAt: Date(timeIntervalSince1970: 150)
)
let operation = InProgressOperation(label: "Pulling…")
let operation = InProgressOperation(label: "Duplicating…")
let signpost = InfoSignpost(message: "This card changed on the remote")
// The failure class's second shape (settled 2026-07-31) newer than the failed move, so it
// leads the rank the two of them share.
let restore = GitFailureBanner(
operation: .undo,
reason: "could not write to 'index.md': Permission denied",
occurredAt: Date(timeIntervalSince1970: 120)
)
let rows = BannerCenter.rows(
lock: .vanishedRoot,
@@ -102,126 +95,27 @@ struct BannerCenterOrderingTests {
losses: [loss],
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
operations: [operation],
signposts: [signpost],
gitFailures: [restore],
repositoryUnreadable: true
signposts: [signpost]
)
// in-progress (pinned) > read-only lock > reload breakage > **the unreadable repository** >
// one-shot failures, both shapes > loss rows > commit and attachment failures > passive info
// rows. The two info classes sit at opposite ends of the strip, and the breakage class holds
// two rows now (06-history-undo.md Rules, ruled 2026-07-31): the reload breakage first,
// because it is the one saying the board on screen is not the board on disk.
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures > loss rows >
// commit and attachment failures > passive info rows. The two info classes sit at opposite
// ends of the strip.
#expect(rows.map(\.id) == [
"operation:\(operation.id.uuidString)",
"read-only-lock",
"reload-breakage",
"repository-unreadable",
"git-failure:\(restore.id.uuidString)",
"one-shot:\(move.id.uuidString)",
"loss:\(loss.id.uuidString)",
"history-suspension",
"one-shot:\(attachment.id.uuidString)",
"signpost:\(signpost.id.uuidString)",
])
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false, false],
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false],
"a spinner may never hide behind '+N more' — nothing else is pinned")
}
/// **The corrupt-`.git` loud failure's row** (06-history-undo.md Rules, ruled 2026-07-31)
/// it stands with the breakage class and above every one-shot, which is what "breakage-class"
/// buys it: a failed move posted a second ago never pushes it down the strip.
@Test("The unreadable repository outranks every failure, and only the breakage class outranks it")
func theUnreadableRepositoryStandsInTheBreakageClass() {
let move = OneShotBanner(error: error(.move(title: "Fix login")))
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [move],
losses: [],
suspension: nil,
operations: [],
repositoryUnreadable: true
)
#expect(rows.map(\.id) == ["repository-unreadable", "one-shot:\(move.id.uuidString)"])
#expect(rows.first?.tone == .error, "the ruling's word is breakage, and breakage is an error")
}
@Test("A readable repository contributes no row at all")
func aReadableRepositoryIsSilent() {
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [],
losses: [],
suspension: nil,
operations: [],
repositoryUnreadable: false
)
#expect(rows.isEmpty)
}
@Test("Both failure shapes share one rank, interleaved by recency")
func theFailureRankHoldsBothShapes() {
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
// banner surface, settled 2026-07-31): the two shapes are one precedence class, so recency
// not vocabulary decides which of them a user reads first.
let oldMove = OneShotBanner(error: error(.move(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
let newMove = OneShotBanner(error: error(.move(title: "New")), occurredAt: Date(timeIntervalSince1970: 3))
let oldSwitch = GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten",
occurredAt: Date(timeIntervalSince1970: 2)
)
let newUndo = GitFailureBanner(
operation: .undo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 4)
)
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [oldMove, newMove],
losses: [LossBanner(message: "Folders can't be attached — 1 skipped")],
suspension: nil,
operations: [],
gitFailures: [oldSwitch, newUndo]
)
#expect(rows.map(\.id).prefix(4) == [
"git-failure:\(newUndo.id.uuidString)",
"one-shot:\(newMove.id.uuidString)",
"git-failure:\(oldSwitch.id.uuidString)",
"one-shot:\(oldMove.id.uuidString)",
])
#expect(rows.map(\.tone) == [.error, .error, .error, .error, .warning],
"and every one of them is a failure, above the warning-tone loss row")
}
@Test("A git failure outranks a loss row however much older it is — the compromise is retired")
func aGitFailureOutranksALossRow() {
// The shipped build posted these as loss rows, which put a failed Z *below* a folder-drop
// notice and painted it warning-tone. Both halves of that are retired (settled 2026-07-31).
let ancient = GitFailureBanner(
operation: .redo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 1)
)
let fresh = LossBanner(message: "Folders can't be attached — 2 skipped", occurredAt: Date(timeIntervalSince1970: 900))
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [fresh], suspension: nil, operations: [],
gitFailures: [ancient]
)
#expect(rows.map(\.id) == ["git-failure:\(ancient.id.uuidString)", "loss:\(fresh.id.uuidString)"])
#expect(rows.map(\.tone) == [.error, .warning])
}
@Test("An attachment failure ranks below other one-shots even when it is newer")
func attachmentFailuresRankLast() {
let attachment = OneShotBanner(
@@ -400,40 +294,14 @@ struct BannerCenterLifecycleTests {
#expect(center.losses.count == 1, "a loss survives everything except its own dismissal")
}
@Test("A git failure dismisses individually and is untimed — the one-shot's lifecycle exactly")
func gitFailuresDismissByIDAndNeverExpire() throws {
let center = BannerCenter()
center.postGitFailure(.undo, reason: "the repository is locked")
center.postGitFailure(.branchSwitch, reason: "your local changes would be overwritten")
#expect(center.gitFailures.count == 2)
#expect(center.gitFailures.map(\.operation) == [.branchSwitch, .undo], "newest first on insertion")
let doomed = try #require(center.gitFailures.first)
center.dismiss(doomed.id)
#expect(center.gitFailures.count == 1)
#expect(center.gitFailures.first?.id != doomed.id, "dismissing one must not take its neighbour")
// No timer, no auto-expiry: an error never evaporates unread, whichever vocabulary raised it.
let id = center.beginOperation(label: "Switching to 'main'…", cancel: nil)
center.endOperation(id)
center.suspendHistory(reason: "disk full")
center.clearHistorySuspension()
#expect(center.gitFailures.count == 1, "a failure survives everything except its own dismissal")
// And it is a *failure*, so nothing about it lands in the loss class.
#expect(center.losses.isEmpty)
#expect(center.oneShots.isEmpty)
}
@Test("Dismissing all dismissable rows clears losses along with both failure shapes and signposts")
@Test("Dismissing all dismissable rows clears losses along with the failures and signposts")
func dismissAllClearsLosses() {
let center = BannerCenter()
center.postLoss("Pasted 'Fix login' without its 3 attachments")
center.postGitFailure(.redo, reason: "the repository is locked")
center.postSignpost("This card changed on the remote")
center.dismissAllDismissableRows()
#expect(center.losses.isEmpty)
#expect(center.gitFailures.isEmpty)
#expect(center.signposts.isEmpty)
}
@Test("postSkippedFolders no-ops when nothing was skipped")
@@ -471,51 +339,6 @@ struct BannerCenterLifecycleTests {
).isEmpty)
}
/// **The corrupt-`.git` loud failure** (06-history-undo.md Rules, ruled 2026-07-31): the row
/// is raised at detection, stands with no dismiss, and *heals* "the banner clears when a later
/// open or reload finds the repo readable".
@Test("The unreadable repository is a standing condition that heals, never a dismissable row")
func theUnreadableRepositoryIsAHealingCondition() throws {
let center = BannerCenter()
#expect(!center.isRepositoryUnreadable)
center.raiseRepositoryUnreadable()
#expect(center.isRepositoryUnreadable)
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
repositoryUnreadable: center.isRepositoryUnreadable
)
#expect(rows.count == 1)
#expect(rows[0].tone == .error)
#expect(rows[0].dismissID == nil, "a condition is never dismissed while it is still true")
// 06's own sentence, verbatim the three clauses being what is wrong, what it costs, and
// the promise that makes waiting safe.
#expect(rows[0].headline
== "This board's git repository can't be read — history is paused; Lanework leaves the repository untouched")
#expect(rows[0].headline == BannerCenter.repositoryUnreadableMessage)
center.clearRepositoryUnreadable()
#expect(!center.isRepositoryUnreadable)
#expect(BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
repositoryUnreadable: center.isRepositoryUnreadable
).isEmpty)
}
@Test("Raising and clearing are idempotent — a re-read that confirms the condition changes nothing")
func raisingTheUnreadableRepositoryIsIdempotent() {
let center = BannerCenter()
center.raiseRepositoryUnreadable()
center.raiseRepositoryUnreadable()
#expect(center.isRepositoryUnreadable)
center.clearRepositoryUnreadable()
center.clearRepositoryUnreadable()
#expect(!center.isRepositoryUnreadable)
}
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
func resuspendingKeepsTheClock() throws {
let center = BannerCenter()
@@ -544,7 +367,7 @@ struct BannerCenterLifecycleTests {
Issue.record("expected an in-progress row")
return
}
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
#expect(!operation.isCancelable, "an operation that cannot be abandoned gets no Cancel — settled")
center.endOperation(id)
rows = BannerCenter.rows(
@@ -651,9 +474,9 @@ struct BannerRowControlsTests {
#expect(cancelled.value)
}
@Test("A git bracket's row offers no control at all — no Cancel, nothing to dismiss")
@Test("An uncancelable bracket's row offers no control at all — no Cancel, nothing to dismiss")
func uncancelableInProgressRowsOfferNothing() {
let row = BannerRow.inProgress(InProgressOperation(label: "Pulling…"))
let row = BannerRow.inProgress(InProgressOperation(label: "Rebuilding…"))
#expect(row.controls.isEmpty)
}
@@ -664,11 +487,8 @@ struct BannerRowControlsTests {
let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")
let gitFailure = GitFailureBanner(operation: .undo, reason: "the repository is locked")
for (row, id) in [
(BannerRow.oneShot(banner), banner.id),
(BannerRow.gitFailure(gitFailure), gitFailure.id),
(BannerRow.loss(loss), loss.id),
(BannerRow.signpost(signpost), signpost.id),
] {
@@ -688,8 +508,7 @@ struct BannerRowControlsTests {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.repositoryUnreadable,
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
.historySuspended(HistorySuspension(reason: "the volume is full")),
]
for row in rows {
@@ -706,10 +525,9 @@ struct BannerRowControlsTests {
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
.historySuspended(HistorySuspension(reason: "disk full")),
.inProgress(InProgressOperation(label: "Pulling…")),
.inProgress(InProgressOperation(label: "Duplicating…")),
.signpost(InfoSignpost(message: "This card changed on the remote")),
]
@@ -797,53 +615,6 @@ struct BannerCenterPhrasingTests {
#expect(uneditable.contains("frontmatter"))
}
@Test("Every git operation names itself in the user's words, with the error as the tail")
func everyGitOperationSaysSomethingDistinct() {
// The vocabulary is closed and the sentences are here, not at the call sites (02 § The
// banner surface, settled 2026-07-31: "the operation named in the user's words plus the
// underlying error, phrasing still BannerCenter's"). `CaseIterable` is what keeps this test
// honest when pro-m2 adds pull and push.
let headlines = GitOperation.allCases.map {
BannerCenter.headline(for: GitFailureBanner(operation: $0, reason: "the repository is locked"))
}
for (operation, headline) in zip(GitOperation.allCases, headlines) {
#expect(!headline.isEmpty, "\(operation) has no headline")
#expect(headline.hasSuffix(" — the repository is locked"), "\(operation) drops the underlying error")
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
}
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
}
@Test("The git failure's sentences are the ruling's own")
func gitFailureSentencesArePinned() {
// Pinned as literals, unlike most phrasing here, because 02 wrote these two shapes by hand
// and the third is their mirror: the undo pair names the command the user pressed, the
// switch names the control they used.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: "the repository is locked"))
== "Undo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .redo, reason: "the repository is locked"))
== "Redo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten"
)) == "Couldn't switch branches — your local changes would be overwritten")
// The tail is trimmed like every other diagnostic tail, and an absent one leaves the action
// clause alone rather than trailing a dash into nothing.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " the disk is full. "))
== "Undo failed — the disk is full")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " ")) == "Undo failed")
}
@Test("The restore pair maps from the direction the provider crossed in")
func restoreOperationsMapFromDirection() {
// The provider knows which key was pressed and nothing else about banners; this is the whole
// of the translation, kept in one place so no wiring can get it backwards.
#expect(GitOperation.restore(.undo) == .undo)
#expect(GitOperation.restore(.redo) == .redo)
}
@Test("Every lock reason says what is wrong and that the view is still the last good one")
func lockHeadlinesReassure() {
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot]
@@ -1004,52 +775,19 @@ struct BannerCenterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.enterUnwritableLock(.permissionDenied)
store.banners.postGitFailure(.undo, reason: "the working tree is locked")
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
store.banners.postLoss("Pasted 'Fix login' without its 3 attachments")
store.banners.suspendHistory(reason: "the disk is full")
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
store.banners.postSignpost("This card changed on the remote")
// The git failure posts before the write failure, so recency (and the tie rule alike)
// puts the write one-shot first within the shared failure rank.
#expect(store.bannerRows.map(\.id) == [
"operation:\(store.banners.operations[0].id.uuidString)",
"read-only-lock",
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
"git-failure:\(store.banners.gitFailures[0].id.uuidString)",
"loss:\(store.banners.losses[0].id.uuidString)",
"history-suspension",
"signpost:\(store.banners.signposts[0].id.uuidString)",
])
}
/// **The row the git state raises, through the store** (06-history-undo.md Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31) raised and healed by
/// `noteRepositoryUnreadable(_:)`, which is the seam `AppModel.beginSession` wires the
/// committer's pause transitions to, and **announced** both ways per 10-accessibility.md.
@Test("The unreadable repository stands on the strip and is spoken when it appears and clears")
func theUnreadableRepositoryRowIsRaisedAndSpoken() throws {
let fixture = try makeBoardWithUneditableLane()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
var spoken: [String] = []
store.announce = { if let line = $0 { spoken.append(line) } }
store.noteRepositoryUnreadable(true)
#expect(store.bannerRows.map(\.id) == ["repository-unreadable"])
#expect(store.bannerRows[0].headline == BannerCenter.repositoryUnreadableMessage)
#expect(spoken == ["Error: \(BannerCenter.repositoryUnreadableMessage)"],
"a standing banner is announced when it appears — the row's own sentence, tone first")
// The 15 s re-read confirming what is already standing must not say it again.
store.noteRepositoryUnreadable(true)
#expect(spoken.count == 1)
store.noteRepositoryUnreadable(false)
#expect(store.bannerRows.isEmpty)
#expect(spoken.last == "History is recording again")
}
}
-44
View File
@@ -441,50 +441,6 @@ struct BoardAnnouncerSpeechTests {
)
}
/// **The corrupt-`.git` loud failure, spoken** (06-history-undo.md Rules, ruled 2026-07-31:
/// "announced per 10-accessibility.md"). It ranks last of the raised conditions, matching the
/// strip's own precedence: the two above it describe the board's files, this one the history
/// over them.
@Test("The unreadable repository announces on arrival, under the conditions about the files")
func raisedRepositoryUnreadable() {
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableAfter = true
#expect(
BoardAnnouncer.speech(for: facts)
== AccessibilityPhrases.bannerLabel(
tone: .error,
headline: BannerCenter.repositoryUnreadableMessage
)
)
// A breakage standing beside it leads: the board on screen not being the board on disk is
// the more consequential of the two.
facts.breakageAfter = breakage()
#expect(
BoardAnnouncer.speech(for: facts)
== AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage()))
)
}
@Test("A repository that heals is announced too, as the regained capability")
func clearedRepositoryUnreadable() {
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableBefore = true
facts.repositoryUnreadableAfter = false
#expect(BoardAnnouncer.speech(for: facts) == "History is recording again")
}
@Test("A standing unreadable repository is not repeated on every re-read")
func standingRepositoryUnreadableIsNotRepeated() {
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableBefore = true
facts.repositoryUnreadableAfter = true
#expect(BoardAnnouncer.speech(for: facts) == nil, "the 15 s re-read confirms; it does not narrate")
}
@Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
func clearedLock() {
var facts = BoardAnnouncer.ReloadFacts()
-115
View File
@@ -1,5 +1,4 @@
import Foundation
import SwiftGitX
import Testing
@testable import Kanban
@@ -653,117 +652,3 @@ struct BoardDecisionSurfaceAttendanceTests {
#expect(second.access == nil)
}
}
// MARK: - The Pro repair commit
/// HEAD's first-parent ancestry, newest first read through SwiftGitX, never through the committer
/// that made the commits (`AutoCommitTests`' rule, kept: nothing here shells out to `git`).
private func repairHistory(at boardRoot: URL, limit: Int = 8) throws -> [(subject: String, authorName: String, authorEmail: String, committerName: String)] {
let repository = try Repository.open(at: boardRoot)
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
var records: [(String, String, String, String)] = []
var current: Commit? = tip
while let commit = current, records.count < limit {
records.append((commit.summary, commit.author.name, commit.author.email, commit.committer.name))
current = (try? commit.parents)?.first
}
return records
}
@MainActor
@Suite("Decision surface ▸ the Pro repair commit")
struct BoardDecisionSurfaceRepairCommitTests {
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home, and which reads as Pro `AutoCommitCompositionRootTests`' fixture.
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("DecisionRepairCommit-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
model.currentTier = { .pro }
return (model, { try? FileManager.default.removeItem(at: folder) })
}
/// **A repaired Pro board's first flush is one heal commit, authored by the integrity identity**
/// (01-storage-format.md § Malformed input: "On Pro boards the repairs drop heal-marked receipts
/// and commit separately as one repair commit, never folded into anyone else's work";
/// 06-history-undo.md Commit messages Healing mutations commit separately).
///
/// The whole chain is exercised end to end, because every link in it can fail silently and the
/// symptom is identical each time a commit blaming the outside world for the app's own repair:
///
/// 1. The surface's default resolution is the minted repair.
/// 2. `BoardRepairRun` writes it store-lessly and marks every receipt in its own ledger.
/// 3. The store built by the following walk **adopts** that ledger (`EchoLedger.adopt`)
/// before `beginSession`, which is where Pro's committer is composed and started.
/// 4. `GitAutoCommitter.start()` harvests, so the debounce it arms can see receipts that were
/// dropped before any write bracket of this session existed.
/// 5. `CommitAttribution.split` sorts the repaired path into the heal class, and the heal class
/// is authored `Lanework Integrity <integrity@lanework.invalid>` with the user as committer.
@Test("A repaired board under Pro + git produces a separate heal commit")
func aRepairCommitsAsTheIntegrityIdentity() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// A board whose root is missing `schema` the minted stamp's own case. The lane is here so
// the repository has an ordinary tree around the file being repaired.
try fixture.item("", "---\ntitle: Needs A Stamp\ncreated: 2026-01-01T09:00:00Z\n---\nBoard.\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n")
let (model, tearDown) = try makeModel()
defer { tearDown() }
// The repository, with everything as it stands committed including the broken root, which is
// what makes the repair a real change rather than a fresh file.
let git = HistoryStore.compose(boardRoot: fixture.root, ledger: EchoLedger())
#expect(await git.addGit())
let commitsBefore = try repairHistory(at: fixture.root).count
git.stopAutoCommit()
// The surface, exactly as the window builds it.
let surface = BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root)
#expect(surface.canRepairAndOpen, "a lone missing root schema is a minted repair, preselected")
// Repair and Open's write half.
let outcome = BoardRepairRun.apply(surface.plannedRepairs, boardRoot: fixture.root)
#expect(outcome.failure == nil)
// The walk that follows, and the store it builds. Built directly rather than through the
// registry so this case is about the repair's commit and not about the open's *other* heals
// (the agent guide, the `.gitignore` seed), which the registry's acquire also fires.
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: surface.skipSet)
let store = BoardStore(rootURL: fixture.root, loaded: result, skipping: surface.skipSet)
// The adoption, before the session composes the committer.
store.echoes.adopt(outcome.ledger)
let ref = BoardWindowRef(url: fixture.root)
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
let committer = try #require(model.session(for: ref)?.git?.committer)
// The debounce `start()` armed is not what this asserts; the explicit flush is.
committer.stop()
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotDeadline = .milliseconds(50)
committer.coveringSnapshotPollInterval = .milliseconds(5)
await committer.flushNow()
let log = try repairHistory(at: fixture.root)
#expect(log.count == commitsBefore + 1, "one repair commit, never folded and never split further")
let head = try #require(log.first)
#expect(head.authorName == CommitAttribution.integrityAuthorName)
#expect(head.authorEmail == CommitAttribution.integrityAuthorEmail)
#expect(
head.authorEmail != CommitAttribution.externalAuthorEmail,
"the app's own repair must never be blamed on the outside world"
)
// "the committer stays the user (the recorded-by convention)".
#expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name)
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "the flush leaves nothing dirty")
}
}
+3 -18
View File
@@ -803,32 +803,17 @@ struct BoardStoreReloadMemoTests {
#expect(store.landedReloads == 2)
}
@Test("A value-equal reload still lands for the auto-commit seam and the covering gate")
@Test("A value-equal reload still lands for the covering gate")
func aValueEqualReloadStillLands() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let landings = LandingCount()
store.commitSeam = HistoryCommitSeam(
willWrite: {},
writeBracketDidClose: {},
reloadDidLand: { _ in landings.value += 1 }
)
_ = await reload(store)
// The covering gate counts landings, not assignments (`GitAutoCommitter.landedReloads`), and
// the commit seam is armed by every landing whether or not the snapshot moved "a landing
// that finds nothing to commit is the silent no-op, not a wasted trip".
#expect(landings.value == 1)
// The covering gate counts landings, not assignments: a walk covers what it walked whether or
// not the tree turned out to differ.
#expect(store.landedReloads == 1)
#expect(store.snapshotGeneration == 0)
}
}
/// What the reload path told the history seam a box, because `HistoryCommitSeam` is a struct of
/// closures and a captured `var` cannot be read back after the reload has landed.
@MainActor
private final class LandingCount {
var value = 0
}
-570
View File
@@ -1,570 +0,0 @@
import Foundation
import SwiftGitX
import Testing
@testable import Kanban
/// **The card window's session as the commit unit** (06-history-undo.md Rules Auto-commit,
/// widened 2026-07-31; 13-native-undo.md Interaction with the trash; 05-card-window.md The
/// comments column).
///
/// > Board history sees **card-window sessions, not gestures** while a card's window is open,
/// > everything happening inside it stays **uncommitted**, and the committer **stages around the
/// > whole open card folder** **window close flushes the session as one commit**.
///
/// The claims here are all about *when* a commit exists, which is exactly the class of thing that
/// looks right in a running app and is wrong: a comment post that quietly landed its own commit, a
/// session's body arriving under `Lanework External` because an interim flush spent its receipt, a
/// `comments/.trash/` purge that committed separately from the delete it belongs to. So every test
/// runs a **real** repository over bundled libgit2, drives the window through the same seams
/// `CardWindowHost` wires, and reads every commit back through libgit2 rather than through the engine
/// that made it. Nothing shells out to `git` (`AutoCommitTests`' rule, kept).
// MARK: - Fixtures
private let cardID = ItemID(rawValue: Ident.card1)
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
private let earlierComment = CommentIdent.one
/// One card window on a Pro git board the store, the committer, and the session, wired to each
/// other exactly as `AppModel.beginSession` and `CardWindowHost` wire them.
///
/// The stage-around is opened and closed through `open()` / `close()` below, which spell what
/// `AppModel.setCardSession(_:for:)` does; that method's *own* wiring that a card window's
/// registration is what opens it is pinned separately in `CardSessionStagingWiringTests`, over a
/// real `AppModel`.
@MainActor
private final class Window {
let fixture: WriterFixture
let store: BoardStore
let git: HistoryStore
let committer: GitAutoCommitter
let session = CardWindowSession()
/// Commits the board already had when the window opened every assertion here is a delta, so a
/// board-open heal landing in the setup cannot be mistaken for a session's commit.
private(set) var baseline = 0
private var token: UUID?
init(fixture: WriterFixture, store: BoardStore, git: HistoryStore, committer: GitAutoCommitter) {
self.fixture = fixture
self.store = store
self.git = git
self.committer = committer
}
var comments: CardComments { session.comments }
var body: CardBodyEditSession { session.body }
/// Commits landed since the window opened.
var commits: Int { committer.commitCount - baseline }
func recordBaseline() {
baseline = committer.commitCount
}
/// The window joins its board `AppModel.registerCardWindow`, whose one git consequence is this
/// exclusion.
func open() {
let token = UUID()
self.token = token
committer.beginCardSession(token) { [weak store] in
guard let store,
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
return path.folder(under: store.rootURL)
}
session.comments.open()
}
/// The close, in the order production runs it: the session's own writes land, *then* the folder is
/// released, *then* the store settles, *then* the pipeline flushes (`CardWindowHost.finish`,
/// `CloseFlushCoordinator.flushPendingWork` "the store's pipeline, then the editor saves, then
/// the pending commit").
///
/// The quiescence matters to the *message*, not to the commit: the composer diffs the store's
/// snapshot against HEAD's tree, so a flush that raced the session's own reload would describe the
/// window by its comment events alone. Production gets the same ordering from the committer's
/// two-second debounce outliving the watcher's.
func close() async {
await session.endSession()
if let token { committer.endCardSession(token) }
token = nil
await settle()
await committer.flushNow()
}
/// Brings the store's snapshot up to what the session wrote, then waits for it to settle the
/// close flush's own first step (`CloseFlushCoordinator.flushPendingWork`: "the store's pipeline,
/// then the editor saves, then the pending commit").
///
/// The reload is delivered by hand because this store has no watcher: the registry is what wires
/// `FolderWatcher` to `handleWatcherEvent(_:)` in production, and a suite that acquired one would
/// be testing FSEvents. What matters here is the *ordering* the composer diffs the store's
/// snapshot against HEAD's tree, so a flush that ran ahead of the session's own reload would
/// describe the window by its comment events alone and lose the body edit.
func settle() async {
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
}
/// The half of the close that happens before the release used to prove the release is what
/// unblocks the commit rather than the passage of time.
func endSessionOnly() async {
await session.endSession()
}
func releaseAndFlush() async {
if let token { committer.endCardSession(token) }
token = nil
await settle()
await committer.flushNow()
}
}
/// A board with a card, one already-posted comment, a repository, and a root commit that has all of
/// it the state a card window opens over.
@MainActor
private func makeWindow() async throws -> Window {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
try fixture.item(commentPath(earlierComment, inCard: cardPath), commentText(body: "posted earlier\n"))
// The store first, and settled, so the board-open heals (the agent guide) are on disk *before*
// the root commit rather than arriving as a mystery commit in the middle of a test.
let store = try BoardStore(rootURL: fixture.root)
await store.awaitQuiescence()
let git = HistoryStore.compose(boardRoot: fixture.root, ledger: store.echoes)
#expect(await git.addGit())
let committer = try #require(git.committer)
// Long enough that **only** an explicit `flushNow()` commits: "zero commits until close" has to be
// a fact about the stage-around, not about a debounce that had not fired yet.
committer.debounceInterval = .seconds(60)
committer.lockRetryDelay = .milliseconds(5)
committer.currentSnapshot = { [weak store] in store?.snapshot }
store.commitSeam = .binding(to: committer)
let window = Window(fixture: fixture, store: store, git: git, committer: committer)
// The window's own seams, `CardWindowHost.configureSession`'s three lines.
CardWindowHost.configureUndo(window.session, store: store, cardID: cardID)
CardWindowHost.configureComments(window.session.comments, store: store, cardID: cardID, on: window.session.undo)
window.session.comments.isEditable = true
window.session.comments.cardFolder = fixture.url(cardPath)
window.session.body.save = { [weak store] text in
store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
}
window.session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
// One reconciling reload lands the board-open heals (the agent guide), and whatever the setup
// left dirty commits now so every assertion below is about the session and nothing else.
await window.settle()
await committer.flushNow()
#expect(isClean(at: fixture.root), "the window opens over a settled tree")
window.recordBaseline()
return window
}
@MainActor
private func editBody(_ window: Window, to text: String) {
window.body.beginEditSession()
window.body.edited(text)
window.body.endEditSession()
}
@MainActor
@discardableResult
private func postComment(_ window: Window, body: String) -> ItemID? {
window.comments.composer.edited(body)
_ = window.comments.composer.flush()
return window.comments.composer.postNow()
}
// MARK: Reading the repository back
private struct Landed: Equatable {
let subject: String
let message: String
let authorEmail: String
}
/// HEAD's first-parent ancestry, newest first read through SwiftGitX, never through the committer.
private func landed(at boardRoot: URL, limit: Int = 32) throws -> [Landed] {
let repository = try Repository.open(at: boardRoot)
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
var records: [Landed] = []
var current: Commit? = tip
while let commit = current, records.count < limit {
records.append(Landed(
subject: commit.summary,
message: commit.message,
authorEmail: commit.author.email
))
current = (try? commit.parents)?.first
}
return records
}
private func isClean(at boardRoot: URL) -> Bool {
GitCommitOperation.changedPaths(at: boardRoot).isEmpty
}
private func tracked(at boardRoot: URL) -> Set<String> {
Set(GitRepository.trackedPaths(at: boardRoot))
}
// MARK: - The close flush
@MainActor
@Suite("Card session commits ▸ the close flush")
struct CardSessionCloseFlushTests {
@Test("A body edit, a comment post and a comment delete commit nothing until the window closes")
func theSessionIsTheCommitUnit() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
window.open()
editBody(window, to: "Edited in the window.\n")
let posted = try #require(postComment(window, body: "A remark.\n"))
#expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true)
// Not "the debounce has not fired": the flush runs, sees the whole card folder staged around,
// and commits nothing.
await window.committer.flushNow()
#expect(window.commits == 0, "no gesture inside an open card window is a commit")
#expect(!isClean(at: window.fixture.root), "the session's writes are on disk, uncommitted")
#expect(window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)"),
"and the purge has not run: it belongs inside the close flush")
await window.close()
#expect(window.commits == 1, "window close flushes the session as one commit")
#expect(isClean(at: window.fixture.root))
// One commit, three changes: "'Update card 'Fix login''-shaped, the composer folding the
// card-scoped diff, body bullets carrying the events" (06 Rules Auto-commit) the model
// event keeps the subject, the thread rides in the body.
let head = try #require(try landed(at: window.fixture.root).first)
#expect(head.subject == "Edit card 'Fix login'")
// A set, because the thread's two events sort by comment id and the posted one's is minted
// fresh every run the *events* are the claim, not their order among themselves.
#expect(Set(head.message.split(separator: "\n").filter { $0.hasPrefix("- ") }) == [
"- Edit card 'Fix login'",
"- Comment on 'Fix login'",
"- Delete comment on 'Fix login'",
])
let paths = tracked(at: window.fixture.root)
#expect(paths.contains("\(cardPath)/comments/\(posted.rawValue)/index.md"),
"the post is in the commit")
#expect(!paths.contains("\(cardPath)/comments/\(earlierComment)/index.md"),
"so is the delete")
#expect(!paths.contains { $0.hasPrefix("\(cardPath)/comments/.trash/") },
"and the purge — delete plus purge net to a removal (13 ▸ Interaction with the trash)")
#expect(!window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)"))
}
@Test("The release is what unblocks the commit, not the end of the session's writes")
func theReleaseIsTheGate() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
window.open()
editBody(window, to: "Edited in the window.\n")
// Everything the session owed disk is written, and the folder is still held.
await window.endSessionOnly()
await window.committer.flushNow()
#expect(window.commits == 0)
await window.releaseAndFlush()
#expect(window.commits == 1)
}
@Test("A session with no net change registers no commit at all")
func anEmptySessionCommitsNothing() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
window.open()
await window.close()
#expect(window.commits == 0, "a window that was only read is not an event")
#expect(window.committer.lastFailure == nil, "an empty window is a no-op, never a failure")
#expect(isClean(at: window.fixture.root))
}
@Test("A session mixing two model events keeps the card's name in the subject")
func aMixedSessionNamesItsCard() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
window.open()
// Two *model* kinds an edit and a restyle so no single verb can head the window. 06's
// retired "Update board" is exactly the subject that could not say which card this was.
editBody(window, to: "Edited in the window.\n")
window.store.applyStyle(to: .items([cardID]), background: .set("#334455"), on: window.session.undo)
postComment(window, body: "A remark.\n")
await window.close()
#expect(window.commits == 1)
let head = try #require(try landed(at: window.fixture.root).first)
#expect(head.subject == "Mixed update — 3 changes to card 'Fix login'")
}
@Test("A draft the session never posted rides the close flush too, as one commit")
func theDraftRidesTheClose() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
window.open()
window.comments.composer.edited("half a thought\n")
_ = window.comments.composer.flush()
await window.committer.flushNow()
#expect(window.commits == 0, "the draft-save cadence never becomes a commit stream")
await window.close()
#expect(window.commits == 1)
#expect(tracked(at: window.fixture.root).contains("\(cardPath)/comments/.draft/index.md"))
}
}
// MARK: - Board-side work, and the split
@MainActor
@Suite("Card session commits ▸ what an open window does not hold back")
struct CardSessionInterimCommitTests {
@Test("Board-side changes commit normally while a card window is open")
func theRestOfTheBoardIsUnaffected() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
window.open()
editBody(window, to: "Edited in the window.\n")
try window.fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
window.committer.noteReloadLanded(sawForeignChange: true)
await window.committer.flushNow()
#expect(window.commits == 1, "the board's own change is not held by somebody's card window")
#expect(tracked(at: window.fixture.root).contains("\(Ident.lane2)/\(BoardLoader.indexFileName)"))
#expect(!isClean(at: window.fixture.root), "and the session folder is still held back")
await window.close()
#expect(window.commits == 2)
#expect(isClean(at: window.fixture.root))
}
@Test("A held window mixing foreign work with the session's splits into two commits at close")
func theTwoCommitSplitSurvivesTheHeldWindow() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
window.open()
// The app's own gesture, vouched for by a receipt in the store's ledger.
postComment(window, body: "Mine.\n")
// Somebody else's, inside the same card folder an agent dropping a file the app never
// witnessed. It is held back by the same exclusion, so the close flush is the first moment it
// can land, and the split is what keeps it out of the user's commit.
try window.fixture.file("\(cardPath)/attachments/notes.txt", Data("theirs\n".utf8))
window.committer.noteReloadLanded(sawForeignChange: true)
// An interim flush that commits nothing must not spend the session's receipts this is the
// line the whole split depends on.
await window.committer.flushNow()
#expect(window.commits == 0)
await window.close()
#expect(window.commits == 2, "foreign and app-mediated never mix in one commit")
let trail = try landed(at: window.fixture.root)
let user = GitCommitOperation.userIdentity(at: window.fixture.root).email
#expect(trail.first?.authorEmail == user, "the user's overwrite lands after the foreign version")
#expect(trail.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail)
#expect(trail.first?.subject.contains("Comment on 'Fix login'") == true)
#expect(isClean(at: window.fixture.root))
}
@Test("A second card window's session is held independently of the first's")
func sessionsAreHeldPerCard() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
try window.fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
window.committer.noteReloadLanded(sawForeignChange: true)
await window.committer.flushNow()
window.recordBaseline()
window.open()
let other = UUID()
window.committer.beginCardSession(other) { window.fixture.url("\(Ident.lane1)/\(Ident.card2)") }
editBody(window, to: "Edited in the window.\n")
try window.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/theirs.txt", Data("typing\n".utf8))
window.committer.noteReloadLanded(sawForeignChange: true)
await window.committer.flushNow()
#expect(window.commits == 0, "two held folders, nothing to commit")
await window.close()
#expect(window.commits == 1, "the first window's session, and only it")
#expect(!isClean(at: window.fixture.root), "the second card is still somebody's open session")
}
}
// MARK: - Crossing the session commit
@MainActor
@Suite("Card session commits ▸ undo crosses the session")
struct CardSessionRestoreTests {
@Test("Board ⌘Z after the close crosses the session commit and restores the deleted comment")
func theSessionCommitIsOneUndoStep() async throws {
let window = try await makeWindow()
defer { window.fixture.tearDown() }
let provider = GitHistoryProvider(boardRoot: window.fixture.root)
provider.flushPendingCommit = { [weak committer = window.committer] in await committer?.flushNow() }
provider.isHeld = { [weak committer = window.committer] in committer?.pause != nil }
provider.suspendCommitting = { [weak committer = window.committer] in committer?.stop() }
provider.resumeCommitting = { [weak committer = window.committer] in committer?.start() }
window.committer.reportLanded = { [weak provider] landed in provider?.noteLanded(landed) }
await provider.reseed()
window.open()
editBody(window, to: "Edited in the window.\n")
postComment(window, body: "A remark.\n")
#expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true)
await window.close()
await provider.settled()
#expect(window.commits == 1)
#expect(provider.canUndo, "the close commit is an ordinary step on the board's stack")
await provider.cross(.undo)
// A forward restore, never a rewrite (14-git-operations.md The forward-restore model).
let trail = try landed(at: window.fixture.root)
#expect(trail.first?.subject.hasPrefix("Undo: ") == true)
#expect(try FrontmatterDocument.parse(window.fixture.indexText(cardPath)).body
!= "Edited in the window.\n", "the session's body edit is undone")
#expect(window.fixture.exists("\(cardPath)/comments/\(earlierComment)"),
"and the purged comment came back out of history — the whole session, in one step")
}
}
// MARK: - The staging wiring
/// What `AppModel` itself owes the rule: which moment opens the exclusion, which closes it, and the
/// settle step's release. Over a real model and a real board session, because every one of these is a
/// claim about production wiring rather than about the committer's own grammar.
@MainActor
@Suite("Card session commits ▸ the staging wiring")
struct CardSessionStagingWiringTests {
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("CardSessionCommitTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
model.currentTier = { .pro }
return (model, { try? FileManager.default.removeItem(at: folder) })
}
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return ref
}
/// A Pro git board, opened through the model so the committer under test is the one production
/// composes, ledger and all.
private func makeBoard(_ model: AppModel) async throws -> (fixture: WriterFixture, ref: BoardWindowRef) {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit())
return (fixture, try openBoard(model, at: fixture.root))
}
@Test("Registering a card window opens the stage-around; unregistering releases it")
func theWindowIsTheUnit() async throws {
let (model, tearDown) = try makeModel()
defer { tearDown() }
let (fixture, ref) = try await makeBoard(model)
defer { fixture.tearDown() }
let committer = try #require(model.session(for: ref)?.git?.committer)
let card = CardWindowRef(board: ref, cardID: cardID)
model.registerCardWindow(card, session: CardWindowSession())
#expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1],
"the whole open card folder, from the moment the window joins its board")
model.unregisterCardWindow(card)
#expect(committer.stagedAroundFolders.isEmpty)
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
}
@Test("The settle step releases every open session's staging, and the operation's end restores it")
func theSettleReleasesTheStaging() async throws {
let (model, tearDown) = try makeModel()
defer { tearDown() }
let (fixture, ref) = try await makeBoard(model)
defer { fixture.tearDown() }
let committer = try #require(model.session(for: ref)?.git?.committer)
let switcher = try #require(model.session(for: ref)?.git?.switcher)
let card = CardWindowRef(board: ref, cardID: cardID)
model.registerCardWindow(card, session: CardWindowSession())
#expect(!committer.stagedAroundFolders.isEmpty)
// A window that is merely *open* answers `needsSettling` with `false` no dirty buffer, no
// raw source so no modal is presented and the gate proceeds. Its folder is still held, and
// a checkout over a held folder is the dirty tree the settle exists to prevent.
#expect(await switcher.settleSessions?() == .proceed)
#expect(committer.stagedAroundFolders.isEmpty,
"the widened stage-around releases at settle, modal or no modal")
switcher.resumeCommitting?()
#expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1],
"and the still-open window is a session again on the other side")
model.unregisterCardWindow(card)
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
}
@Test("The board's close flush releases each session before the pipeline flushes")
func theCloseFlushReleasesBeforeItCommits() async throws {
let (model, tearDown) = try makeModel()
defer { tearDown() }
let (fixture, ref) = try await makeBoard(model)
defer { fixture.tearDown() }
let committer = try #require(model.session(for: ref)?.git?.committer)
let store = try #require(model.session(for: ref)?.store)
let card = CardWindowRef(board: ref, cardID: cardID)
model.registerCardWindow(card, session: CardWindowSession())
// The session's uncommitted work the state a quit must not leave behind.
_ = store.writeCardBody(inCard: cardID, body: "Typed and never committed.\n")
await committer.flushNow()
#expect(!isClean(at: fixture.root), "held, as an open window's folder should be")
await model.closeBoard(ref: ref, cause: .quit)
#expect(isClean(at: fixture.root),
"nothing settled is left uncommitted by closing (06 ▸ Rules ▸ Auto-commit)")
}
}
+6 -7
View File
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
defer { tearDown() }
let bound = FakeHistoryProvider()
model.makeHistoryProvider = { _, _ in bound }
model.makeHistoryProvider = { _ in bound }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
@@ -605,14 +605,13 @@ struct BoardSessionHistoryTests {
defer { tearDown() }
// 12-editions.md The entitlement: the tier is read at composition, once, and recorded.
// **PIVOT 2026-08-07**: it is no longer *handed* anywhere. This test used to pin the argument
// arriving at `makeHistoryProvider` (`{ _, tier, _ in }`); the parameter is gone, so what is
// pinned now is the pair of facts that replaced it the seam takes a store and a git state
// and nothing else, and the session still carries the tier for the base/Pro split yet to be
// ruled. The substrate matrix itself is `GitUndoBindingTests`'.
// **PIVOT 2026-08-07**: it is no longer *handed* anywhere, and the 2026-08-08 excision took
// the last argument beside it the seam takes a store and nothing else. What is pinned here
// is what replaced the old matrix: one call per board, and a session still carrying the tier
// for the base/Pro split yet to be ruled.
var seen = 0
model.currentTier = { .pro }
model.makeHistoryProvider = { _, _ in
model.makeHistoryProvider = { _ in
seen += 1
return NativeHistoryProvider()
}
-93
View File
@@ -623,96 +623,3 @@ struct GitPathHistoryTests {
#expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
}
}
// MARK: - Session composition
@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("HistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
return (model, { try? FileManager.default.removeItem(at: folder) })
}
@MainActor
@discardableResult
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return ref
}
@MainActor
@Suite("Board sessions ▸ the git state they compose")
struct BoardSessionGitTests {
@Test("A free-tier session composes a git state too, and detects the repository it finds")
func freeSessionsComposeAGitStateAsWell() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let (model, tearDown) = try makeModel()
defer { tearDown() }
// **PIVOT 2026-08-07** (12-editions.md). The inverse of what this test used to pin: the free
// tier carried no git state at all, reported mode `none` on this very board, and injected
// nothing into the loader. Git left the paywall, so the session composes exactly what a Pro
// session composes same call, no tier in it.
model.currentTier = { .free }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.git != nil)
#expect(session.gitMode == .git, "the mode is the disk's answer, not the tier's")
#expect(session.store.makeIdentityHistoryRanker != nil, "and the loader's rung is wired")
#expect(session.tier == .free, "the tier is still recorded — it just decides nothing here")
}
@Test("A session on a git board composes git mode and wires the loader's ranker")
func sessionsCarryTheDetectedMode() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
// A real repository, so the ranker has something to read.
let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit())
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.gitMode == .git)
let provider = try #require(session.store.makeIdentityHistoryRanker)
let ranker = try #require(provider())
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
// The provider binding arrived with the undo/redo card: a session on a git board binds the
// git substrate over exactly this mode (12-editions.md The provider seam).
#expect(session.history is GitHistoryProvider)
}
@Test("A session on a plain board is mode none and injects nothing")
func sessionsOnPlainBoardsInjectNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.gitMode == .none)
let provider = try #require(session.store.makeIdentityHistoryRanker, "the wiring is there")
#expect(provider() == nil, "and it answers nothing on a board with no repository")
}
}