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 committer.reportRecovery = { [weak store] in
store?.banners.clearHistorySuspension() 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) store.commitSeam = .binding(to: committer)
// **The undo stack's ear on the committer** every commit this engine lands, and // **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, // 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 git.reportFailure = { [weak store] failure in
store?.banners.postGitFailure(.addGit, reason: failure.message) 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 // **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 // 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 @ObservationIgnored
public var reportRecovery: (@MainActor () -> Void)? 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 /// **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 /// (06-history-undo.md Rules The stack is HEAD's first-parent ancestry, live; Heal commits
/// are transparent to undo, in-session). /// are transparent to undo, in-session).
@@ -459,9 +475,36 @@ public final class GitAutoCommitter {
/// and it read it in order to write. /// and it read it in order to write.
public func refreshPause() async { public func refreshPause() async {
let root = boardRoot let root = boardRoot
pause = await Task.detached(priority: .userInitiated) { let read = await Task.detached(priority: .userInitiated) {
GitCommitOperation.reading(at: root).pause GitCommitOperation.reading(at: root).pause
}.value }.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 /// 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 { switch outcome {
case let .committed(landed): case let .committed(landed):
let oids = landed.map(\.oid) let oids = landed.map(\.oid)
pause = nil setPause(nil)
lastFailure = nil lastFailure = nil
commitCount += oids.count commitCount += oids.count
lastCommitOIDs = oids lastCommitOIDs = oids
@@ -899,7 +942,7 @@ public final class GitAutoCommitter {
case .nothingToCommit: case .nothingToCommit:
// **The happy path, not a malfunction** (06): an agent committed its own work, or the // **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. // whole window was staged around. Silent, and the window closes either way.
pause = nil setPause(nil)
lastFailure = nil lastFailure = nil
dropHarvestOutsideOpenSessions() dropHarvestOutsideOpenSessions()
holdsForeignChanges = false holdsForeignChanges = false
@@ -913,12 +956,16 @@ public final class GitAutoCommitter {
arm() arm()
case let .held(reason): case let .held(reason):
pause = reason setPause(reason)
Self.logger.notice("auto-commit held: \(reason.rawValue, privacy: .public)") 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) arm(after: holdRecheckInterval)
case let .failed(failure): case let .failed(failure):
pause = nil setPause(nil)
lastFailure = failure lastFailure = failure
Self.logger.error("auto-commit failed: \(failure.description, privacy: .public)") Self.logger.error("auto-commit failed: \(failure.description, privacy: .public)")
reportFailure?(failure) 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 /// fact about how the next commit is shaped (`GitRepository.initialCommitSubject`), never a reason
/// to stop. /// to stop.
/// ///
/// The cases are libgit2's own `git_repository_state`, which reads exactly the marker files 06 /// Seven of the cases are libgit2's own `git_repository_state`, which reads exactly the marker files
/// names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this /// 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`). /// 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 { public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case detachedHead case detachedHead
case merge case merge
@@ -25,6 +37,13 @@ public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case rebase case rebase
case applyMailbox 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 /// 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 /// 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 /// 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 .bisect: "a bisect is in progress"
case .rebase: "a rebase is in progress" case .rebase: "a rebase is in progress"
case .applyMailbox: "a patch application 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 /// "the check runs at open and again before every flush, so finishing the operation in a
/// terminal resumes the pipeline without ceremony"). /// terminal resumes the pipeline without ceremony").
/// ///
/// A repository that cannot be opened at all reads as no pause, not unborn, not locked the /// **A repository that cannot be opened at all is `.unreadable`** a pause, not a shrug (06
/// same shrug every read in `GitRepository` gives an unopenable repo, and the commit attempt /// Rules, the corrupt-`.git` loud failure, ruled 2026-07-31). This line used to answer "no pause,
/// that follows will fail honestly with libgit2's own message rather than on a guess made here. /// 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 { nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
_ = startUp _ = startUp
guard let repository = open(boardRoot) else { 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) } 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 /// 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 /// 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". /// 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( public static func decide(
stamp: GitOperationStamp?, stamp: GitOperationStamp?,
pause: GitRepositoryPause? pause: GitRepositoryPause?
) -> GitOperationRecovery { ) -> GitOperationRecovery {
guard let stamp else { return .nothingToDo } guard let stamp else { return .nothingToDo }
guard pause != .unreadable else { return .nothingToDo }
guard pause != nil else { return .clearStamp } guard pause != nil else { return .clearStamp }
return .abort(stamp) return .abort(stamp)
} }
+23
View File
@@ -237,6 +237,29 @@ enum GitRepository {
// MARK: Reads // 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 /// 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 /// 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. /// 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") 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) { init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) {
self.boardRoot = boardRoot self.boardRoot = boardRoot
self.mode = mode self.mode = mode
self.ledger = ledger self.ledger = ledger
if mode == .git { if mode == .git {
committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger) let committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
self.committer = committer
switcher = GitBranchSwitcher(boardRoot: boardRoot) switcher = GitBranchSwitcher(boardRoot: boardRoot)
housekeeper = GitHousekeeper(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 /// 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 /// 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. /// 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 { public func writeIdentity(name: String, email: String) async {
guard mode == .git else { return } guard mode == .git, !isRepositoryUnreadable else { return }
let root = boardRoot let root = boardRoot
identityFailure = nil identityFailure = nil
let outcome = await Task.detached(priority: .userInitiated) { 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 /// divide into three lifecycles that the view renders differently and that the ordering rule
/// treats as classes: /// 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, /// 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 /// "a condition is never dismissed while it is still true". Each leaves when the thing it
/// describes stops being true. /// 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 /// 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. /// counts the rest rather than pretending the walk found only one.
case reloadBreakage(BoardLoadFailure) 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. /// A write that did not happen. Dismissable, error tone.
case oneShot(OneShotBanner) case oneShot(OneShotBanner)
/// A git operation that did not happen an undo restore, a branch switch, and (pro-m2) a pull /// 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 { switch self {
case .readOnlyLock: "read-only-lock" case .readOnlyLock: "read-only-lock"
case .reloadBreakage: "reload-breakage" case .reloadBreakage: "reload-breakage"
case .repositoryUnreadable: "repository-unreadable"
case let .oneShot(banner): "one-shot:\(banner.id.uuidString)" case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
case let .gitFailure(banner): "git-failure:\(banner.id.uuidString)" case let .gitFailure(banner): "git-failure:\(banner.id.uuidString)"
case let .loss(loss): "loss:\(loss.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 // 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" // 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). // (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 .historySuspended, .loss: .warning
case .inProgress, .signpost: .info case .inProgress, .signpost: .info
} }
@@ -340,6 +361,7 @@ public enum BannerRow: Identifiable, Sendable {
switch self { switch self {
case let .readOnlyLock(reason): BannerCenter.headline(for: reason) case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
case let .reloadBreakage(error): BannerCenter.headline(for: error) case let .reloadBreakage(error): BannerCenter.headline(for: error)
case .repositoryUnreadable: BannerCenter.repositoryUnreadableMessage
case let .oneShot(banner): BannerCenter.headline(for: banner.error) case let .oneShot(banner): BannerCenter.headline(for: banner.error)
case let .gitFailure(banner): BannerCenter.headline(for: banner) case let .gitFailure(banner): BannerCenter.headline(for: banner)
case let .loss(loss): loss.message case let .loss(loss): loss.message
@@ -358,7 +380,7 @@ public enum BannerRow: Identifiable, Sendable {
case let .gitFailure(banner): banner.id case let .gitFailure(banner): banner.id
case let .loss(loss): loss.id case let .loss(loss): loss.id
case let .signpost(signpost): signpost.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. /// The standing "history isn't advancing" condition, or `nil` when commits are landing.
public private(set) var historySuspension: HistorySuspension? 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. /// Work in flight, newest first for the same reason `oneShots` is.
public private(set) var operations: [InProgressOperation] = [] public private(set) var operations: [InProgressOperation] = []
@@ -810,6 +844,27 @@ public final class BannerCenter {
historySuspension = nil 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 // MARK: In-progress operations
/// Starts an info row with a spinner and hands back its id. /// 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 /// 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. /// 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 /// - **The unreadable repository stands in the breakage class, just under the reload breakage**
/// window, the second because a center that hosts no git surface (a card window's own) can never /// (06-history-undo.md Rules, ruled 2026-07-31: "a standing breakage-class banner"). Under,
/// hold one. Every other class is spelled out at every call site `losses` included, since a /// and not over, because the two describe different things going wrong and one of them is
/// Finder drop that skipped folders already posts one (`postSkippedFolders`). /// 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( public nonisolated static func rows(
lock: ReadOnlyLockReason?, lock: ReadOnlyLockReason?,
breakage: BoardLoadFailure?, breakage: BoardLoadFailure?,
@@ -901,7 +964,8 @@ public final class BannerCenter {
suspension: HistorySuspension?, suspension: HistorySuspension?,
operations: [InProgressOperation], operations: [InProgressOperation],
signposts: [InfoSignpost] = [], signposts: [InfoSignpost] = [],
gitFailures: [GitFailureBanner] = [] gitFailures: [GitFailureBanner] = [],
repositoryUnreadable: Bool = false
) -> [BannerRow] { ) -> [BannerRow] {
var rows: [BannerRow] = [] var rows: [BannerRow] = []
@@ -913,6 +977,9 @@ public final class BannerCenter {
if let breakage { if let breakage {
rows.append(.reloadBreakage(breakage)) rows.append(.reloadBreakage(breakage))
} }
if repositoryUnreadable {
rows.append(.repositoryUnreadable)
}
let ordered = newestFirst(oneShots, by: \.occurredAt) let ordered = newestFirst(oneShots, by: \.occurredAt)
rows.append(contentsOf: failureRank( rows.append(contentsOf: failureRank(
@@ -1447,6 +1514,23 @@ public final class BannerCenter {
return "\(verb) skipped — '\(subject)' changed outside Lanework" 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 /// The suspended-history line. It names the *consequence* the user cares about undo and the
/// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries /// flush-before-overwrite guarantee are degraded rather than the git mechanics, and carries
/// the diagnosis as its tail. /// the diagnosis as its tail.
+26
View File
@@ -244,6 +244,21 @@ public enum BoardAnnouncer {
public var breakageBefore: BoardLoadFailure? public var breakageBefore: BoardLoadFailure?
public var breakageAfter: 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() {} public init() {}
} }
@@ -304,6 +319,14 @@ public enum BoardAnnouncer {
if let breakage = facts.breakageAfter, breakage != facts.breakageBefore { if let breakage = facts.breakageAfter, breakage != facts.breakageBefore {
return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage)) 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 return nil
} }
@@ -316,6 +339,9 @@ public enum BoardAnnouncer {
if facts.breakageBefore != nil, facts.breakageAfter == nil { if facts.breakageBefore != nil, facts.breakageAfter == nil {
return AccessibilityPhrases.reloadBreakageCleared return AccessibilityPhrases.reloadBreakageCleared
} }
if facts.repositoryUnreadableBefore, !facts.repositoryUnreadableAfter {
return AccessibilityPhrases.repositoryUnreadableCleared
}
return nil return nil
} }
} }
+31 -1
View File
@@ -462,10 +462,40 @@ public final class BoardStore: HealHost {
suspension: banners.historySuspension, suspension: banners.historySuspension,
operations: banners.operations, operations: banners.operations,
signposts: banners.signposts, 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 // MARK: Wiring
/// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and /// 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 /// 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. /// than the lock's and is deliberately phrased as one.
static let reloadBreakageCleared = "The board is loading again" 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 /// - **A read-only board disables them too** (02-architecture.md The lock's scope, which names "the
/// popover's git controls" outright). /// popover's git controls" outright).
/// - **A switch in flight disables them**, so a second click cannot start a second checkout. /// - **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 { struct BoardGitBranchSurface: Equatable {
/// What the branch line reads the branch name, the short hash on a detached HEAD /// 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 /// (`GitRepository.branchName` decides which), the placeholder while the first read is in
/// flight. /// flight, or `unavailableLabel` when there is no readable repository for it to name.
let branchLabel: String let branchLabel: String
/// Whether that label is the placeholder rather than an answer. /// 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. /// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
let pauseExplanation: String? 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 /// 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 /// 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. /// paused repository, a read-only board and a switch in flight close both by one rule.
let controlsEnabled: Bool let controlsEnabled: Bool
/// The line the branch display is read as by VoiceOver. /// 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 { var accessibilityLabel: String {
isReadingBranch ? "Reading branch" : "Branch \(branchLabel)" if isRepositoryUnreadable { return "Branch unavailable" }
return isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
} }
static let placeholder = "" 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 /// 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 /// 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. /// wait: the app is not going to touch the repository behind the user's back.
static let pauseCaption = static let pauseCaption =
"Finishing it belongs to the tool that started it; Lanework leaves the repository untouched." "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( static func resolve(
branch: String?, branch: String?,
pause: GitRepositoryPause?, pause: GitRepositoryPause?,
isSwitching: Bool, isSwitching: Bool,
isWritable: Bool isWritable: Bool
) -> BoardGitBranchSurface { ) -> BoardGitBranchSurface {
BoardGitBranchSurface( // Derived from the pause rather than passed in beside it: the pause *is* how this state is
branchLabel: branch ?? placeholder, // carried everywhere else (`GitRepositoryPause.unreadable`, seeded by the detection-time
isReadingBranch: branch == nil, // 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, pauseExplanation: pause?.explanation,
isRepositoryUnreadable: unreadable,
controlsEnabled: pause == nil && isWritable && !isSwitching controlsEnabled: pause == nil && isWritable && !isSwitching
) )
} }
@@ -95,7 +138,13 @@ struct BoardGitControls: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
branchRow 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) pauseNote(explanation)
} }
+5 -1
View File
@@ -409,7 +409,11 @@ struct BoardInfoView: View {
/// controls inside it disable themselves (the Board Info I rule). /// controls inside it disable themselves (the Board Info I rule).
@ViewBuilder @ViewBuilder
private var boardSettingsRow: some View { 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…") { Button("Board Settings…") {
dismiss() dismiss()
settings.present() 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 /// What the sheet would show right now and therefore, when empty, that there is no sheet to
/// show (`BoardSettingsAvailability`). /// show (`BoardSettingsAvailability`).
var sections: [BoardSettingsSection] { 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 /// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
/// at all. /// at all.
var isReachable: Bool { 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 /// **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 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 // 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. // 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: case .none:
return [.git] return [.git]
case .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: case .repoNested:
// **Nothing setup-shaped can apply** (06 Rules): the board lives inside a repository // **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 // 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. /// this board.
enum BoardSettingsAvailability { enum BoardSettingsAvailability {
static func resolve(tier: Tier, mode: BoardGitMode) -> Bool { static func resolve(
!BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty tier: Tier,
mode: BoardGitMode,
isRepositoryUnreadable: Bool = false
) -> Bool {
!BoardSettingsSection.resolve(
tier: tier,
mode: mode,
isRepositoryUnreadable: isRepositoryUnreadable
).isEmpty
} }
} }
+60
View File
@@ -732,6 +732,66 @@ struct AutoCommitContentionTests {
#expect(committer.commitCount == 0) #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") @Test("An unborn HEAD is normal — the first settled change commits the whole tree")
func anUnbornHeadIsNormal() async throws { func anUnbornHeadIsNormal() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
+121 -6
View File
@@ -103,16 +103,20 @@ struct BannerCenterOrderingTests {
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)), suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
operations: [operation], operations: [operation],
signposts: [signpost], signposts: [signpost],
gitFailures: [restore] gitFailures: [restore],
repositoryUnreadable: true
) )
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures, both shapes > // in-progress (pinned) > read-only lock > reload breakage > **the unreadable repository** >
// loss rows > commit and attachment failures > passive info rows. The two info classes // one-shot failures, both shapes > loss rows > commit and attachment failures > passive info
// sit at opposite ends of the strip. // 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) == [ #expect(rows.map(\.id) == [
"operation:\(operation.id.uuidString)", "operation:\(operation.id.uuidString)",
"read-only-lock", "read-only-lock",
"reload-breakage", "reload-breakage",
"repository-unreadable",
"git-failure:\(restore.id.uuidString)", "git-failure:\(restore.id.uuidString)",
"one-shot:\(move.id.uuidString)", "one-shot:\(move.id.uuidString)",
"loss:\(loss.id.uuidString)", "loss:\(loss.id.uuidString)",
@@ -120,11 +124,46 @@ struct BannerCenterOrderingTests {
"one-shot:\(attachment.id.uuidString)", "one-shot:\(attachment.id.uuidString)",
"signpost:\(signpost.id.uuidString)", "signpost:\(signpost.id.uuidString)",
]) ])
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .warning, .warning, .error, .info]) #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], #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") "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") @Test("Both failure shapes share one rank, interleaved by recency")
func theFailureRankHoldsBothShapes() { func theFailureRankHoldsBothShapes() {
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The // "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
@@ -432,6 +471,51 @@ struct BannerCenterLifecycleTests {
).isEmpty) ).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") @Test("Re-suspending keeps the original start and takes the newer diagnosis")
func resuspendingKeepsTheClock() throws { func resuspendingKeepsTheClock() throws {
let center = BannerCenter() let center = BannerCenter()
@@ -604,11 +688,13 @@ struct BannerRowControlsTests {
let rows: [BannerRow] = [ let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot), .readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))), .reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.repositoryUnreadable,
.historySuspended(HistorySuspension(reason: "the repository is corrupt")), .historySuspended(HistorySuspension(reason: "the repository is corrupt")),
] ]
for row in rows { for row in rows {
#expect(row.controls.isEmpty, "\(row.id) is a condition — it heals, it is not waved away") #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)", "signpost:\(store.banners.signposts[0].id.uuidString)",
]) ])
} }
/// **The row the git state raises, through the store** (06-history-undo.md Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31) raised and healed by
/// `noteRepositoryUnreadable(_:)`, which is the seam `AppModel.beginSession` wires the
/// committer's pause transitions to, and **announced** both ways per 10-accessibility.md.
@Test("The unreadable repository stands on the strip and is spoken when it appears and clears")
func theUnreadableRepositoryRowIsRaisedAndSpoken() throws {
let fixture = try makeBoardWithUneditableLane()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
var spoken: [String] = []
store.announce = { if let line = $0 { spoken.append(line) } }
store.noteRepositoryUnreadable(true)
#expect(store.bannerRows.map(\.id) == ["repository-unreadable"])
#expect(store.bannerRows[0].headline == BannerCenter.repositoryUnreadableMessage)
#expect(spoken == ["Error: \(BannerCenter.repositoryUnreadableMessage)"],
"a standing banner is announced when it appears — the row's own sentence, tone first")
// The 15 s re-read confirming what is already standing must not say it again.
store.noteRepositoryUnreadable(true)
#expect(spoken.count == 1)
store.noteRepositoryUnreadable(false)
#expect(store.bannerRows.isEmpty)
#expect(spoken.last == "History is recording again")
}
} }
+44
View File
@@ -441,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") @Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
func clearedLock() { func clearedLock() {
var facts = BoardAnnouncer.ReloadFacts() var facts = BoardAnnouncer.ReloadFacts()
+16
View File
@@ -78,6 +78,22 @@ struct BoardGitSectionTests {
#expect(section != .noRepository) #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") @Test("Every posture is reachable, and none of them is two postures")
func theMatrixIsTotal() { func theMatrixIsTotal() {
let resolved = Set( let resolved = Set(
+18
View File
@@ -45,6 +45,19 @@ struct BoardSettingsSectionTests {
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == []) #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") @Test("The sections carry the headers VoiceOver navigates by")
func headersAreNamed() { func headersAreNamed() {
// 10-accessibility.md Board settings sheet: "titled and sectioned with headers VoiceOver // 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. // denied ancestor check is never distinguishable from a repository actually being there.
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable)) #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 // **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 // `.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. // swept for completeness rather than because it can vary.
+70
View File
@@ -583,6 +583,37 @@ struct BranchSwitchSequenceTests {
#expect(GitRepository.branchName(at: fixture.root) == "main") #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 /// "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 /// in-progress banner row a wait that persists implausibly long names the lock path never an
/// error dialog, never a hammer." /// error dialog, never a hammer."
@@ -946,6 +977,45 @@ struct BoardGitBranchSurfaceTests {
).controlsEnabled) ).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") @Test("Before the first read the line is a placeholder, not a guess at a branch name")
func theReadingSurface() { func theReadingSurface() {
let surface = BoardGitBranchSurface.resolve( let surface = BoardGitBranchSurface.resolve(
+166
View File
@@ -68,6 +68,37 @@ private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
return entries.sorted { $0.path < $1.path } 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 // MARK: - Composition
@MainActor @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: "[email protected]")
#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 // MARK: - Add git
@MainActor @MainActor