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:
2026-08-06 18:44:12 -04:00
parent 52df210284
commit 0d8ecdb78b
20 changed files with 938 additions and 42 deletions
+15
View File
@@ -908,6 +908,15 @@ public final class AppModel {
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,
@@ -933,6 +942,12 @@ public final class AppModel {
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 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
+52 -5
View File
@@ -194,6 +194,22 @@ public final class GitAutoCommitter {
@ObservationIgnored
public var reportRecovery: (@MainActor () -> Void)?
/// **The repository became unreadable, or readable again** the standing breakage banner's
/// raise and heal (06-history-undo.md Rules, the corrupt-`.git` loud failure, ruled
/// 2026-07-31: "a standing breakage-class banner at detection the banner clears when a later
/// open or reload finds the repo readable").
///
/// Called on the *transition only*, with the new answer so a board that stands unreadable for
/// an hour posts one row rather than one per 15 s re-read, and a repository repaired in a
/// terminal heals the row on the first re-read that opens it.
///
/// Separate from `reportFailure` because the two conditions are different rows saying different
/// things: a failed commit is "history stopped advancing, here is the error" (a retry away),
/// while this is "there is no repository the app can read at all". Wired by
/// `AppModel.beginSession` to `BoardStore.noteRepositoryUnreadable(_:)`.
@ObservationIgnored
public var reportRepositoryUnreadable: (@MainActor (Bool) -> Void)?
/// **What a flush landed, and which of it was heal work** the undo stack's in-session ear
/// (06-history-undo.md Rules The stack is HEAD's first-parent ancestry, live; Heal commits
/// are transparent to undo, in-session).
@@ -459,9 +475,36 @@ public final class GitAutoCommitter {
/// and it read it in order to write.
public func refreshPause() async {
let root = boardRoot
pause = await Task.detached(priority: .userInitiated) {
let read = await Task.detached(priority: .userInitiated) {
GitCommitOperation.reading(at: root).pause
}.value
setPause(read)
}
/// **The detection-time probe's answer, seeded before anything has been attempted**
/// (06-history-undo.md Rules, the corrupt-`.git` loud failure: "a standing breakage-class
/// banner **at detection**").
///
/// The engine's ordinary way of learning a pause is to try to commit and be held, which is the
/// right cadence for committing and far too late for this one: the ruling's whole point is that
/// the failure is loud at the open rather than discovered a debounce later or, worse, only in
/// the popover. `HistoryStore`'s composition probes (`GitRepository.canOpen`) and calls this.
///
/// It is deliberately the *same* state a held flush would have reached, not a parallel flag: one
/// pause, one surface, and the first re-read either confirms it or heals it.
public func noteRepositoryUnreadable() {
setPause(.unreadable)
}
/// The one place `pause` is assigned, so the raise-and-heal seam cannot be forgotten by a path
/// that sets it (`reportRepositoryUnreadable`). Fires on the transition only entering
/// `.unreadable` from anything else, or leaving it for anything else, `nil` included.
private func setPause(_ new: GitRepositoryPause?) {
let was = pause == .unreadable
pause = new
let now = new == .unreadable
guard was != now else { return }
reportRepositoryUnreadable?(now)
}
/// Whether a card window's folder is currently staged around the stage-around rule, made
@@ -881,7 +924,7 @@ public final class GitAutoCommitter {
switch outcome {
case let .committed(landed):
let oids = landed.map(\.oid)
pause = nil
setPause(nil)
lastFailure = nil
commitCount += oids.count
lastCommitOIDs = oids
@@ -899,7 +942,7 @@ public final class GitAutoCommitter {
case .nothingToCommit:
// **The happy path, not a malfunction** (06): an agent committed its own work, or the
// whole window was staged around. Silent, and the window closes either way.
pause = nil
setPause(nil)
lastFailure = nil
dropHarvestOutsideOpenSessions()
holdsForeignChanges = false
@@ -913,12 +956,16 @@ public final class GitAutoCommitter {
arm()
case let .held(reason):
pause = reason
setPause(reason)
Self.logger.notice("auto-commit held: \(reason.rawValue, privacy: .public)")
// **The standing pause's own re-read** (06 Rules Abnormal repo states, blessed
// 2026-07-31) and the mid-session healing path for the unreadable repository too: the
// watcher never delivers `.git`, so a repository repaired in a terminal has no other way
// to be noticed before the next open.
arm(after: holdRecheckInterval)
case let .failed(failure):
pause = nil
setPause(nil)
lastFailure = failure
Self.logger.error("auto-commit failed: \(failure.description, privacy: .public)")
reportFailure?(failure)
+46 -6
View File
@@ -13,9 +13,21 @@ import os
/// fact about how the next commit is shaped (`GitRepository.initialCommitSubject`), never a reason
/// to stop.
///
/// The cases are libgit2's own `git_repository_state`, which reads exactly the marker files 06
/// names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this
/// Seven of the cases are libgit2's own `git_repository_state`, which reads exactly the marker files
/// 06 names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this
/// version has no story for but must not commit over either (`REVERT_HEAD`, `BISECT_LOG`).
///
/// **The eighth is the app's own reading, and it is a pause by ruling** (06 Rules, "A `.git` that
/// isn't a valid repository still reads as git mode and fails loudly", ruled 2026-07-31): a `.git`
/// libgit2 cannot open at all is not a repository *state* there is no repository to be in one
/// but the posture it calls for is this one, verbatim: "the whole git surface paused (the
/// abnormal-states posture below)". Putting it in this vocabulary is what makes that true
/// structurally rather than by a rule somebody has to keep: every consumer of a pause already holds
/// the auto-commit debounce (`GitAutoCommitter.execute`), disables Undo/Redo and the branch controls
/// (`GitHistoryProvider.isHeld`, `GitBranchSwitcher.perform`), skips housekeeping
/// (`GitHousekeeper.runNow`), and names the state in the popover so `.unreadable` inherits all of
/// it by construction, including the standing pause's own 15 s re-read, which is what heals it
/// mid-session (`GitAutoCommitter.holdRecheckInterval`).
public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case detachedHead
case merge
@@ -25,6 +37,13 @@ public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case rebase
case applyMailbox
/// **The repository could not be opened** a corrupt `.git`, a worktree pointer aimed at
/// nothing, or a repository this engine has no support for (a SHA-256 one, 06 Repository
/// hygiene: "an adopted SHA-256 repo the engine cannot open takes the corrupt-repo loud-failure
/// path"). Never a fall to mode none: detection is presence-shaped, so the board stays in git
/// mode and this is what git mode *reads* like while the repository is unreadable.
case unreadable
/// What the popover will say **the branch-switching card's surface, phrased here** so the
/// engine-side hold and the sentence that explains it cannot drift apart (06 Rules Abnormal
/// repo states: "the popover's git section names the state plainly and says resolving it
@@ -38,6 +57,11 @@ public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case .bisect: "a bisect is in progress"
case .rebase: "a rebase is in progress"
case .applyMailbox: "a patch application is in progress"
// The clause the failure family reads with ("Adding git to this board failed: ",
// `GitBranchOperation`'s held case), in the same voice as its siblings. The *popover's*
// sentence for this state is its own and says more (`BoardGitBranchSurface.unreadableNote`):
// unlike every pause above it, nothing is in progress and no tool is coming to finish it.
case .unreadable: "this board's git repository can't be read"
}
}
}
@@ -262,13 +286,29 @@ enum GitCommitOperation {
/// "the check runs at open and again before every flush, so finishing the operation in a
/// terminal resumes the pipeline without ceremony").
///
/// A repository that cannot be opened at all reads as no pause, not unborn, not locked the
/// same shrug every read in `GitRepository` gives an unopenable repo, and the commit attempt
/// that follows will fail honestly with libgit2's own message rather than on a guess made here.
/// **A repository that cannot be opened at all is `.unreadable`** a pause, not a shrug (06
/// Rules, the corrupt-`.git` loud failure, ruled 2026-07-31). This line used to answer "no pause,
/// not unborn, not locked" and let the commit attempt that followed fail with libgit2's own
/// message; under the ruling that is exactly backwards the failure must be loud *before* a
/// write is attempted, and nothing may be attempted against a repository the app cannot open
/// ("Lanework leaves the repository untouched").
///
/// Because every caller of this function already branches on `pause`, that one word is the whole
/// of the pause wiring: the flush holds, housekeeping skips, the interrupted-operation recovery
/// defers, and the popover's `refreshPause` learns it.
/// **Presence-shaped, exactly as detection is**: `.unreadable` is what a root `.git` that will
/// not open reads like, and a board with no `.git` at all is not in git mode in the first place
/// it keeps the old no-pause answer, so a caller outside git mode (`GitHousekeeping.run`'s own
/// `.noRepository` reading, a storeless test) is not told a repository it does not have is
/// paused.
nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
_ = startUp
guard let repository = open(boardRoot) else {
return GitRepositoryReading(pause: nil, isUnborn: false, isIndexLocked: false)
return GitRepositoryReading(
pause: BoardGitMode.hasGitEntry(at: boardRoot) ? .unreadable : nil,
isUnborn: false,
isIndexLocked: false
)
}
defer { git_repository_free(repository) }
+10
View File
@@ -99,11 +99,21 @@ public enum GitOperationRecovery: Sendable, Equatable {
/// The conjunction is the point: a pause **without** a stamp is somebody else's operation and the
/// app must not touch it, and a stamp **without** a pause is the app's own finished work. Only
/// both together are "the app's own leftovers".
///
/// **The unreadable repository is the one pause that decides nothing** (06 Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31): an abort is a *write*, and there is no
/// repository to write to running one could only produce a second failure row beside the
/// standing banner that already explains the board. The stamp is deliberately kept rather than
/// cleared, on the same reasoning that keeps it after a failed abort: it is the sole evidence the
/// leftover is this app's, and clearing it would demote the leftover to somebody else's forever.
/// Whenever the repository becomes readable again, the next open or the standing pause's own
/// re-read followed by a later open finds the stamp and the real state, and decides properly.
public static func decide(
stamp: GitOperationStamp?,
pause: GitRepositoryPause?
) -> GitOperationRecovery {
guard let stamp else { return .nothingToDo }
guard pause != .unreadable else { return .nothingToDo }
guard pause != nil else { return .clearStamp }
return .abort(stamp)
}
+23
View File
@@ -237,6 +237,29 @@ enum GitRepository {
// MARK: Reads
/// **Whether libgit2 can open the repository at the board root at all** the detection-time
/// probe behind 06-history-undo.md Rules' corrupt-`.git` loud failure (ruled 2026-07-31):
/// "a corrupt or unopenable repo never falls to mode none the failure is **loud**".
///
/// It is deliberately the *same* call every read here already makes (`Repository.open`), so
/// "unreadable" means exactly what it means to the rest of this file rather than being a second
/// opinion about the same repository. `git_repository_open` validates the layout `HEAD`,
/// `objects/`, `refs/` resolves a `gitdir:` pointer file, and refuses a repository whose
/// format version or extensions it does not implement, which is why a SHA-256 repository lands
/// here "by construction" (06 Repository hygiene: "an adopted SHA-256 repo the engine cannot
/// open takes the corrupt-repo loud-failure path").
///
/// A board with no `.git` at all answers `false` too there is no repository to read but that
/// is not a state any caller reaches: the probe runs only in mode `git`, which is exactly the
/// mode a root `.git` defines.
///
/// Read-only, like everything in this section: opening a repository writes nothing, and a
/// repository that fails to open has not been touched at all.
nonisolated static func canOpen(at boardRoot: URL) -> Bool {
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return false }
return (try? Repository.open(at: boardRoot)) != nil
}
/// The current branch's short name, or `nil` when there is no repository at `boardRoot` or
/// libgit2 cannot open it the popover's read-only branch line (03-board-ui.md Board
/// popover), and nothing more: branch switching and creation are a later card.
+36 -2
View File
@@ -183,14 +183,41 @@ public final class HistoryStore {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// **The repository is there and cannot be opened** (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).
///
/// Read off the committer's pause rather than stored beside it, deliberately: the detection-time
/// probe *seeds* that pause (`init`), every later read of the repository refreshes it the
/// standing pause's 15 s re-read, the popover's `refreshPause`, any flush attempt and a second
/// stored copy could only ever be the stale one. `false` on every board with no repository to
/// read, which is every mode but `.git`.
///
/// **Never a mode change.** Detection stays presence-shaped: the board is in git mode because a
/// `.git` is at its root, whatever condition it is in, so add-git is never offered against it
/// ("init into a repairable repo is exactly the never-mutate hazard").
public var isRepositoryUnreadable: Bool { committer?.pause == .unreadable }
init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
self.boardRoot = boardRoot
self.mode = mode
self.ledger = ledger
if mode == .git {
committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
let committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
self.committer = committer
switcher = GitBranchSwitcher(boardRoot: boardRoot)
housekeeper = GitHousekeeper(boardRoot: boardRoot)
// **The detection-time probe** (06 Rules, the corrupt-`.git` loud failure): detection
// answers presence, this answers readability, and the ruling wants the second answer at
// the same moment as the first "a standing breakage-class banner at detection
// never a silent placeholder discovered only in the popover".
//
// One `git_repository_open` per git-mode board open, which is the same call the branch
// line makes a moment later and a handful of `stat`s in the ordinary case. That is the
// budget 02's hang-avoidance doctrine leaves for an answer the open path cannot do
// without: the alternative is a board that looks live until the first debounce fires.
if !GitRepository.canOpen(at: boardRoot) {
committer.noteRepositoryUnreadable()
}
}
}
@@ -368,8 +395,15 @@ public final class HistoryStore {
/// placeholder promises: an empty field means the derived default applies. The read afterwards is
/// not ceremony it is how the fields end up showing what the file says rather than what was
/// typed at it, which is the only version that survives a foreign edit landing in between.
///
/// **Refused against an unreadable repository** (06 Rules, the corrupt-`.git` loud failure:
/// "Lanework leaves the repository untouched"). This is the one identity call that *writes*, and
/// `.git/config` is the file most likely to be what is wrong with a repository libgit2 will not
/// open. Unreachable in practice the sheet that hosts these fields resolves to nothing on such a
/// board (`BoardSettingsSection.resolve`) and gated anyway, because "never touched" is a
/// promise about the repository rather than about which surfaces happen to be reachable.
public func writeIdentity(name: String, email: String) async {
guard mode == .git else { return }
guard mode == .git, !isRepositoryUnreadable else { return }
let root = boardRoot
identityFailure = nil
let outcome = await Task.detached(priority: .userInitiated) {
+92 -8
View File
@@ -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.
+26
View File
@@ -244,6 +244,21 @@ 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() {}
}
@@ -304,6 +319,14 @@ 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
}
@@ -316,6 +339,9 @@ public enum BoardAnnouncer {
if facts.breakageBefore != nil, facts.breakageAfter == nil {
return AccessibilityPhrases.reloadBreakageCleared
}
if facts.repositoryUnreadableBefore, !facts.repositoryUnreadableAfter {
return AccessibilityPhrases.repositoryUnreadableCleared
}
return nil
}
}
+31 -1
View File
@@ -462,10 +462,40 @@ public final class BoardStore: HealHost {
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts,
gitFailures: banners.gitFailures
gitFailures: banners.gitFailures,
repositoryUnreadable: banners.isRepositoryUnreadable
)
}
/// **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
+9
View File
@@ -420,4 +420,13 @@ 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"
}
+56 -7
View File
@@ -14,11 +14,14 @@ import SwiftUI
/// - **A read-only board disables them too** (02-architecture.md The lock's scope, which names "the
/// popover's git controls" outright).
/// - **A switch in flight disables them**, so a second click cannot start a second checkout.
/// - **An unreadable repository reads as broken, never as still loading** (06 Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31: "Never a silent placeholder discovered only in
/// the popover").
struct BoardGitBranchSurface: Equatable {
/// What the branch line reads the branch name, the short hash on a detached HEAD
/// (`GitRepository.branchName` decides which), or the placeholder while the first read is in
/// flight.
/// (`GitRepository.branchName` decides which), the placeholder while the first read is in
/// flight, or `unavailableLabel` when there is no readable repository for it to name.
let branchLabel: String
/// Whether that label is the placeholder rather than an answer.
@@ -27,34 +30,74 @@ struct BoardGitBranchSurface: Equatable {
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
let pauseExplanation: String?
/// **Whether the pause is the unopenable repository** the one pause whose surface is not the
/// pause note: nothing is in progress and no tool is coming to finish it, so the section says
/// its own sentence instead (`unreadableNote`), and the branch line has no answer to wait for.
let isRepositoryUnreadable: Bool
/// Whether the branch controls accept a click the popover's switch picker, and the settings
/// sheet's create field (`BoardSettingsSheet`), which resolves this same surface so that a
/// paused repository, a read-only board and a switch in flight close both by one rule.
let controlsEnabled: Bool
/// The line the branch display is read as by VoiceOver.
///
/// The broken case is spelled out rather than left to fall through "Branch \(label)": the label
/// is a *state* there, not a name, and "Branch Unavailable" would read as a branch somebody
/// called Unavailable.
var accessibilityLabel: String {
isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
if isRepositoryUnreadable { return "Branch unavailable" }
return isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
}
static let placeholder = ""
/// **What the branch line reads when the repository will not open** an answer, not a
/// placeholder, which is the whole of the ruling's "fails loudly" at this one control: the
/// placeholder means "still reading" and would go on meaning it forever here.
static let unavailableLabel = "Unavailable"
/// The second half of the paused sentence 06's "says resolving it belongs to the tool that
/// created it", said in the app's own voice and paired with the promise that makes it safe to
/// wait: the app is not going to touch the repository behind the user's back.
static let pauseCaption =
"Finishing it belongs to the tool that started it; Lanework leaves the repository untouched."
/// **The popover's own sentence for an unreadable repository** (06 Rules, the corrupt-`.git`
/// loud failure, ruled 2026-07-31: "with the whole git surface paused and the popover's git
/// section naming the state").
///
/// A sibling of the nested and unverifiable notes (`BoardInfoPopover`) and written in their
/// register one sentence, the state first and the consequence after rather than the pause
/// note's two lines, because both of *those* lines would be wrong here: nothing is "in
/// progress", and there is no tool whose job it is to finish it. What it keeps from the pause
/// note is the promise that matters most on a repository the app cannot read, in the ruling's
/// own words.
///
/// It is deliberately **not** the banner's sentence (`BannerCenter.repositoryUnreadableMessage`)
/// re-used: the strip announces a condition to somebody who has not asked, and this answers
/// somebody looking straight at the git section the register the neighbouring notes set.
static let unreadableNote =
"Lanework can't read this board's git repository, so history is paused; the repository is left untouched."
static func resolve(
branch: String?,
pause: GitRepositoryPause?,
isSwitching: Bool,
isWritable: Bool
) -> BoardGitBranchSurface {
BoardGitBranchSurface(
branchLabel: branch ?? placeholder,
isReadingBranch: branch == nil,
// Derived from the pause rather than passed in beside it: the pause *is* how this state is
// carried everywhere else (`GitRepositoryPause.unreadable`, seeded by the detection-time
// probe and refreshed by every later read), so a second parameter would be a second answer
// to one question and a caller could hold them apart.
let unreadable = pause == .unreadable
return BoardGitBranchSurface(
branchLabel: unreadable ? unavailableLabel : (branch ?? placeholder),
// Never "reading" on an unreadable repository: there is nothing in flight, and the line
// the ruling forbids is exactly the one that says otherwise forever.
isReadingBranch: !unreadable && branch == nil,
pauseExplanation: pause?.explanation,
isRepositoryUnreadable: unreadable,
controlsEnabled: pause == nil && isWritable && !isSwitching
)
}
@@ -95,7 +138,13 @@ struct BoardGitControls: View {
VStack(alignment: .leading, spacing: 8) {
branchRow
if let explanation = surface.pauseExplanation {
// **The unreadable repository names itself in its own sentence** (06 Rules, the
// corrupt-`.git` loud failure) checked before the pause note because it *is* a pause,
// and the pause note's second line ("finishing it belongs to the tool that started it")
// would be advice about an operation nobody started.
if surface.isRepositoryUnreadable {
caption(BoardGitBranchSurface.unreadableNote, tone: .primary)
} else if let explanation = surface.pauseExplanation {
pauseNote(explanation)
}
+5 -1
View File
@@ -409,7 +409,11 @@ struct BoardInfoView: View {
/// controls inside it disable themselves (the Board Info I rule).
@ViewBuilder
private var boardSettingsRow: some View {
if let settings, BoardSettingsAvailability.resolve(tier: tier, mode: git?.mode ?? .none) {
if let settings, BoardSettingsAvailability.resolve(
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
) {
Button("Board Settings…") {
dismiss()
settings.present()
+42 -6
View File
@@ -69,13 +69,21 @@ final class BoardSettingsPresentation {
/// What the sheet would show right now and therefore, when empty, that there is no sheet to
/// show (`BoardSettingsAvailability`).
var sections: [BoardSettingsSection] {
BoardSettingsSection.resolve(tier: tier, mode: git?.mode ?? .none)
BoardSettingsSection.resolve(
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
)
}
/// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
/// at all.
var isReachable: Bool {
BoardSettingsAvailability.resolve(tier: tier, mode: git?.mode ?? .none)
BoardSettingsAvailability.resolve(
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
)
}
/// **Opening, not toggling** unlike I. A sheet is modal to its window and carries its own
@@ -141,7 +149,15 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
}
}
static func resolve(tier: Tier, mode: BoardGitMode) -> [BoardSettingsSection] {
/// - Parameter isRepositoryUnreadable: whether the board's `.git` exists and will not open
/// (`HistoryStore.isRepositoryUnreadable`). Defaulted, because it can only ever be true in mode
/// `git` every other mode has no repository for the probe to have failed on, and a caller
/// that has no git state to ask is describing one of those boards.
static func resolve(
tier: Tier,
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> [BoardSettingsSection] {
// **The free tier has no setup to host** (12-editions.md The free tier and `.git`): git is
// the Pro subscription's, "any `.git` is inert", and 03 gives the free tier's whole git story
// as the popover's one-line pointer. There is nothing for a sheet to be about.
@@ -150,7 +166,19 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
case .none:
return [.git]
case .git:
return [.branch, .commitIdentity]
// **An unreadable repository hosts no setup either** (06-history-undo.md Rules, the
// corrupt-`.git` loud failure, ruled 2026-07-31: "the whole git surface paused
// Lanework leaves the repository untouched"), and it lands on repo-nested's emptiness by
// repo-nested's own reasoning, one step further along: both sections here are *writes* to
// a repository a branch created in it, an identity written into its config and there
// is no repository the app can open to write either into. An empty sheet would be the
// greyed-out button 06 rules out one level up, so the surface simply does not exist for
// such a board and the popover's own prose carries the explanation
// (`BoardGitBranchSurface.unreadableNote`).
//
// The mode stays `.git` throughout this is emptiness *within* git mode, never a fall
// to mode none, which is what would let add-git be offered against an existing `.git`.
return isRepositoryUnreadable ? [] : [.branch, .commitIdentity]
case .repoNested:
// **Nothing setup-shaped can apply** (06 Rules): the board lives inside a repository
// Lanework leaves alone, so there is no add-git (the design is insistent that the option
@@ -192,8 +220,16 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
/// this board.
enum BoardSettingsAvailability {
static func resolve(tier: Tier, mode: BoardGitMode) -> Bool {
!BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty
static func resolve(
tier: Tier,
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> Bool {
!BoardSettingsSection.resolve(
tier: tier,
mode: mode,
isRepositoryUnreadable: isRepositoryUnreadable
).isEmpty
}
}