From 0d8ecdb78bc8172a1ba42611a57ea86487a27abe Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 6 Aug 2026 18:44:12 -0400 Subject: [PATCH] =?UTF-8?q?An=20unopenable=20repository=20fails=20loudly?= =?UTF-8?q?=20=E2=80=94=20the=20standing=20row,=20the=20paused=20surface,?= =?UTF-8?q?=20the=20honest=20heal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- Kanban/App/AppModel.swift | 15 ++ Kanban/Git/GitAutoCommitter.swift | 57 +++++++- Kanban/Git/GitCommitOperation.swift | 52 ++++++- Kanban/Git/GitOperationStamp.swift | 10 ++ Kanban/Git/GitRepository.swift | 23 +++ Kanban/Git/HistoryStore.swift | 38 ++++- Kanban/LiveStore/BannerCenter.swift | 100 +++++++++++-- Kanban/LiveStore/BoardAnnouncer.swift | 26 ++++ Kanban/LiveStore/BoardStore.swift | 32 ++++- Kanban/UI/AccessibilityPhrases.swift | 9 ++ Kanban/UI/Board/BoardGitControls.swift | 63 +++++++- Kanban/UI/Board/BoardInfoPopover.swift | 6 +- Kanban/UI/Board/BoardSettingsSheet.swift | 48 ++++++- KanbanTests/AutoCommitTests.swift | 60 ++++++++ KanbanTests/BannerCenterTests.swift | 127 ++++++++++++++++- KanbanTests/BoardAnnouncerTests.swift | 44 ++++++ KanbanTests/BoardInfoPopoverTests.swift | 16 +++ KanbanTests/BoardSettingsSheetTests.swift | 18 +++ KanbanTests/BranchSwitchTests.swift | 70 +++++++++ KanbanTests/HistoryStoreTests.swift | 166 ++++++++++++++++++++++ 20 files changed, 938 insertions(+), 42 deletions(-) diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index a833124..01d3524 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -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 diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index a8d1af8..135dd29 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -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) diff --git a/Kanban/Git/GitCommitOperation.swift b/Kanban/Git/GitCommitOperation.swift index 4295bb3..f45e02d 100644 --- a/Kanban/Git/GitCommitOperation.swift +++ b/Kanban/Git/GitCommitOperation.swift @@ -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) } diff --git a/Kanban/Git/GitOperationStamp.swift b/Kanban/Git/GitOperationStamp.swift index 88d11e3..1ea1329 100644 --- a/Kanban/Git/GitOperationStamp.swift +++ b/Kanban/Git/GitOperationStamp.swift @@ -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) } diff --git a/Kanban/Git/GitRepository.swift b/Kanban/Git/GitRepository.swift index f951d74..08c594e 100644 --- a/Kanban/Git/GitRepository.swift +++ b/Kanban/Git/GitRepository.swift @@ -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. diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index 52be1b7..ba1fabf 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -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) { diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 8a52ead..862b712 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -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. diff --git a/Kanban/LiveStore/BoardAnnouncer.swift b/Kanban/LiveStore/BoardAnnouncer.swift index c687ddd..9e9db90 100644 --- a/Kanban/LiveStore/BoardAnnouncer.swift +++ b/Kanban/LiveStore/BoardAnnouncer.swift @@ -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 } } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 267931c..fee2bf8 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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 diff --git a/Kanban/UI/AccessibilityPhrases.swift b/Kanban/UI/AccessibilityPhrases.swift index 45a1c6f..26f8f7c 100644 --- a/Kanban/UI/AccessibilityPhrases.swift +++ b/Kanban/UI/AccessibilityPhrases.swift @@ -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" } diff --git a/Kanban/UI/Board/BoardGitControls.swift b/Kanban/UI/Board/BoardGitControls.swift index 6723a89..b45db54 100644 --- a/Kanban/UI/Board/BoardGitControls.swift +++ b/Kanban/UI/Board/BoardGitControls.swift @@ -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) } diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index 0c43ae3..94dc9e4 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -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() diff --git a/Kanban/UI/Board/BoardSettingsSheet.swift b/Kanban/UI/Board/BoardSettingsSheet.swift index 200cd9a..fd2385d 100644 --- a/Kanban/UI/Board/BoardSettingsSheet.swift +++ b/Kanban/UI/Board/BoardSettingsSheet.swift @@ -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 } } diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index fc44479..1dc20fc 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -732,6 +732,66 @@ struct AutoCommitContentionTests { #expect(committer.commitCount == 0) } + /// **The corrupt-`.git` loud failure's engine half** (06-history-undo.md ▸ Rules, ruled + /// 2026-07-31): "the board itself loads and edits normally — files are the board — but the + /// failure is loud … with the whole git surface paused", and "the banner clears when a later + /// open or reload finds the repo readable". + @Test("An unreadable repository holds the engine, and heals when it opens again") + func anUnreadableRepositoryHolds() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + var transitions: [Bool] = [] + committer.reportRepositoryUnreadable = { transitions.append($0) } + + // What a half-copied or half-deleted `.git` looks like to libgit2: the layout no longer + // validates, so the repository will not open at all. Reversible, which is what makes the + // heal half of this test the real thing rather than a second fixture. + let head = fixture.root.appendingPathComponent(".git/HEAD") + let savedHead = try Data(contentsOf: head) + try FileManager.default.removeItem(at: head) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + + #expect(committer.pause == .unreadable) + #expect(committer.commitCount == 0, "nothing is attempted against a repository the app can't open") + #expect(committer.lastFailure == nil, "a pause is not a failure") + #expect(transitions == [true], "the banner is raised once, on the transition") + + // "Edits keep landing on disk — files are the board — and commit as one settled batch when + // the state clears." Mid-session, the re-read that notices is the paused engine's own. + try fixture.item("\(Ident.lane1)/card-3", plain(order: "3072", title: "Third")) + try savedHead.write(to: head) + await committer.flushNow() + + #expect(committer.pause == nil) + #expect(committer.commitCount == 1, "one settled batch, exactly as any other pause") + #expect(transitions == [true, false], "and the banner heals — it is a condition, not an event") + #expect(isClean(at: fixture.root)) + } + + @Test("The popover's own re-read learns the state without attempting anything") + func refreshPauseLearnsTheUnreadableRepository() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + let head = fixture.root.appendingPathComponent(".git/HEAD") + let savedHead = try Data(contentsOf: head) + try FileManager.default.removeItem(at: head) + + await committer.refreshPause() + #expect(committer.pause == .unreadable) + #expect(committer.commitCount == 0) + + try savedHead.write(to: head) + await committer.refreshPause() + #expect(committer.pause == nil) + } + @Test("An unborn HEAD is normal — the first settled change commits the whole tree") func anUnbornHeadIsNormal() async throws { let fixture = try makeBoard() diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index b467071..d3dd37f 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -103,16 +103,20 @@ struct BannerCenterOrderingTests { suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)), operations: [operation], signposts: [signpost], - gitFailures: [restore] + gitFailures: [restore], + repositoryUnreadable: true ) - // in-progress (pinned) > read-only lock > reload breakage > 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. + // 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. #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)", @@ -120,11 +124,46 @@ struct BannerCenterOrderingTests { "one-shot:\(attachment.id.uuidString)", "signpost:\(signpost.id.uuidString)", ]) - #expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .warning, .warning, .error, .info]) - #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false], + #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], "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 @@ -432,6 +471,51 @@ 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() @@ -604,11 +688,13 @@ struct BannerRowControlsTests { let rows: [BannerRow] = [ .readOnlyLock(.vanishedRoot), .reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))), + .repositoryUnreadable, .historySuspended(HistorySuspension(reason: "the repository is corrupt")), ] for row in rows { #expect(row.controls.isEmpty, "\(row.id) is a condition — it heals, it is not waved away") + #expect(row.dismissID == nil) } } @@ -937,4 +1023,33 @@ struct BannerCenterStoreTests { "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") + } } diff --git a/KanbanTests/BoardAnnouncerTests.swift b/KanbanTests/BoardAnnouncerTests.swift index 1e3fd79..7226468 100644 --- a/KanbanTests/BoardAnnouncerTests.swift +++ b/KanbanTests/BoardAnnouncerTests.swift @@ -441,6 +441,50 @@ 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() diff --git a/KanbanTests/BoardInfoPopoverTests.swift b/KanbanTests/BoardInfoPopoverTests.swift index 5d9c0d1..1ef4507 100644 --- a/KanbanTests/BoardInfoPopoverTests.swift +++ b/KanbanTests/BoardInfoPopoverTests.swift @@ -78,6 +78,22 @@ struct BoardGitSectionTests { #expect(section != .noRepository) } + /// **A board whose repository will not open keeps the git section it has** (06 ▸ Rules, the + /// corrupt-`.git` loud failure, ruled 2026-07-31) — the *branch* section, held and explaining + /// itself, never the mode-none posture that would imply a board with no history to have. + /// + /// The section case is deliberately blind to readability: what changes on such a board is what + /// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation + /// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The + /// posture matrix stays a function of the tier and the mode alone. + @Test("An unreadable repository is still the branch section — never the no-repository posture") + func anUnreadableRepositoryKeepsTheBranchSection() { + let section = BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) + + #expect(section == .branch) + #expect(section != .noRepository, "the board has a repository; it is unreadable, not absent") + } + @Test("Every posture is reachable, and none of them is two postures") func theMatrixIsTotal() { let resolved = Set( diff --git a/KanbanTests/BoardSettingsSheetTests.swift b/KanbanTests/BoardSettingsSheetTests.swift index a467b13..2334aab 100644 --- a/KanbanTests/BoardSettingsSheetTests.swift +++ b/KanbanTests/BoardSettingsSheetTests.swift @@ -45,6 +45,19 @@ struct BoardSettingsSectionTests { #expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == []) } + @Test("Pro, git mode with an unreadable repository: nothing setup-shaped applies either") + func anUnreadableRepositoryHoldsNothing() { + // **The corrupt-`.git` loud failure** (06 ▸ Rules, ruled 2026-07-31): 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. Repo-nested's emptiness, + // reached one step further along. + #expect(BoardSettingsSection.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true) == []) + + // …and the mode is still `git` throughout: this is emptiness *within* git mode, never the + // fall to mode none that would let add-git be offered against an existing `.git`. + #expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity]) + } + @Test("The sections carry the headers VoiceOver navigates by") func headersAreNamed() { // 10-accessibility.md ▸ Board settings sheet: "titled and sectioned with headers VoiceOver @@ -77,6 +90,11 @@ struct BoardSettingsAvailabilityTests { // denied ancestor check is never distinguishable from a repository actually being there. #expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable)) + // **Pro, git mode over a repository that will not open**: no door either, so neither the + // popover's Board Settings… row nor the menu command offers a surface with nothing on it + // (06 ▸ Rules, the corrupt-`.git` loud failure). + #expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true)) + // **The free tier**: no setup exists there at all (12-editions.md ▸ The free tier and // `.git`), whatever mode a stray value claims — detection never runs off Pro, so the mode is // swept for completeness rather than because it can vary. diff --git a/KanbanTests/BranchSwitchTests.swift b/KanbanTests/BranchSwitchTests.swift index 25ba907..718d477 100644 --- a/KanbanTests/BranchSwitchTests.swift +++ b/KanbanTests/BranchSwitchTests.swift @@ -583,6 +583,37 @@ struct BranchSwitchSequenceTests { #expect(GitRepository.branchName(at: fixture.root) == "main") } + /// The same refusal through the **real** pause rather than an injected one: an unreadable + /// repository is a `GitRepositoryPause` like any other, so the switch's existing gate closes on + /// it with nothing added here (06-history-undo.md ▸ Rules, the corrupt-`.git` loud failure, ruled + /// 2026-07-31: "the whole git surface paused"). + @Test("A switch refuses against a repository the app cannot open, and changes nothing") + @MainActor + func anUnreadableRepositoryRefusesTheSwitch() async throws { + let (fixture, git) = try await makeGitBoard() + defer { fixture.tearDown() } + let switcher = try makeSwitcher(git) + #expect(GitBranchOperation.createAndSwitch("redesign", at: fixture.root) == .switched("redesign")) + #expect(GitBranchOperation.checkout("main", at: fixture.root) == .switched("main")) + + var asked = 0 + switcher.settleSessions = { asked += 1; return .proceed } + + let head = fixture.root.appendingPathComponent(".git/HEAD") + let savedHead = try Data(contentsOf: head) + try FileManager.default.removeItem(at: head) + await git.committer?.refreshPause() + #expect(git.isRepositoryUnreadable) + + #expect(await switcher.switchTo("redesign") == false) + #expect(asked == 0, "no modal, no stamp, no checkout — nothing is attempted at all") + #expect(switcher.lastFailure == nil, "a pause is not a failure; the standing banner is the message") + + // The repository is exactly as it was found, which is the promise the row makes. + try savedHead.write(to: head) + #expect(GitRepository.branchName(at: fixture.root) == "main") + } + /// "Contention outlasting the brief retry surfaces as a *waiting* state in the operation's /// in-progress banner row … a wait that persists implausibly long names the lock path — never an /// error dialog, never a hammer." @@ -946,6 +977,45 @@ struct BoardGitBranchSurfaceTests { ).controlsEnabled) } + /// **The corrupt-`.git` loud failure at this one control** (06-history-undo.md ▸ Rules, ruled + /// 2026-07-31: "Never a silent placeholder discovered only in the popover"). The bug this pins + /// against is the shipped one: `GitRepository.branchName` answers `nil` on a repository it cannot + /// open, and the placeholder meant "still reading" — forever. + @Test("An unreadable repository reads as broken, never as still loading") + func anUnreadableSurface() { + let surface = BoardGitBranchSurface.resolve( + branch: nil, + pause: .unreadable, + isSwitching: false, + isWritable: true + ) + + #expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel) + #expect(surface.branchLabel != BoardGitBranchSurface.placeholder) + #expect(!surface.isReadingBranch, "there is nothing in flight to be waiting for") + #expect(surface.isRepositoryUnreadable) + #expect(!surface.controlsEnabled, "the whole git surface is paused") + #expect(surface.accessibilityLabel == "Branch unavailable") + + // The section's own sentence — a sibling of the popover's nested and unverifiable notes, and + // not the pause note, whose second line would be advice about an operation nobody started. + #expect(BoardGitBranchSurface.unreadableNote + == "Lanework can't read this board's git repository, so history is paused; the repository is left untouched.") + } + + @Test("A branch name read before the repository broke does not survive the pause") + func anUnreadableSurfaceDropsAStaleBranch() { + let surface = BoardGitBranchSurface.resolve( + branch: "main", + pause: .unreadable, + isSwitching: false, + isWritable: true + ) + + #expect(surface.branchLabel == BoardGitBranchSurface.unavailableLabel, + "the line must not go on naming a branch nothing can read") + } + @Test("Before the first read the line is a placeholder, not a guess at a branch name") func theReadingSurface() { let surface = BoardGitBranchSurface.resolve( diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift index 1eb991e..157490c 100644 --- a/KanbanTests/HistoryStoreTests.swift +++ b/KanbanTests/HistoryStoreTests.swift @@ -68,6 +68,37 @@ private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] { return entries.sorted { $0.path < $1.path } } +/// **A `.git` file aimed at nothing** — the worktree/submodule pointer shape (`gitdir: …`), which +/// detection reads as a repository (presence is presence, 06 ▸ Rules ▸ Detection) and libgit2 cannot +/// open, because the directory it names is not there. +private func plantDanglingGitPointer(in fixture: WriterFixture) throws { + let target = fixture.root.appendingPathComponent("nowhere/.git/worktrees/board").path + try fixture.file(".git", Data("gitdir: \(target)\n".utf8)) +} + +/// **A SHA-256 repository, by hand** — the layout libgit2 validates, plus the two config keys +/// `git init --object-format=sha256` writes (06 ▸ Repository hygiene: "SHA-256 repositories are +/// unsupported, safely … an adopted SHA-256 repo the engine cannot open takes the corrupt-repo +/// loud-failure path"). +/// +/// Built by hand rather than by `git init --object-format=sha256` for the file's standing reason: +/// there is no `/usr/bin/git` in this feature's promise, so there is none in its tests. What makes +/// the fixture honest is that nothing here is a mock — the bytes are the ones git writes, and the +/// refusal is libgit2's own. +private func plantSHA256Repository(in fixture: WriterFixture) throws { + try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) + try fixture.file(".git/objects/info/.keep", Data()) + try fixture.file(".git/refs/heads/.keep", Data()) + try fixture.file(".git/config", Data(""" + [core] + \trepositoryformatversion = 1 + \tbare = false + [extensions] + \tobjectformat = sha256 + + """.utf8)) +} + // MARK: - Composition @MainActor @@ -157,6 +188,141 @@ struct HistoryStoreCompositionTests { } } +// MARK: - The unreadable repository + +/// **A `.git` that isn't a valid repository still reads as git mode — and fails loudly** +/// (06-history-undo.md ▸ Rules, ruled 2026-07-31). +/// +/// The probe is `GitRepository.canOpen(at:)` — the same `Repository.open` every read in that file +/// makes — run at composition, seeding the committer's pause so the whole git surface is held from +/// the first moment rather than from the first debounce. Every fixture here is a real shape from the +/// wild: a half-made `.git`, a worktree pointer aimed at nothing, and a SHA-256 repository this +/// engine has no support for. +@MainActor +@Suite("HistoryStore ▸ the unreadable repository") +struct HistoryStoreUnreadableRepositoryTests { + + @Test("A repository that opens reads readable, and holds nothing") + func aValidRepositoryIsReadable() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(await first.addGit()) + + // The next open, which is where the probe actually runs. + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(git.mode == .git) + #expect(!git.isRepositoryUnreadable) + #expect(git.committer?.pause == nil) + #expect(GitRepository.canOpen(at: fixture.root)) + } + + @Test("A corrupt `.git` stays git mode, reads unreadable, and holds the surface from the first moment") + func aCorruptGitDirectoryIsUnreadable() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + // A `.git` with nothing in it but a plausible HEAD: enough for detection, which asks the + // filesystem one question, and not a repository at all to libgit2. + try plantGitDirectory(in: fixture) + + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + + // **Never a fall to mode none** — "detection is presence-shaped … a corrupt or unopenable + // repo never falls to mode none", which is what keeps add-git from ever being offered + // against an existing `.git` ("init into a repairable repo is exactly the never-mutate + // hazard"). + #expect(git.mode == .git) + #expect(git.isRepositoryUnreadable) + #expect(!GitRepository.canOpen(at: fixture.root)) + + // The pause is seeded at *detection*, before anything has been attempted: the surface is + // held and the banner is raised at the open rather than a debounce later. + #expect(git.committer?.pause == .unreadable) + #expect(git.committer?.lastFailure == nil, "a pause is not a failure") + + // And the one operation that could make it worse is refused, whatever the mode read. + #expect(await git.addGit() == false) + } + + @Test("A worktree pointer aimed at nothing reads unreadable — the file shape, not just the directory one") + func aDanglingPointerIsUnreadable() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try plantDanglingGitPointer(in: fixture) + + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence") + #expect(git.isRepositoryUnreadable) + } + + @Test("A SHA-256 repository takes the same path, by construction") + func aSHA256RepositoryIsUnreadable() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try plantSHA256Repository(in: fixture) + + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(git.mode == .git) + // 06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the + // corrupt-repo loud-failure path — never a silent fall to mode-none". + #expect(git.isRepositoryUnreadable) + } + + @Test("Probing an unreadable repository touches nothing") + func theProbeIsARead() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try plantGitDirectory(in: fixture) + let before = try snapshotGitDirectory(fixture.root) + + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(git.isRepositoryUnreadable) + + // "Lanework leaves the repository untouched" — the same bytes and the same mtimes, on the + // one path where a repair instinct would be most tempting. + #expect(try snapshotGitDirectory(fixture.root) == before) + } + + @Test("The identity write is refused against a repository the app cannot open") + func identityWritesAreRefused() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try plantGitDirectory(in: fixture) + + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + await git.writeIdentity(name: "Ada", email: "ada@example.com") + + #expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open") + #expect(git.identityFailure == nil, "and nothing was attempted, so there is nothing to report") + } + + /// The seam every git operation consults before it runs (`GitCommitOperation.reading`), asked + /// directly: one word is what holds the auto-commit flush, skips housekeeping, disables Undo/Redo + /// and the branch controls, and defers the interrupted-operation recovery. + @Test("The repository reading reports the pause every operation gates on") + func theReadingReportsThePause() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try plantGitDirectory(in: fixture) + + let reading = GitCommitOperation.reading(at: fixture.root) + #expect(reading.pause == .unreadable) + #expect(!reading.isUnborn) + #expect(!reading.isIndexLocked) + + // Optional work simply does not happen (06 ▸ Repository hygiene: skipped under a pause). + #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held)) + + // And the app never aborts its own leftover against a repository it cannot open — the stamp + // is kept, not cleared, so the leftover stays recognizable as this app's. + let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: nil) + #expect(GitOperationRecovery.decide(stamp: stamp, pause: .unreadable) == .nothingToDo) + #expect(GitOperationRecovery.decide(stamp: stamp, pause: .merge) == .abort(stamp), + "every other pause still means the app's own leftover") + } +} + // MARK: - Add git @MainActor