The seams unbind — the provider is always native, and the git stack compiles dead
Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -288,10 +288,9 @@ public final class BoardStore: HealHost {
|
||||
/// still refresh the card window's thread and board search's comment index. Keying either on
|
||||
/// `snapshotGeneration` would make the value-equal skip a freshness bug rather than an
|
||||
/// optimization.
|
||||
/// - **The auto-committer's covering gate** (`GitAutoCommitter.awaitCoveringSnapshot`): what
|
||||
/// covers a flush is a *walk* that started after its writes hit disk, and a completed walk
|
||||
/// covers them whether or not the tree turned out to differ. A gate waiting on the applied
|
||||
/// counter would spin out its whole deadline on any value-equal landing.
|
||||
/// - **Any covering gate** — a consumer waiting for a walk that started after its writes hit
|
||||
/// disk. A completed walk covers them whether or not the tree turned out to differ, so a gate
|
||||
/// waiting on the applied counter would spin out its whole deadline on any value-equal landing.
|
||||
///
|
||||
/// A failed reload bumps neither counter: it produced no snapshot, so it covers nothing and
|
||||
/// refreshes nothing — the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
|
||||
@@ -471,41 +470,10 @@ public final class BoardStore: HealHost {
|
||||
losses: banners.losses,
|
||||
suspension: banners.historySuspension,
|
||||
operations: banners.operations,
|
||||
signposts: banners.signposts,
|
||||
gitFailures: banners.gitFailures,
|
||||
repositoryUnreadable: banners.isRepositoryUnreadable
|
||||
signposts: banners.signposts
|
||||
)
|
||||
}
|
||||
|
||||
/// **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
|
||||
@@ -569,18 +537,6 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
public weak var history: (any HistoryProviding)?
|
||||
|
||||
/// **Where Pro's auto-committer meets the write and reload paths** (06-history-undo.md ▸ Rules
|
||||
/// ▸ Auto-commit), or `nil` on every board there is no committer for — which is every free-tier
|
||||
/// board and every Pro board without a repository at its root.
|
||||
///
|
||||
/// Injected like `watcherBrackets` and `history`, and for their reason: the committer belongs to
|
||||
/// the *session* (`HistoryStore.committer`), and a store that reached for one would be a second
|
||||
/// answer to which committer a board has. `nil` keeps every method below behaving exactly as it
|
||||
/// did before this milestone — which is what makes the free tier's inert posture structural
|
||||
/// rather than conditional.
|
||||
@ObservationIgnored
|
||||
public var commitSeam: HistoryCommitSeam?
|
||||
|
||||
// MARK: The open card windows
|
||||
|
||||
/// **Which of this board's cards have a card window open right now** — one fact, kept for one
|
||||
@@ -761,28 +717,6 @@ public final class BoardStore: HealHost {
|
||||
@ObservationIgnored
|
||||
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
|
||||
|
||||
/// **Where git path history reaches the loader** (01-storage-format.md ▸ Fractal layout
|
||||
/// ▸ Rules, the duplicate-id winner rule; `BoardLoader.IdentityHistoryRanker`) — `nil` on every
|
||||
/// board the app manages no git for, which is every free-tier board and every Pro board without
|
||||
/// a repo at its root.
|
||||
///
|
||||
/// A **provider** rather than a ranker, for two reasons that point the same way. Each load wants
|
||||
/// its own ranker, so that a load never answers from a history that has moved since the last one
|
||||
/// (the ranker caches internally, once, per load). And add-git flips a board into git mode
|
||||
/// mid-session, which a closure asked at load time absorbs by construction while a value handed
|
||||
/// over at composition never could.
|
||||
///
|
||||
/// `@MainActor` because it is called here, on the main actor, at the head of each reload; what
|
||||
/// it returns is `Sendable` and does its git work off-main, inside the walk that consults it.
|
||||
///
|
||||
/// **The board's first load predates this** — `init` runs inside `BoardStoreRegistry.acquire`,
|
||||
/// before a session exists to compose the git state that supplies it — so an opening board's
|
||||
/// duplicate-id ladder falls through to birth date, and every reload after it consults history.
|
||||
/// Deliberate, and the narrow cost of composing the git state where the design puts it
|
||||
/// (`AppModel.beginSession`) rather than where the first walk happens to run.
|
||||
@ObservationIgnored
|
||||
var makeIdentityHistoryRanker: (@MainActor () -> BoardLoader.IdentityHistoryRanker?)?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
|
||||
|
||||
// MARK: - Init
|
||||
@@ -904,10 +838,6 @@ public final class BoardStore: HealHost {
|
||||
let generation = reloadGeneration
|
||||
let root = rootURL
|
||||
let barrier = loadBarrier
|
||||
// Asked once per load, on the main actor, and answered off it: what comes back is a lazy
|
||||
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
|
||||
// break a tie for. `nil` everywhere the app manages no git.
|
||||
let historyRanker = makeIdentityHistoryRanker?()
|
||||
// **This session's consented skips, on every walk it runs** (`skippedPaths`): the open's
|
||||
// decision stands for the session, so a reload sees the board the user chose to open.
|
||||
let skipping = skippedPaths
|
||||
@@ -928,7 +858,6 @@ public final class BoardStore: HealHost {
|
||||
outcome = .success(try BoardLoader.load(
|
||||
boardRoot: root,
|
||||
skipping: skipping,
|
||||
historyRanker: historyRanker,
|
||||
memo: memo,
|
||||
counter: counter
|
||||
))
|
||||
@@ -982,13 +911,6 @@ public final class BoardStore: HealHost {
|
||||
facts.lockBefore = readOnlyLock
|
||||
facts.breakageBefore = reloadFailure
|
||||
|
||||
// **Whether this reload revealed anything the app does not vouch for** — the one bit the
|
||||
// auto-committer's flush-before-overwrite gate turns on (06 ▸ Rules ▸ Flush-before-overwrite).
|
||||
// A failed reload counts as foreign, conservatively: a file the loader could not read is one
|
||||
// the app certainly did not write, and the safe direction is to let the next app write commit
|
||||
// what is there before overwriting it.
|
||||
var sawForeignChange = false
|
||||
|
||||
switch outcome {
|
||||
case let .success(result):
|
||||
// **What changed, who changed it, and what it cost the cursor** — all three computed
|
||||
@@ -1020,7 +942,6 @@ public final class BoardStore: HealHost {
|
||||
includingTrash: shownTrash
|
||||
)
|
||||
facts.diff = verdicts.foreign
|
||||
sawForeignChange = verdicts.foreign.boardChanged || !verdicts.foreignItems.isEmpty
|
||||
// The vanishing-focus sentence takes the same gate, one rung up the ladder: it says
|
||||
// "deleted *externally*", which would be a lie about an app-mediated delete — whose
|
||||
// own command already chose a successor (04-interactions.md ▸ The map's ⌫ rule) and
|
||||
@@ -1170,7 +1091,6 @@ public final class BoardStore: HealHost {
|
||||
if endsWholesaleOperation, readOnlyLock == nil {
|
||||
readOnlyLock = .bracketedReloadFailed
|
||||
}
|
||||
sawForeignChange = true
|
||||
Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
|
||||
}
|
||||
|
||||
@@ -1181,18 +1101,6 @@ public final class BoardStore: HealHost {
|
||||
facts.lockAfter = readOnlyLock
|
||||
facts.breakageAfter = reloadFailure
|
||||
announce(BoardAnnouncer.speech(for: facts))
|
||||
|
||||
// **The auto-commit debounce, armed by every landing** (06 ▸ Rules ▸ Auto-commit; ▸
|
||||
// Interaction with external writers: "Agent and hand edits arrive through the watcher like
|
||||
// any change and get auto-committed on the same debounce").
|
||||
//
|
||||
// Here rather than at the watcher, deliberately: a reload landing means the tree walk is
|
||||
// over, so the committer never races the loader for the same files. **Unconditional on what
|
||||
// changed**, equally deliberately — a reload lands whether or not the snapshot moved, and the
|
||||
// committer's condition is the *tree*, not the snapshot diff, so a window that touched only
|
||||
// strays or only `CLAUDE.md` still commits (06 ▸ Commit messages ▸ Non-snapshot files commit
|
||||
// too). A landing that finds nothing to commit is the silent no-op, not a wasted trip.
|
||||
commitSeam?.reloadDidLand(sawForeignChange)
|
||||
}
|
||||
|
||||
/// Installs the recovery `BoardAnnouncer` chose for a focus that vanished under a foreign
|
||||
@@ -1448,24 +1356,11 @@ public final class BoardStore: HealHost {
|
||||
if let readOnlyLock {
|
||||
throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
|
||||
}
|
||||
// **Flush-before-overwrite** (06-history-undo.md ▸ Rules), before the bracket rather than
|
||||
// inside it: what the committer may need to do here is *commit*, and a commit taken with the
|
||||
// watcher suspended would be a commit whose own reload never arrives. It is a no-op unless
|
||||
// the window holds a change the app does not vouch for — see `GitAutoCommitter.noteWillWrite`
|
||||
// for the gate, and for the two costs it is recorded as carrying.
|
||||
commitSeam?.willWrite()
|
||||
watcherBrackets?.begin()
|
||||
// `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
|
||||
// and an unbalanced bracket would leave the watcher suspended for the rest of the session.
|
||||
//
|
||||
// **The receipt harvest rides the same defer**, and after `end()` deliberately: the committer
|
||||
// copies the ledger's receipts here because the landing reload *consumes* them, and this is
|
||||
// the last moment they still describe a completed write nothing has classified yet
|
||||
// (`EchoLedger.outstandingEntries`). A partway failure harvests too — bytes that reached disk
|
||||
// are bytes the next commit will carry, whoever they belong to.
|
||||
defer {
|
||||
watcherBrackets?.end()
|
||||
commitSeam?.writeBracketDidClose()
|
||||
}
|
||||
// **The receipt seam** (02-architecture.md ▸ Components ▸ EchoLedger). Binding the ledger
|
||||
// here rather than passing it down is what keeps `BoardWriter` the stateless enum of statics
|
||||
@@ -1535,25 +1430,21 @@ public final class BoardStore: HealHost {
|
||||
try operation()
|
||||
} catch let error as BoardWriteError {
|
||||
// Same honesty rule as `performWrite`, applied to the one error type the banner has
|
||||
// phrasing for. A wholesale operation is usually git's (m7), whose own failure
|
||||
// vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history
|
||||
// condition, the in-progress row swapping for an error — is the committer's to drive;
|
||||
// but a `BoardWriteError` escaping here is an ordinary failed write and may no more
|
||||
// bypass the strip than one from `performWrite`.
|
||||
// phrasing for. A wholesale operation may carry a failure vocabulary of its own, whose
|
||||
// surfacing is that operation's to drive; but a `BoardWriteError` escaping here is an
|
||||
// ordinary failed write and may no more bypass the strip than one from `performWrite`.
|
||||
banners.post(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// The same bracket over work that **awaits** — the undo restore (06-history-undo.md) and, next,
|
||||
/// the branch switch.
|
||||
/// The same bracket over work that **awaits**.
|
||||
///
|
||||
/// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the
|
||||
/// synchronous version above exists because `performWrite`-shaped work is synchronous, while a
|
||||
/// git operation is a detached libgit2 task the main actor must not block on
|
||||
/// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion
|
||||
/// phrase in one place; the distinct argument label is what keeps overload resolution from having
|
||||
/// to guess which one a trailing closure meant.
|
||||
/// wholesale operation can be a detached task the main actor must not block on. Both keep the
|
||||
/// bracket, the reload floor and the completion phrase in one place; the distinct argument label
|
||||
/// is what keeps overload resolution from having to guess which one a trailing closure meant.
|
||||
///
|
||||
/// The refusal, the ordering and the arming are the synchronous version's, unchanged — see its
|
||||
/// doc comment for all three.
|
||||
|
||||
Reference in New Issue
Block a user