An unopenable repository fails loudly — the standing row, the paused surface, the honest heal
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -256,7 +256,8 @@ public struct InProgressOperation: Identifiable, Sendable {
|
||||
/// divide into three lifecycles that the view renders differently and that the ordering rule
|
||||
/// treats as classes:
|
||||
///
|
||||
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
|
||||
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `repositoryUnreadable`,
|
||||
/// `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.
|
||||
@@ -283,6 +284,20 @@ 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
|
||||
@@ -307,6 +322,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)"
|
||||
@@ -321,7 +337,12 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
// 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).
|
||||
case .readOnlyLock, .reloadBreakage, .oneShot, .gitFailure: .error
|
||||
// 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 .historySuspended, .loss: .warning
|
||||
case .inProgress, .signpost: .info
|
||||
}
|
||||
@@ -340,6 +361,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
|
||||
@@ -358,7 +380,7 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
case let .gitFailure(banner): banner.id
|
||||
case let .loss(loss): loss.id
|
||||
case let .signpost(signpost): signpost.id
|
||||
case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
|
||||
case .readOnlyLock, .reloadBreakage, .repositoryUnreadable, .historySuspended, .inProgress: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +521,18 @@ public final class BannerCenter {
|
||||
/// The standing "history isn't advancing" condition, or `nil` when commits are landing.
|
||||
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] = []
|
||||
|
||||
@@ -810,6 +844,27 @@ public final class BannerCenter {
|
||||
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.
|
||||
@@ -889,10 +944,18 @@ public final class BannerCenter {
|
||||
/// 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.
|
||||
///
|
||||
/// `signposts` and `gitFailures` carry defaults: the first because its producer is m6's card
|
||||
/// window, the second because a center that hosts no git surface (a card window's own) can never
|
||||
/// hold one. Every other class is spelled out at every call site — `losses` included, since a
|
||||
/// Finder drop that skipped folders already posts one (`postSkippedFolders`).
|
||||
/// - **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`).
|
||||
public nonisolated static func rows(
|
||||
lock: ReadOnlyLockReason?,
|
||||
breakage: BoardLoadFailure?,
|
||||
@@ -901,7 +964,8 @@ public final class BannerCenter {
|
||||
suspension: HistorySuspension?,
|
||||
operations: [InProgressOperation],
|
||||
signposts: [InfoSignpost] = [],
|
||||
gitFailures: [GitFailureBanner] = []
|
||||
gitFailures: [GitFailureBanner] = [],
|
||||
repositoryUnreadable: Bool = false
|
||||
) -> [BannerRow] {
|
||||
var rows: [BannerRow] = []
|
||||
|
||||
@@ -913,6 +977,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(
|
||||
@@ -1447,6 +1514,23 @@ 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
|
||||
/// the diagnosis as its tail.
|
||||
|
||||
Reference in New Issue
Block a user