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