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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user