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
+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)