diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 844b999..eae9e15 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -344,11 +344,12 @@ public final class AppModel { /// **Read once per session, at composition, and never again** (12 ▸ The entitlement: "a lapse /// never interrupts an open session"). `beginSession` is the only caller. /// - /// **Dormant since PIVOT 2026-08-07** (12-editions.md — git left the paywall): the answer is - /// still read and still recorded on the session (`BoardSession.tier`), and **nothing git-shaped - /// consults it any more** — `HistoryStore.compose` and `makeHistoryProvider` both lost the axis. - /// The seam is kept unchanged, mechanics and all, because the entitlement is correct for - /// whatever the next base/Pro split turns out to gate; what it does not do is decide undo. + /// **Dormant since PIVOT 2026-08-07** (12-editions.md — git left the paywall, and the 2026-08-08 + /// ruling then excised git entirely): the answer is still read and still recorded on the session + /// (`BoardSession.tier`), and **nothing consults it any more** — `makeHistoryProvider` lost the + /// axis, and the stack it used to gate has gone. The seam is kept unchanged, mechanics and all, + /// because the entitlement is correct for whatever the next base/Pro split turns out to gate; + /// what it does not do is decide undo. @ObservationIgnored public var currentTier: @MainActor () -> Tier = { .free } @@ -357,68 +358,39 @@ public final class AppModel { /// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a /// board session's undo stack is built by, called once per board as its session begins. /// - /// **The provider follows the board — and, since PIVOT 2026-08-07, the board alone** - /// (re-ruled 2026-07-31 — 12 ▸ The provider seam; 13-native-undo.md's header; 06 ▸ Rules; then - /// 12 ▸ PIVOT 2026-08-07, git leaves the paywall). A board's substrate is decided by what the - /// board *is*. The 2026-07-31 re-ruling took the tier out of every row but one — it still decided - /// whether git was on the table at all — and the pivot took it out of that one too: "every tier - /// composes the git stack on git-mode boards exactly as Pro did". The rule the re-ruling replaced - /// bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, which made - /// subscribing *remove* undo from a mode-none board — an upgrade that takes a feature away. + /// **Every board gets the native stack, and the seam has one answer** + /// (13-native-undo.md's header; `strategy/01-git-excision.md`, ruled 2026-08-08 — the app-managed + /// git substrate is excised, so `Kanban/History/` is the only one there is). Nothing about a board + /// decides this any more: not its tier (12 ▸ PIVOT 2026-08-07 took the last row the tier decided), + /// not whether it sits inside somebody's repository, not what is on disk beside it. A board + /// nobody has done anything special to and a board living in a user's git repo bind the same + /// stack, which was already true before this ruling and is now true by construction. /// - /// It takes the store because that is what a provider is a history *of*: the git provider needs - /// the board root it is a repository at, and the native one's steps are computed from the same - /// store's snapshots. A property rather than an initializer argument so a test - /// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because - /// nothing renders from it. + /// ### Why it is still a seam /// - /// ### The two answers, and the two `nil`s that are no longer among them + /// Deliberately kept, and the excision plan says so in as many words (▸ Reversibility: "the seams + /// the git stack plugged into … are all nil-safe/default-native and are **kept**, so a future + /// provider — journal, ops service, or even git again — re-binds without re-plumbing"). Two things + /// it buys today: a test binds a fake substrate — or a substrate-less board — without a second + /// `AppModel` initializer, and the day a second provider exists it arrives as a different default + /// here rather than as a branch threaded through the session. /// - /// - **Mode `git`** — the git provider: undo as forward restore commits over HEAD's first-parent - /// ancestry (06). **In every tier** since the pivot; this row used to read "Pro only — no other - /// tier composes a git state", because `HistoryStore.compose` answered `nil` off Pro without so - /// much as a `stat`. - /// - **Mode `none`, `repoNested`, and `unverifiable` alike** — the **native stack**. "Boards - /// without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in - /// **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog, re-ruled 2026-07-31 twice; 12 ▸ The - /// provider seam; 13's header). `unverifiable` joins the same branch structurally — a denial - /// can never be told apart from a repository actually being there, so it takes `repoNested`'s - /// posture, undo included (06 ▸ Rules ▸ Detection). This is where a board nobody opted git in - /// for lands, permanently: opt-in per board is exactly what the pivot left standing (13's - /// header, read through 12 ▸ PIVOT 2026-08-07). - /// - **No `HistoryStore` at all** — the argument stays optional for the seam's own reason (a test - /// binds a git-stateless board through it), and no session composes one: `compose` returns a - /// store for every board there is. It answers the native stack, which is what it always - /// answered for the free tier that used to arrive this way. + /// It takes the store because that is what a provider is a history *of*: the native stack's steps + /// are computed from that store's snapshots, and a provider that needed the board root would find + /// it there too. `@ObservationIgnored` because nothing renders from it. /// - /// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone - /// stance "concerns *git*, and this stack never touches git — memory-only, journal-free, - /// session-scoped — so what repo-nested denies is app-managed history, never ⌘Z" (13's header). - /// It also made the Pro upgrade story exceptional, which was the other half of the same defect: - /// the free tier could not tell such a board from a plain one and bound the native stack anyway, - /// so subscribing *removed* undo from exactly the boards it left alone. Edit ▸ Undo/Redo and the - /// toolbar pair now disable only under a lock and on an empty stack. - /// - /// This closure therefore never answers `nil`, and the seam stays optional for the seam's own - /// reason: a test binds a substrate-less board through it (`BoardUndoManager.history`). - /// - /// The `HistoryStore` argument is what makes the git/gitless split decidable here, and it is why - /// `beginSession` composes the git state *before* the provider: which substrate a board gets is a - /// question about its repository, and a root that had to ask the disk itself would be a second - /// detection. + /// **The production closure never answers `nil`**, and the optionality is the seam's rather than a + /// board's: `nil` means "no undo at all", which no board the app composes is + /// (`BoardUndoManager.history` answers the empty way over an absent substrate, which is what makes + /// a test able to bind one). /// /// ### Consumers /// - /// `beginSession` calls it once per board, and `bindHistoryProvider(for:)` calls it again on the - /// one event that changes a board's answer under an open session — add-git's commanded flip, - /// which swaps `none`'s native stack for `git`'s trail. + /// `beginSession`, once per board. Nothing re-binds a live session's substrate: the one event that + /// used to — add-git's commanded mid-session mode flip — went with the git stack. @ObservationIgnored - public var makeHistoryProvider: (BoardStore, HistoryStore?) -> (any HistoryProviding)? = { store, git in - guard let git else { return NativeHistoryProvider() } - switch git.mode { - case .git: return GitHistoryProvider(boardRoot: store.rootURL) - case .none, .repoNested, .unverifiable: return NativeHistoryProvider() - } + public var makeHistoryProvider: (BoardStore) -> (any HistoryProviding)? = { _ in + NativeHistoryProvider() } // MARK: Sessions @@ -440,23 +412,21 @@ public final class AppModel { /// window). It lives here for the store's reason exactly: the session is what every window /// over this board shares, and "undo is board-local". /// - /// Which implementation it is, is the board's answer and nobody else's - /// (12-editions.md ▸ The provider seam, read through ▸ PIVOT 2026-08-07) — see - /// `AppModel.makeHistoryProvider`. + /// Which implementation it is comes from one place — see `AppModel.makeHistoryProvider`, + /// which since the 2026-08-08 git excision has exactly one answer for every board. /// - /// **`nil` is a board with no undo at all, and no board the app composes is one any more** - /// (re-ruled 2026-07-31 — see `AppModel.makeHistoryProvider`): boards without app-managed git, - /// repo-nested included, bind the native stack in every tier, and git boards bind the git - /// provider. What keeps the optionality is the seam rather than a board: a test binds a - /// substrate-less session through `makeHistoryProvider`, and a store with no session at all - /// registers nothing (`BoardStore.registerStep`). The command surface disables through - /// `undoManager`, which answers the empty way over an absent substrate. + /// **`nil` is a board with no undo at all, and no board the app composes is one**. What keeps + /// the optionality is the seam rather than a board: a test binds a substrate-less session + /// through `makeHistoryProvider`, and a store with no session at all registers nothing + /// (`BoardStore.registerStep`). The command surface disables through `undoManager`, which + /// answers the empty way over an absent substrate. /// - /// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single - /// sanctioned mid-session mode flip, *swaps* the substrate here on the board it flips — - /// native out, git in, the in-session steps discarded with it - /// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter, - /// and since PIVOT 2026-08-07 it has no say in this either. + /// A `var` rather than a `let` beside `tier`, and now for no event at all: the one sanctioned + /// mid-session substrate swap was add-git's commanded mode flip, which went with the git + /// stack. It stays a `var` because a second provider is a live possibility + /// (`strategy/01-git-excision.md` ▸ Reversibility) and because nothing is bought by tightening + /// it; a tier lapse could never touch it — `tier` has no setter, and since PIVOT 2026-08-07 it + /// has no say in this either. public var history: (any HistoryProviding)? /// **The tier this board composed under** (12-editions.md ▸ The entitlement) — recorded, @@ -467,46 +437,14 @@ public final class AppModel { /// nothing anywhere that re-evaluates a live session's tier, so a subscription ending /// mid-session is a fact about the *next* open and about nothing already on screen. /// - /// **Nothing git-shaped reads it.** Git left the paywall (12 ▸ PIVOT 2026-08-07), so the - /// substrate, the committer, the branch controls and the History surfaces are all decided by - /// the board's mode alone. It stays recorded because the entitlement's machinery stays built - /// and correct for whatever the re-decided base/Pro split gates, and because the fact a board - /// opened under is a composition-time answer the way the provider binding is: whatever asks - /// next must get what this board opened with, never what the entitlement says at render time. + /// **Nothing reads it.** Git left the paywall (12 ▸ PIVOT 2026-08-07) and then left the app + /// (`strategy/01-git-excision.md`, ruled 2026-08-08), so no surface anywhere is decided by + /// this. It stays recorded because the entitlement's machinery stays built and correct for + /// whatever the re-decided base/Pro split gates, and because the fact a board opened under is + /// a composition-time answer the way the provider binding is: whatever asks next must get what + /// this board opened with, never what the entitlement says at render time. public let tier: Tier - /// **This board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components - /// ▸ HistoryStore) — the detected mode, the repository behind it in git mode, and the - /// add-git action the popover offers on a board that has none. - /// - /// **Never `nil` on a session the app composes**, since PIVOT 2026-08-07 (12-editions.md): - /// `HistoryStore.compose` runs detection for every board in every tier and returns a store. - /// It used to answer `nil` under the free tier — the inert-`.git` posture made structural - /// rather than remembered — and that posture is retired. The optionality survives for the - /// seam's sake alone: a test binds a git-stateless session through `makeHistoryProvider`. - /// - /// A `let` beside `tier`, for `tier`'s reason: which board this is a git story *of* is - /// settled at composition and cannot change under an open session. What can change is the - /// mode *inside* it, by add-git alone — the one commanded mid-session flip 06 allows. - public let git: HistoryStore? - - /// The mode this board is being edited in, `none` when there is no git state at all — which - /// no session the app composes is any more (see `git` above), and which a board nobody has - /// added git to detects anyway. - /// - /// **Its first consumer is the provider seam** — `makeHistoryProvider` reads exactly this to - /// know whether the board has a repository to be an undo stack for, and it is the *mode* and - /// nothing else that decides (re-ruled 2026-07-31; the tier's last say went with PIVOT - /// 2026-08-07): `git` binds the git provider, `none` and `repoNested` alike the native stack. - /// The popover's git section is the other reader — and the one place the two gitless modes - /// still differ, since add-git is offered on one and explained away on the other. - /// - /// `@MainActor` because the state it reads is: a nested type does not inherit its enclosing - /// type's isolation, and everything that asks a session what mode it is in is main-actor - /// work anyway (a menu, a popover, a provider being composed). - @MainActor - public var gitMode: BoardGitMode { git?.mode ?? .none } - /// The same stack, wearing the face AppKit needs (`BoardUndoManager`): what this board's /// windows hand back from `windowWillReturnUndoManager`, so the Edit menu's Undo/Redo rows /// and the toolbar's pair resolve to *this* board through the ordinary responder chain. @@ -903,115 +841,15 @@ public final class AppModel { // gains no network dependency." Synchronous, over facts already in memory. It is the *only* // time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it. // - // **Recorded, and consulted by nothing below** (12 ▸ PIVOT 2026-08-07 — git left the - // paywall). This line used to sit on the same line as the git state it gated; the gate is - // gone, and what is left is a dormant fact kept for the base/Pro split still to be ruled. + // **Recorded, and consulted by nothing below** (12 ▸ PIVOT 2026-08-07 — git left the paywall; + // `strategy/01-git-excision.md`, ruled 2026-08-08 — git left the app). This line used to sit + // on the same line as the git state it gated; both the gate and the state are gone, and what + // is left is a dormant fact kept for the base/Pro split still to be ruled. let tier = currentTier() - // **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board - // open") — unconditionally, in every tier. One `stat` per open, freshly, so a board that - // gained or lost a `.git` since its last open opens in the mode it now has. Until the - // 2026-08-07 pivot this call took the tier and answered `nil` under `.free`, which bought - // the free tier a *zero*-stat open (the inert-`.git` posture); that promise is retired with - // the posture, and every open now pays what Pro's opens always paid. - // - // Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The - // running session keeps its mode, and the watcher does not scan for `.git` appearing." - // - // **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends - // on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the - // disk itself would be a second detection able to disagree with this one. - // - // **The ledger is the store's own** (06 ▸ Interaction with external writers: "the Writer/echo - // machinery — the EchoLedger — lets the auto-committer classify every observed change, per - // file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less - // callers (the add-git surface, unit tests), and a session that took that default would hand - // the committer a ledger nothing ever writes to: every commit this app made would classify - // foreign and be authored `Lanework External`. The default is a fallback, never this path's. - let git = HistoryStore.compose(boardRoot: store.rootURL, ledger: store.echoes) // The board's stack is born here, with the session that owns it, and dies in `tearDown` // below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with - // the board session and dies at close/quit ... standard macOS behavior". On a git-mode board - // it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo - // survives relaunch) — the seam's whole point. - let history = makeHistoryProvider(store, git) - // **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal - // layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker, - // everything else keeps injecting nothing. A *provider* rather than a ranker because each - // load wants its own — see `BoardStore.makeIdentityHistoryRanker` — and because add-git - // flips the mode mid-session, which this closure picks up for free by asking the git state - // at the moment of each load rather than at composition. - store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker } - // **The auto-commit engine, wired into the session it commits for** (06-history-undo.md - // ▸ Rules ▸ Auto-commit). Called on every session and not only on git-mode ones, because - // add-git can flip a board mid-session and the committer it builds then must land in - // exactly this shape — `activateAutoCommit` remembers the wiring for that. - git.activateAutoCommit { [weak store] committer in - guard let store else { return } - committer.currentSnapshot = { [weak store] in store?.snapshot } - // **The flush awaits the snapshot that covers it** (06 ▸ Rules ▸ Auto-commit, ruled - // 2026-07-31): "the composer diffs `store.snapshot` against HEAD, so the close flush - // awaits a snapshot generation covering its changed paths before the committer runs — - // the commit's subject can never be outrun by its own reload". Both halves of that - // await are reads of the store the composer is already diffing, which is why they are - // wired here rather than reached for: the engine holds the *policy* (when to wait, how - // long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`). - // - // The generation the gate counts is `landedReloads` — completed *walks* rather than - // applied snapshots — because a value-equal reload skips the assignment and its - // counter since 2026-07-31, and a walk covers a flush's paths whether or not it found - // anything to change (`BoardStore.landedReloads`). - committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() } - committer.landedReloads = { [weak store] in store?.landedReloads } - // 02-architecture.md ▸ Write-failure surfacing, through the strip the board window - // already renders: a genuine commit failure means "your edits are saved, history has - // stopped advancing", which is exactly what the standing suspension row says. Lock - // contention and a held repository never reach here — neither is a failure. - committer.reportFailure = { [weak store] failure in - store?.banners.suspendHistory(reason: failure.message) - } - 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, - // live; ▸ Heal commits are transparent to undo). Bound here rather than in - // `wireGitUndo` because add-git builds a *new* committer, and this wiring is what - // `activateAutoCommit` remembers on its behalf. - committer.reportLanded = { [weak self, ref] window in - guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return } - provider.noteLanded(window) - } - } - // **Add-git swaps the undo substrate too** (06 ▸ Rules ▸ Detection — the one commanded - // mid-session mode flip; 13-native-undo.md's header — "discards the in-session native - // stack and seeds the git trail from the root commit"). See `bindHistoryProvider(for:)`. - git.didAddGit = { [weak self] in - self?.bindHistoryProvider(for: ref) - } - // **The form-anchored posture's fallback half** (06 ▸ Interaction with external writers, - // ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead - // when that form has been dismissed before the answer arrived — "inline is the primary - // surface, never a silence trap". The banner enumeration is the same one branch switch and - // undo restore post into, one row per failure. - 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 board session and dies at close/quit ... standard macOS behavior". + let history = makeHistoryProvider(store) // **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 // methods — so it is the store that computes each inverse and registers it. What it cannot @@ -1024,7 +862,6 @@ public final class AppModel { recordID: recordID, history: history, tier: tier, - git: git, // The lock's enablement half (13-native-undo.md ▸ Rules): Undo and Redo disable with the // other mutating commands while the board refuses writes, and the stack survives to // resume when it clears. Weak, so the adapter is never the reason a closed board's store @@ -1036,183 +873,19 @@ public final class AppModel { cardRefs: [], access: access ) - wireGitUndo(history, store: store, git: git, ref: ref) clearLaunchFailures(naming: [ref.path, store.rootURL.path]) refreshRecents() } - // MARK: - The git provider's wiring - - /// Fills a `GitHistoryProvider`'s seams with the session it is the history of — and does nothing - /// at all for any other substrate. - /// - /// Everything the git provider needs is a fact about *this* board that neither a repository nor a - /// protocol could supply: which committer's debounce to settle first, whether the git surface is - /// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree - /// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason — the provider - /// stays a thing that knows about commits, and the model stays the only object that knows what a - /// window is. - private func wireGitUndo( - _ history: (any HistoryProviding)?, - store: BoardStore, - git: HistoryStore?, - ref: BoardWindowRef - ) { - guard let provider = history as? GitHistoryProvider, let git else { return } - - provider.flushPendingCommit = { [weak git] in - await git?.committer?.flushNow() - } - provider.isHeld = { [weak git] in git?.committer?.pause != nil } - provider.suspendCommitting = { [weak git] in git?.committer?.stop() } - // The stage-around the settle released comes back with the committer: a card window still open - // after the restore is still a session (`resumeCardSessionStaging(for:)`). - provider.resumeCommitting = { [weak self, weak git] in - git?.committer?.start() - self?.resumeCardSessionStaging(for: ref) - } - // **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a - // one-shot banner failure naming the operation and the error, the tree left as it was") — - // now literally that, at the failure rank in the error tone (02 ▸ The banner surface, settled - // 2026-07-31: the one-shot class's second, message-carrying shape). The loss-row compromise - // this line used to carry is retired: a ⌘Z that didn't happen is an action that didn't - // happen, not content that didn't arrive. - // - // The closure passes the *direction* and libgit2's own message and stops there — "Undo - // failed — …" is BannerCenter's sentence, from the closed `GitOperation` vocabulary. - provider.reportFailure = { [weak store] direction, failure in - store?.banners.postGitFailure(.restore(direction), reason: failure.message) - } - provider.runBracketed = { [weak store] subject, work in - guard let store else { return await work() } - // The completion phrase 10-accessibility.md gives a bracketed operation is the restore's - // own subject — the sentence the trail now carries, spoken once when the reload lands. - try? await store.performWholesale(announcing: subject) { await work() } - } - provider.settleSessions = { [weak self, weak provider] paths in - guard let self, let provider else { return .proceed } - let gate = self.settleGate(for: ref) { [weak provider] folder in - // The card's uncommitted on-disk saves are reverted by the restore itself, which - // compares this folder against the working tree rather than against HEAD — see - // `GitRestoreOperation.plan`. - provider?.noteDiscarded(cardFolderName: folder) - } - let outcome = await gate.settle(touching: paths) - // **Only on `.proceed`** — a cancelled or failed settle leaves the board exactly as it - // was, sessions and their staging included. - if outcome == .proceed { self.releaseCardSessionStaging(for: ref) } - return outcome - } - provider.seed() - - wireBranchSwitching(git: git, store: store, provider: provider, ref: ref) - } - - /// **The branch controls' seams** (06-history-undo.md ▸ Branch switching) — the five things the - /// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an - /// interrupted switch recognizable as this app's. - /// - /// Wired beside the undo provider's rather than in a place of its own, because the two are the - /// same board's git session seen from two sides — and because both must be re-wired on exactly the - /// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`). - private func wireBranchSwitching( - git: HistoryStore, - store: BoardStore, - provider: GitHistoryProvider, - ref: BoardWindowRef - ) { - guard let switcher = git.switcher else { return } - let recordID = sessions[ref]?.recordID - - switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() } - switcher.isHeld = { [weak git] in git?.committer?.pause != nil } - switcher.suspendCommitting = { [weak git] in git?.committer?.stop() } - // As on the restore path: what the settle released is a session that has not ended, and the - // window is still open on the other side of the checkout. - switcher.resumeCommitting = { [weak self, weak git] in - git?.committer?.start() - self?.resumeCardSessionStaging(for: ref) - } - // **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by - // construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo - // starts empty". - switcher.reseedUndo = { [weak provider] in await provider?.reseed() } - switcher.didSwitch = { [weak git] in await git?.refreshBranch() } - switcher.runBracketed = { [weak store] announcement, work in - guard let store else { return await work() } - try? await store.performWholesale(announcing: announcement) { await work() } - } - switcher.beginProgress = { [weak store] label in - store?.banners.beginOperation(label: label) ?? UUID() - } - switcher.updateProgress = { [weak store] id, label in - store?.banners.updateOperation(id, label: label) - } - switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) } - // The failure rank's git shape, as on the restore path above: a switch that didn't happen is - // an action that didn't happen ("Couldn't switch branches — …", BannerCenter's words from - // the operation alone). - switcher.reportFailure = { [weak store] failure in - store?.banners.postGitFailure(.branchSwitch, reason: failure.message) - } - // **The recovery notice stays a loss row**, and the ruling is explicit about why (02 ▸ The - // banner surface): "recovery notices report a success, not a failure, and stay warning-tone". - // "A branch switch was interrupted — the previous state is restored" is the app tidying up - // after itself, with nothing for the user to do — the loss class's own register. - switcher.reportRecovery = { [weak store] message in - store?.banners.postLoss(message) - } - // **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no - // record — a store-level test — simply carries no stamp, and recovery then has nothing to - // recognize, which is the honest answer for a board the app has no state for. - switcher.readStamp = { [weak self] in - guard let self, let recordID else { return nil } - return self.boardRegistry.gitOperationStamp(id: recordID) - } - switcher.writeStamp = { [weak self] stamp in - guard let self, let recordID else { return } - self.boardRegistry.setGitOperationStamp(id: recordID, stamp) - } - switcher.settleSessions = { [weak self, weak switcher] in - guard let self, let switcher else { return .proceed } - let gate = self.settleGate( - for: ref, - message: SessionSettleStep.branchSwitchMessage - ) { [weak switcher] folder in - switcher?.noteDiscarded(cardFolderName: folder) - } - // Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`. - let outcome = await gate.settleAll() - // The switch's flush runs next and must find a tree it can settle whole — see - // `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough. - // - // **And the fine undo stacks go with it** (06 ▸ Branch switching, ruled 2026-07-31): the - // same `.proceed`, the same seam, for the same reason one rung up — what a window is - // holding describes the branch being left. "Cancel keeps the current branch and the - // sessions" is this `if`, unchanged: a cancelled or failed settle clears nothing, exactly - // as it releases nothing. - if outcome == .proceed { - self.releaseCardSessionStaging(for: ref) - self.discardCardWindowUndoStacks(for: ref) - } - return outcome - } - - // **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the - // committer's start, which is where a pause first becomes knowable, and before anything the - // user does can land on top of a half-finished checkout. - Task { await switcher.recoverInterruptedOperation() } - } - /// **The three buttons, as a seam** — `SessionSettleStep.ask(message:)` in production. /// /// `SessionSettleGate` already keeps the presentation behind a closure for its own reason /// ("presenting three buttons is AppKit's job and cannot be asserted without a display … the /// presentation is a seam and the decision is testable"), and every gate this model builds pointed /// that closure straight at the alert — so the *composition* around the gate, which is what - /// `releaseCardSessionStaging(for:)` and `discardCardWindowUndoStacks(for:)` hang off, could only - /// be exercised by a board with nothing to settle. Lifting the ask one level up is what lets a test - /// answer Save All, Discard and Cancel over real card windows without a modal on screen. + /// `discardCardWindowUndoStacks(for:)` hangs off, could only be exercised by a board with nothing + /// to settle. Lifting the ask one level up is what lets a test answer Save All, Discard and Cancel + /// over real card windows without a modal on screen. /// /// `@ObservationIgnored` because nothing renders from it, and internal because it is a test seam /// rather than API: production never assigns it. @@ -1222,18 +895,20 @@ public final class AppModel { } /// **The save-or-discard step for one board**, built from its open card windows - /// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching). + /// (`SessionSettleGate`). /// /// Built per ask rather than stored, because its whole content is "which card windows are open /// right now" — a set that changes under any operation slow enough to need the step at all. /// + /// **No production caller today** — the git restore and branch switch were the two, and both went + /// with the git stack (`strategy/01-git-excision.md`). The composition is kept beside the gate it + /// composes, for the gate's own reason: it is what the next wholesale operation binds to. + /// /// - Parameters: - /// - message: what the step says it is about. The two callers describe different consequences — - /// a restore changes the cards being edited, a switch replaces them — and 06 gives the step to - /// both without giving either the other's wording. + /// - message: what the step says it is about. Different operations describe different + /// consequences, and a step that described the wrong one would be a worse modal than none. /// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the - /// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it - /// into its plan; the switch reverts before it flushes). + /// gate can reconcile that folder's already-written bytes its own way. func settleGate( for ref: BoardWindowRef, message: String = SessionSettleStep.message, @@ -1272,62 +947,6 @@ public final class AppModel { ) } - /// **Swaps the board's undo substrate onto an already-open session** — add-git's one caller. - /// - /// 06 ▸ Rules ▸ Detection sanctions exactly one mid-session mode flip, the app's own add-git: - /// "clicking it flips the open board into git mode immediately — the popover flows straight into - /// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what - /// that does to undo: "**Add-git swaps the substrate mid-session** — the commanded flip discards - /// the in-session native stack and seeds the git trail from the root commit, the branch-switch - /// discard-and-reseed precedent applied". - /// - /// ### The discard is the whole of it — there is no migration - /// - /// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real - /// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are - /// in-memory inverse operations against a board that has just acquired a commit trail, and - /// replaying one after the swap would walk the board back across a change the root commit already - /// records as the baseline. The branch-switch precedent says the same thing about the same - /// question — "the undo/redo stack does not survive a switch. It is discarded and reseeded from - /// the new HEAD's first-parent ancestry … redo starts empty" (06 ▸ Branch switching) — so the old - /// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`) - /// walks a trail whose only commit is the root, which is the stack's floor and not a step: ⌘Z is - /// correctly empty the instant the flip lands. - /// - /// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git - /// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard - /// has none; what the user sees is the Edit rows and the toolbar pair revalidating through - /// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement - /// change on this board rides. Nothing here posts a banner, and nothing should. - /// - /// ### Why live rather than at the next open - /// - /// A judgment call, recorded when the free-tier matrix still left this board with no provider at - /// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit` - /// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with - /// the provider it composed" was a rule about a **tier** lapsing, which never could change a - /// running session — and since PIVOT 2026-08-07 no longer changes the *next* open either, since - /// a git board composes the git provider in every tier; and a board that visibly starts - /// accumulating commits while ⌘Z answers from a - /// stack the repository knows nothing about would read as a defect rather than as a policy. - /// - /// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but - /// `none`, and flips to `.git` before it fires `didAddGit`. - func bindHistoryProvider(for ref: BoardWindowRef) { - guard var session = sessions[ref], let git = session.git, git.mode == .git else { return } - guard let history = makeHistoryProvider(session.store, git) else { return } - guard history !== session.history else { return } - // Before the reassignment, while `session.history` is still the substrate being replaced: the - // in-flight native steps go with it, and any closure that outlives this line finds an empty - // stack rather than inverses against a pre-repository board. - session.history?.clear() - session.history = history - sessions[ref] = session - session.store.history = history - session.undoManager.history = history - wireGitUndo(history, store: session.store, git: git, ref: ref) - } - /// Registers a card window with its board's session, so the close flush can find it. /// /// A card window whose board has no session is a card window with no board — the ownership rule @@ -1340,112 +959,32 @@ public final class AppModel { } sessions[ref.board]?.cardRefs.insert(ref) cardSessions[ref] = session - // **The window *is* the commit unit** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31), so the - // stage-around opens here — with the window — rather than at the body's first Edit→Preview - // flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land - // in an interim commit. - setCardSession(true, for: ref) } func unregisterCardWindow(_ ref: CardWindowRef) { sessions[ref.board]?.cardRefs.remove(ref) cardSessions[ref] = nil - // **The close flush's release** — and, for a window that left without its session ending (a - // crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a - // card folder excluded from staging forever. Both are the same line because both mean the same - // thing: this window is no longer holding its folder back. - setCardSession(false, for: ref) } - /// Tokens the committer knows each card window's session by. Beside `cardSessions` for its - /// reason: this is the seam table's third column, written only here. - @ObservationIgnored - private var cardSessionTokens: [CardWindowRef: UUID] = [:] - - /// **A card window's session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit: the - /// committer "stages around the whole open card folder"). + /// **A wholesale operation's settle empties every open card window's fine undo stack.** /// - /// This is the honest seam between the two halves of the rule: the host knows a window exists, the - /// committer knows what staging is, and only the app model knows which board a card window belongs - /// to and how to reach its committer. A board with no committer — any board nobody has added git - /// to, which since PIVOT 2026-08-07 is the only such board there is (12-editions.md) — records - /// nothing, which is the same `nil` every other git seam takes. + /// The rule the branch switch established, kept as a rule about *any* operation that replaces the + /// tree under an open window: pre-operation steps describe a state that has gone, so Save All and + /// Discard alike end with every window's stack empty — the board-stack discard-and-reseed + /// precedent one level down; the windows stay open with fresh stacks. Cancel clears nothing, which + /// is why this hangs off a `.proceed` at the call site rather than off the ask. /// - /// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be - /// moved into the trash, in the middle of a session, and what must be staged around is wherever - /// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans - /// both containers, which is exactly why the body save uses it too. + /// **The board stack is not touched here**, and it is not an omission: an operation that replaces + /// the tree reseeds the board's stack itself, after the write that decides what to reseed *from*. + /// Doing it here would be the same discard at the wrong moment. /// - /// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume), - /// and closing an already-closed session reaches a committer that has nothing to remove. - func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) { - guard isOpen else { - // **The token goes whether or not there is anyone left to tell.** A card window's own - // teardown can land after its board's, and a token kept past the board it names would - // outlive everything that could ever release it. - guard let token = cardSessionTokens.removeValue(forKey: ref) else { return } - sessions[ref.board]?.git?.committer?.endCardSession(token) - return - } - guard let session = sessions[ref.board], let committer = session.git?.committer else { return } - let token = cardSessionTokens[ref] ?? UUID() - cardSessionTokens[ref] = token - let cardID = ref.cardIdentity - committer.beginCardSession(token) { [weak store = session.store] in - guard let store, - let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil } - return path.folder(under: store.rootURL) - } - } - - /// **The stage-around releases at the settle step** (06 ▸ Branch switching; ▸ Rules ▸ Undo restore - /// vs open Edit sessions) — every open card window on this board, unconditionally. + /// **A diff-shaped operation deliberately does not call this.** One that materializes only a diff + /// leaves a window's steps describing a state that is still mostly there; what protects them is + /// 13-native-undo.md's field-level staleness predicate, a per-step question rather than a + /// wholesale one. /// - /// ### Why unconditionally, rather than through the modal - /// - /// The save-or-discard step asks about *buffers* — "unsaved keystrokes, or on-disk ~700 ms saves - /// the session hasn't committed" — and a window that is merely open, with a comment posted an hour - /// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around - /// that window is still holding its whole folder out of every commit, so leaving it held would - /// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with - /// sessions settled the restore runs on a settled tree … it cannot fail dirty". - /// - /// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate: - /// Save All's flush then carries the session's commit, and Discard's reverted bytes are - /// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is - /// why this runs *after* the gate has answered rather than before it. - func releaseCardSessionStaging(for ref: BoardWindowRef) { - for cardRef in sessions[ref]?.cardRefs ?? [] { - setCardSession(false, for: cardRef) - } - } - - /// **The branch switch's settle empties every open card window's fine undo stack** - /// (06-history-undo.md ▸ Branch switching, ruled 2026-07-31). - /// - /// > "The settle also clears each open card window's fine undo stack: pre-switch steps describe - /// > the branch being left — Save All and Discard alike end with every window's stack empty, the - /// > board-stack discard-and-reseed precedent one level down; the windows stay open, following - /// > their cards onto the new branch with fresh stacks." - /// - /// ### Why here, beside the staging release - /// - /// Because it is the same fact about the same moment. `releaseCardSessionStaging(for:)` lets go of - /// what a window is holding *on disk*; this lets go of what it is holding *in memory*, and both - /// are true of a session whose branch is about to be replaced under it. Running them from one - /// `.proceed` is also what makes "Cancel clears nothing" a property of one `if` rather than a rule - /// two call sites have to keep in step (`wireBranchSwitching`). - /// - /// **The board stack is not touched**, and it is not an omission: the switch discards and reseeds - /// it from the new HEAD's first-parent ancestry a few steps later, inside the bracket - /// (`GitBranchSwitcher.reseedUndo` → `GitHistoryProvider.reseed`). Doing it here would be the same - /// discard, one level up, at the wrong moment — before the checkout that decides what to reseed - /// *from*. - /// - /// **The restore path deliberately does not call this.** An undo restore materializes a diff and - /// leaves the branch where it is, so a window's steps still describe the branch they were made on; - /// what protects them there is 13-native-undo.md's field-level staleness predicate, which is a - /// per-step question rather than a wholesale one. + /// **No production caller today** — it went with the branch switch (`strategy/01-git-excision.md`) + /// — and it is kept beside `settleGate(for:message:didDiscard:)` for that gate's reason. /// /// The downcast is the honest shape rather than a shortcut: `CardSessionFlushing` is the *close /// flush's* seam — end the session, say whether it holds unsaved content, offer the settle's two @@ -1457,18 +996,6 @@ public final class AppModel { } } - /// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the - /// operation behind the settle has finished with the tree. - /// - /// A window that is still open after a restore or a branch switch is still a session, and its - /// folder must go back to being staged around. Idempotent, so the paths that resume without ever - /// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup. - func resumeCardSessionStaging(for ref: BoardWindowRef) { - for cardRef in sessions[ref]?.cardRefs ?? [] { - setCardSession(true, for: cardRef) - } - } - // MARK: - Launch failures /// Records a board that could not be opened. Deliberately additive and never cleared on success: @@ -1629,11 +1156,10 @@ public final class AppModel { /// /// ### Declining costs nothing, today least of all /// - /// A board that says Not Now keeps the substrate it composed with, and since the provider follows - /// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either - /// way — declining costs undo nothing at all, and costs a git board only the trail it would have - /// gained at its next open. The offer exists because "subscribe takes effect at each board's next - /// open" (12 ▸ The entitlement) needs one, not because anything breaks without it. + /// A board that says Not Now keeps the substrate it composed with, and since every board composes + /// the same one (`makeHistoryProvider`), that costs undo nothing at all. The offer exists because + /// "subscribe takes effect at each board's next open" (12 ▸ The entitlement) needs one, not + /// because anything breaks without it. public func reopenOpenBoards() async { // Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s. let refs = sessions.keys.sorted { $0.path < $1.path } @@ -1681,15 +1207,6 @@ public final class AppModel { }, endCardSession: { [weak self] cardRef in await self?.cardSessions[cardRef]?.endSession() - // **The release, here rather than only at the host's unregister** (06 ▸ Rules ▸ - // Auto-commit). The unregister does release it — that is what closes a window on its - // own — but it arrives from the *window's* teardown, which this sequence waits for - // only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window - // was slow to disappear would then flush with the folder still staged around and leave - // a settled session uncommitted, which is precisely what "nothing settled is ever left - // ... uncommitted by closing" forbids. Ending the session is this step's own act, so - // releasing what the session held is too. Idempotent with the unregister. - self?.setCardSession(false, for: cardRef) }, dismissCardWindow: { [weak self] cardRef in self?.windowDismisser?(value: cardRef) @@ -1705,17 +1222,10 @@ public final class AppModel { // window of its own — the raw-source buffer is the candidate — so that the order // relative to `committerFlush` is already decided when one arrives. // - // **`committerFlush` is filled now** (06-history-undo.md ▸ Rules ▸ Auto-commit: "Board - // window close and app quit flush the pipeline — any pending editor save, then the - // pending auto-commit — before teardown; nothing settled is ever left unsaved or - // uncommitted by closing"). By the time it runs, step 1 has ended every card window's - // session *and released its stage-around*, so each session — body, comments, purge and - // all — lands in exactly one commit. `nil` on every board with no committer, which is - // every board nobody has added git to (12 ▸ PIVOT 2026-08-07 — the tier no longer - // decides; opt-in per board still does). - committerFlush: { [weak self] in - await self?.sessions[ref]?.git?.committer?.flushNow() - }, + // **`committerFlush` stays nil too**, and now for want of a committer rather than by + // policy: the auto-committer it drove went with the git stack + // (`strategy/01-git-excision.md`). The slot is 02's ordering statement — pending editor + // saves, then whatever records history — and it keeps that place for the successor. recordClose: { [weak self] in guard let self, let session = sessions[ref] else { return } let counts = Self.liveCounts(of: session.store.snapshot) @@ -1734,11 +1244,6 @@ public final class AppModel { }, tearDown: { [weak self] in guard let self, let session = sessions.removeValue(forKey: ref) else { return } - // The committer dies with the session it commits for, `history.clear()`'s reason - // exactly: its debounce holds a closure over the store this line is about to release, - // and a timer that outlived its board would fire against a repository nobody is - // looking at. Its pending work has already been flushed by `committerFlush` above. - session.git?.stopAutoCommit() // Session-only persistence, the other half of `beginSession` (13-native-undo.md // ▸ Rules): "the stack ... dies at close/quit", so reopening the board starts empty. // Cleared rather than merely dropped because the steps hold closures over the store diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index e2ec4bf..fb01cca 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -162,8 +162,8 @@ final class CardWindowSession: CardSessionFlushing { }, discard: { [self] in // The buffer goes back to what disk says; the *disk* goes back to the target state as - // part of the restore itself, which reconciles this card's folder against the working - // tree rather than against HEAD (`GitRestoreOperation.plan`). + // part of the wholesale operation itself, which is the only party that knows what it + // is reconciling this card's folder towards (`SessionSettleGate`). body.discardBuffer() rawSourceCancel?() } diff --git a/Kanban/App/SessionSettleGate.swift b/Kanban/App/SessionSettleGate.swift index 58eac11..137c35b 100644 --- a/Kanban/App/SessionSettleGate.swift +++ b/Kanban/App/SessionSettleGate.swift @@ -3,9 +3,9 @@ import Foundation // MARK: - Vocabulary -/// What the user chose at the save-or-discard step (06-history-undo.md ▸ Branch switching: "**Save -/// All** ends every session with its normal commit …, **Discard** reverts buffers and uncommitted -/// saves to HEAD, **Cancel** keeps the current branch and the sessions"). +/// What the user chose at the save-or-discard step: **Save All** ends every session with its normal +/// save, **Discard** reverts buffers and leaves the wholesale operation to reconcile what already +/// reached disk, **Cancel** keeps everything exactly as it is. public enum SessionSettleChoice: Sendable, Equatable { case saveAll case discard @@ -21,10 +21,9 @@ public enum SessionSettleOutcome: Sendable, Equatable { /// The user chose Cancel. "Cancel keeps everything" — nothing was written, nothing reverted. case cancelled - /// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer - /// that fails validation cancels the whole switch with focus on the offending window, nothing - /// half-switched" (06 ▸ Branch switching). The payload is that window's session id, already - /// focused by the gate. + /// **Save All met a raw-source buffer that would not validate.** Since Apply validates, a buffer + /// that fails validation cancels the whole operation with focus on the offending window, nothing + /// half-done. The payload is that window's session id, already focused by the gate. case failed(String) } @@ -79,7 +78,7 @@ public struct SettleableSession { /// /// Matched component-wise against the paths a wholesale operation would write, which is what /// makes the match survive a lane move: a card's own folder component never changes, only the - /// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason). + /// lane above it (`SessionSettleGate.path(_:isInsideFolderNamed:)`). public let cardFolderName: String /// Whether this session is holding state a wholesale tree operation would disturb: unsaved @@ -91,10 +90,9 @@ public struct SettleableSession { /// the raw buffer. `false` means the raw buffer failed validation — the whole operation is off. public let saveAll: @MainActor () -> Bool - /// **Discard** for this one session: revert the buffer and leave raw source without writing. The - /// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's - /// folder against the working tree rather than against HEAD for exactly that reason - /// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`). + /// **Discard** for this one session: revert the buffer and leave raw source without writing. What + /// already reached disk is reconciled by the wholesale operation behind the gate, which is the + /// only party that knows the state it is writing towards. public let discard: @MainActor () -> Void public init( @@ -117,23 +115,22 @@ public struct SettleableSession { /// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md ▸ Rules /// ▸ Undo restore vs open Edit sessions; ▸ Branch switching). /// -/// ### One machinery, two callers, by design +/// ### One machinery, every caller, by design /// -/// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same -/// one by name: "When the diff *does* touch a session card, the restore **gates on the branch-switch -/// save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same -/// rationale)." So this object is written for both from the start; the undo provider is its first -/// caller and the branch controls will be its second, passing the paths a checkout would write -/// instead of the paths a restore would. +/// The design never described a gate per operation: it described one save-or-discard step — Save All +/// / Discard / Cancel — and handed it to whatever wants to move the tree wholesale under an open +/// editor. So this object is written for all of them. **Nothing calls it today** (`strategy/ +/// 01-git-excision.md` ▸ What is deleted: the git restore and branch switch were its two callers and +/// went with the stack); it is kept because it is the settled answer to a question the next wholesale +/// operation will ask on its first day. /// -/// ### The diff decides whether it appears at all +/// ### The paths decide whether it appears at all /// -/// "A restore materializes only the diff between the current tree and the target state, so a card -/// whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms -/// saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all." -/// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a -/// yes/no: a modal that appeared on every ⌘Z because *some* window somewhere was in Edit would be a -/// different, much worse feature. +/// An operation that materializes only a diff leaves a card whose open Edit session the diff doesn't +/// touch simply unaffected — that card's uncommitted ~700 ms saves continue undisturbed. That is +/// `settle(touching:)`'s first line, and it is why the gate takes paths rather than a yes/no: a modal +/// that appeared on every gesture because *some* window somewhere was in Edit would be a different, +/// much worse feature. `settleAll()` is the other shape, for an operation that moves everything. /// /// ### Why the ask is a closure /// @@ -174,17 +171,16 @@ public struct SessionSettleGate { await decide(over: Self.reached(by: paths, among: sessions())) } - /// **Settles every open session, whatever the operation writes** — the branch switch's gate - /// (06 ▸ Branch switching). + /// **Settles every open session, whatever the operation writes** — the gate for an operation that + /// replaces the tree wholesale rather than materializing a diff. /// - /// The path filter above is the *restore's* narrowing and belongs to it alone: "a restore - /// materializes only the diff between the current tree and the target state, so a card whose open - /// Edit session the diff doesn't touch is simply unaffected". A branch switch has no such - /// property. It moves the whole tree at once, and the raw-source hazard 06 singles out — "an - /// unsettled raw buffer … its Apply later writes the *entire* pre-switch `index.md` byte-for-byte - /// onto the new branch's card" — is about the buffer belonging to the old branch, not about - /// whether the checkout happened to rewrite that card. So this asks about every session that is - /// holding something, and about no path at all. + /// The path filter above is a diff-shaped operation's narrowing and belongs to it alone: a card + /// whose open Edit session the diff doesn't touch is simply unaffected. A wholesale replacement + /// has no such property, and the raw-source hazard is the reason it cannot borrow one — an + /// unsettled raw buffer's Apply later writes the *entire* pre-operation `index.md` byte-for-byte + /// over whatever now stands there, which is about the buffer describing a state that has gone, not + /// about whether the operation happened to rewrite that card. So this asks about every session + /// that is holding something, and about no path at all. public func settleAll() async -> SessionSettleOutcome { await decide(over: sessions()) } @@ -225,9 +221,21 @@ public struct SessionSettleGate { ) -> [SettleableSession] { guard !paths.isEmpty else { return [] } return sessions.filter { session in - paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) } + paths.contains { path($0, isInsideFolderNamed: session.cardFolderName) } } } + + /// Whether `path` lies inside a folder named `name` — component-exact, so a card whose id is a + /// prefix of another's cannot be dragged into a step it has no business in. + /// + /// Moved here from the git history walk when the git stack was unbound (`strategy/ + /// 01-git-excision.md` ▸ Sequencing ▸ Seam unbind); the walk was its only other caller. + /// + /// `dropLast()` because the *containing* folders are the question: a path that ends in the folder + /// name is a file called that, not a file inside it. + static func path(_ path: String, isInsideFolderNamed name: String) -> Bool { + path.split(separator: "/").dropLast().contains { $0 == name } + } } // MARK: - The presented step @@ -236,12 +244,11 @@ public struct SessionSettleGate { /// /// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s /// reason exactly: the operation behind it cannot proceed until the user has decided what happens to -/// text no commit protects, and there is no non-modal shape for a question whose three answers are +/// text no save protects, and there is no non-modal shape for a question whose three answers are /// mutually exclusive and immediate. /// -/// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed -/// reflexively at a dialog nobody read must be the answer that changes nothing, and both other -/// answers write. +/// The default is **Cancel**, deliberately: a Return pressed reflexively at a dialog nobody read must +/// be the answer that changes nothing, and both other answers write. public enum SessionSettleStep { public static let title = "Unsaved card edits" @@ -251,12 +258,12 @@ public enum SessionSettleStep { Save them, discard the changes, or cancel. """ - /// The same three buttons, asked for the other caller. **One sentence differs, deliberately**: the - /// consequence a user is deciding about is not the same one — a restore would change the cards - /// being edited, while a switch takes every card to a different branch — and a step that described - /// the wrong operation would be a worse modal than no wording at all. - public static let branchSwitchMessage = """ - Switching branches would replace the cards you are editing. \ + /// The same three buttons, asked for an operation that replaces the tree rather than editing part + /// of it. **One sentence differs, deliberately**: the consequence a user is deciding about is not + /// the same one, and a step that described the wrong operation would be a worse modal than no + /// wording at all. Kept unused beside the gate, for the gate's reason. + public static let wholesaleReplacementMessage = """ + This would replace the cards you are editing. \ Save them, discard the changes, or cancel. """ diff --git a/Kanban/Changes/ChangeNarration.swift b/Kanban/Changes/ChangeNarration.swift index b7b3c37..89a980d 100644 --- a/Kanban/Changes/ChangeNarration.swift +++ b/Kanban/Changes/ChangeNarration.swift @@ -64,15 +64,15 @@ public struct ChangeNarrationRequest: Sendable { public let snapshot: BoardModel? /// **The last-committed half** of the "last-committed vs. current" diff — supplied by the caller - /// by contract; the narrator itself never reads this from anywhere. The git stack supplied it from - /// HEAD's tree (`GitHeadSnapshot`), the former git-backed supplier; the journal will supply it from - /// memory/snapshots. + /// by contract; the narrator itself never reads this from anywhere. The retired git substrate + /// materialized it from HEAD's tree; the journal will supply it from memory/snapshots. /// - /// `nil` when the caller has nothing to diff against — the git stack's case was an unborn HEAD - /// (where `isRootCommit` already says everything) or a HEAD whose tree did not load as a board. The - /// git stack read it from the repository rather than carrying it forward from the last commit the - /// app made, because the app is not the only writer and because launch catch-up has no carried - /// value to offer: see `GitHeadSnapshot` for the whole of that argument. + /// `nil` when the caller has nothing to diff against — a first window (where `isRootCommit` + /// already says everything), or a stored state that did not load as a board. + /// + /// **A supplier reads it rather than carrying it forward** from the last window it described, and + /// deliberately: the app is not the only writer, and catch-up after a period of not watching has + /// no carried value to offer. public let previousSnapshot: BoardModel? /// **The board-root `CLAUDE.md` as it now reads**, when this commit touches it — the one @@ -92,8 +92,7 @@ public struct ChangeNarrationRequest: Sendable { /// comments' own `created`, folder name on ties" (06 ▸ Rules ▸ Auto-commit, blessed 2026-07-31), /// and `created` lives in a comment's own `index.md` because comments are window-scoped and the /// board snapshot never carries them (01-storage-format.md § Enhanced schema). The flush resolves - /// it once (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`) and the engine stays a pure - /// function of values. + /// it once, off disk, and the engine stays a pure function of values. /// /// **Missing is normal, not a defect.** A comment whose folder left the tree in this very commit /// (the close purge), one whose `index.md` does not parse, one written by hand with no `created` @@ -141,7 +140,7 @@ public protocol ChangeNarrating: Sendable { // MARK: - The wired composer -/// **The semantic composer**, and the committer's default (`GitAutoCommitter.composer`). +/// **The semantic composer**, and the seam's default. /// /// A one-line conformance over `ChangeNarrator`, deliberately: the vocabulary is worth a file of /// its own and nothing about it should have to know that a protocol exists. The type stays because diff --git a/Kanban/Changes/ChangeNarrator.swift b/Kanban/Changes/ChangeNarrator.swift index 448b3d7..6a226fe 100644 --- a/Kanban/Changes/ChangeNarrator.swift +++ b/Kanban/Changes/ChangeNarrator.swift @@ -24,10 +24,10 @@ import Foundation /// ### Purity, and where the impurity went /// /// Nothing in here reads a file, opens a repository, or asks what time it is. Both snapshots and the -/// guide's text arrive as values on `ChangeNarrationRequest`; `GitAutoCommitter` resolves them once per -/// flush, the last-committed one through `GitHeadSnapshot`. That is what makes the whole vocabulary — -/// every subject form, every fold, every trash reading — testable with two snapshots and no -/// repository at all. +/// guide's text arrive as values on `ChangeNarrationRequest`, resolved once per window by whoever is +/// driving the narration — the previous snapshot included. That is what makes the whole vocabulary — +/// every subject form, every fold, every trash reading — testable with two snapshots and no store at +/// all. /// /// ### The changed-path list is a filter, not a hint /// @@ -93,6 +93,14 @@ enum ChangeNarrator { /// says *added* without a second sentence shape for it. static let absentValuePlaceholder = "(none)" + /// **The subject the one snapshot-less window gets** (06 ▸ Rules ▸ Abnormal repo states): a first + /// window with no previous snapshot to diff against describes the whole board as arriving, never a + /// folded diff-from-empty. + /// + /// Defined here rather than borrowed from a substrate, so the narrator stays a pure function of + /// two snapshots (`strategy/01-git-excision.md` ▸ What is harvested). + static let rootSubject = "Initial board state" + // MARK: - Entry point /// One request in, one whole message out — a subject, and a body when there is more to say. @@ -100,7 +108,7 @@ enum ChangeNarrator { // **The one commit with a subject of its own** (06 ▸ Rules ▸ Abnormal repo states): "it // commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is // no last-committed snapshot to diff against." - guard !request.isRootCommit else { return GitRepository.initialCommitSubject } + guard !request.isRootCommit else { return rootSubject } return assemble(events(for: request), request: request) } @@ -910,7 +918,7 @@ enum ChangeNarrator { /// inside a thread at all. /// /// Shared with the flush that reads each of those folders' `created` - /// (`GitAutoCommitter.commentTimestamps(for:boardRoot:)`), so the key a timestamp is *filed* + /// (`ChangeNarrationRequest.commentTimestamps`), so the key a timestamp is *filed* /// under and the key it is *looked up* by have one definition. Everything about where a thread /// lives is still `CommentPath.classify`'s; this only says how many of the path's components that /// classification consumed. @@ -1281,9 +1289,9 @@ enum ChangeNarrator { /// Whether a path could make the *snapshot* differ at all — every path the model speaks for, /// plus attachments, whose names it carries. /// - /// The flush asks it before it materializes HEAD's tree: a window of nothing but strays and - /// the agent guide composes path-shaped events, and reading two whole boards to describe a - /// changed `.gitignore` would be work with no reader (`GitAutoCommitter.composition`). + /// The flush asks it before it materializes the previous snapshot: a window of nothing but + /// strays and the agent guide composes path-shaped events, and reading two whole boards to + /// describe a changed `.gitignore` would be work with no reader. static func mightAffectSnapshot(_ path: String) -> Bool { isModelSilent(path) || path.split(separator: "/").contains(Substring(attachmentsFolder)) } diff --git a/Kanban/History/BoardStoreHistory.swift b/Kanban/History/BoardStoreHistory.swift index 3cb6973..f886f9d 100644 --- a/Kanban/History/BoardStoreHistory.swift +++ b/Kanban/History/BoardStoreHistory.swift @@ -160,8 +160,7 @@ extension BoardStore { /// - Returns: whether the purge now has an owner — a live step holding it until the substrate /// lets the step go (which a stale skip deliberately does not do; 13's skip-purge decoupling, /// ruled 2026-07-31), or a substrate that declined to keep the step and therefore ran it - /// already (`GitHistoryProvider.register`). `false` means nothing was registered and the caller - /// still owes the purge. + /// already. `false` means nothing was registered and the caller still owes the purge. @discardableResult func registerCardSession( _ window: CardWindowUndo, diff --git a/Kanban/History/HistoryProviding.swift b/Kanban/History/HistoryProviding.swift index 7fa7a7a..4f0b9e8 100644 --- a/Kanban/History/HistoryProviding.swift +++ b/Kanban/History/HistoryProviding.swift @@ -363,9 +363,9 @@ public protocol HistoryProviding: AnyObject { extension HistoryProviding { - /// **A substrate that keeps no steps holds no backing** — the git provider's honest answer - /// (`GitHistoryProvider.register` retires every step on arrival, which is what makes "purge rides - /// the close flush" true on Pro), and a test fake's. + /// **A substrate that keeps no steps holds no backing** — the honest answer for any provider that + /// retires every step on arrival (which is what makes "purge rides the close flush" true over such + /// a substrate), and a test fake's. /// /// A default on the requirement rather than a free function, so the dispatch is the substrate's: /// an implementation that *does* keep steps overrides it and every caller through `any diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index d7c576c..1407a1b 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -36,79 +36,6 @@ public struct OneShotBanner: Identifiable, Sendable, Equatable { } } -/// **The one-shot failure class's second shape** (02-architecture.md § The banner surface, settled -/// 2026-07-31): "the `BoardWriteError`-shaped write failure, and a message-carrying **git-operation -/// failure** — the operation named in the user's words plus the underlying error, phrasing still -/// BannerCenter's — because failures rank by what they are, not by which error vocabulary threw -/// them". -/// -/// It is a row of its own rather than a `BoardWriteError` with a git-shaped case because "git -/// operations stay off the closed `WriteOperation` vocabulary — only the banner tier learns the -/// second shape": a `WriteOperation` is something the Writer does to a file the model knows, and a -/// checkout is not one. What the two shapes *do* share is everything the strip cares about — the -/// error tone, the failure rank, and the one-shot's dismissable, untimed lifecycle — which is why -/// they sit in one precedence class rather than in two (`BannerCenter.rows(...)`). -/// -/// It replaces the shipped compromise, and the compromise is worth naming because its reasoning is -/// the mirror of this one's: a failed restore used to post a `LossBanner` — the nearest class with a -/// free-form message — which read as warning-tone content-didn't-arrive when what actually happened -/// was an action that didn't happen at all. "A failed undo restore, branch switch, or (pro-m2) -/// pull/push … presents in the error tone at the failure rank, never as a warning-tone loss row." -public struct GitFailureBanner: Identifiable, Sendable, Equatable { - public let id: UUID - /// Which operation didn't happen — a closed vocabulary, so the sentence is composed here rather - /// than carried in from the git layer (`GitOperation`). - public let operation: GitOperation - /// The underlying error, verbatim — libgit2's own message, the free-form tail the phrasing rules - /// allow ("could not write to '…': Permission denied"). Never the banner's verb. - public let reason: String - /// When it failed — the sort key for "newest first within a class", shared with the write - /// failures it ranks beside. - public let occurredAt: Date - - public init(id: UUID = UUID(), operation: GitOperation, reason: String, occurredAt: Date = Date()) { - self.id = id - self.operation = operation - self.reason = reason - self.occurredAt = occurredAt - } -} - -/// **The git operations the banner can name** — the closed vocabulary behind `GitFailureBanner`, -/// and the git half of the rule the `WriteOperation` enum already carries for writes: "the banner -/// owns all user-facing phrasing and localization from that vocabulary", and an operation added -/// without a sentence to say about it is a compile-time hole rather than a silent default -/// (`BannerCenter.actionPhrase(for:)` switches over it exhaustively). -/// -/// Deliberately small. 02 names the members: "a failed undo restore, branch switch, or (pro-m2) -/// pull/push" — the first three are here because they have producers today -/// (`GitHistoryProvider`, `GitBranchSwitcher`), and pull/push join by adding a case and a sentence -/// when the remote half ships. -/// -/// No associated values: a branch switch's target is in the in-progress row the failure replaces -/// ("Switching to 'main'…"), and a failure that named it again would be the only member of this -/// vocabulary carrying a payload — a case can grow one the day a sentence genuinely needs it. -public enum GitOperation: Sendable, Equatable, CaseIterable { - case undo - case redo - case branchSwitch - - /// **Add-git, when the form that asked has been dismissed** (06-history-undo.md ▸ Interaction with - /// external writers, ruled 2026-07-31): "if the sheet has been dismissed before the answer - /// arrives, the failure falls back to the one-shot banner above — inline is the primary surface, - /// never a silence trap." Inline is `HistoryStore.lastFailure`; this is the fallback. - case addGit - - /// The pair ⌘Z and ⇧⌘Z map to — so a caller that knows a `HistoryDirection` (the provider's - /// crossing) never has to spell the mapping itself, and never gets it backwards. - public static func restore(_ direction: HistoryDirection) -> GitOperation { - switch direction { - case .undo: .undo - case .redo: .redo - } - } -} - /// A loss row: content that didn't arrive though nothing failed (02-architecture.md § The banner /// surface, "Loss rows are the warning-tone class for non-failure losses", settled 2026-07-28) — /// folders skipped from a Finder drop, the app's own relocation and repair notices, their future kin. @@ -218,19 +145,18 @@ public struct InfoSignpost: Identifiable, Sendable, Equatable { } /// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface, -/// "In-progress operations are info rows"): bracketed git operations ("Pulling…", "Switching to -/// 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment -/// imports). +/// "In-progress operations are info rows"): long copy-shaped work — big-board Duplicate, template +/// instantiation, large attachment imports — and whatever wholesale operation joins them. /// /// `label` is the caller's, deliberately: unlike a failure — whose vocabulary is the closed /// `WriteOperation` enum precisely so the banner can own every word — an in-progress row names an -/// operation the banner layer has no enum for, and inventing one would mean a new git verb could -/// not describe itself without touching this file. The tradeoff is stated rather than hidden. +/// operation the banner layer has no enum for, and inventing one would mean a new verb could not +/// describe itself without touching this file. The tradeoff is stated rather than hidden. /// /// **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports, /// Duplicate, template instantiation — carries `cancel`, meaning "remove the partial copy, nothing -/// lost". Git brackets pass `nil`: they are seconds long, and aborting a rebase mid-flight is a -/// repair job, not a cancel. +/// lost". An operation that cannot be abandoned halfway passes `nil`: unwinding it is a repair job, +/// not a cancel. public struct InProgressOperation: Identifiable, Sendable { public let id: UUID public let label: String @@ -252,19 +178,16 @@ public struct InProgressOperation: Identifiable, Sendable { /// One row in a window's banner strip. /// -/// The eight cases are the whole vocabulary of 02-architecture.md § The banner surface, and they +/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they /// divide into three lifecycles that the view renders differently and that the ordering rule /// treats as classes: /// -/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `repositoryUnreadable`, -/// `historySuspended`. They describe +/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `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. -/// - **One-shots dismiss**: `oneShot`, `gitFailure`, `loss`, and `signpost`. Each reports something -/// that already happened, so only the user can clear it. `gitFailure` is the same failure class as -/// `oneShot` in a second shape (settled 2026-07-31) — same tone, same rank, same lifecycle, a -/// message where the other carries a `BoardWriteError`. `loss` shares the lifecycle deliberately +/// - **One-shots dismiss**: `oneShot`, `loss`, and `signpost`. Each reports something +/// that already happened, so only the user can clear it. `loss` shares the lifecycle deliberately /// (settled 2026-07-28) even though it reports no failure: "a loss the user didn't notice is the /// harm, so it never auto-expires" is the same reasoning that keeps a one-shot from evaporating /// unread, aimed at a row that isn't an error at all. @@ -284,35 +207,18 @@ 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 - /// or a push. Dismissable, error tone, and it ranks *with* `oneShot`: the one-shot failure - /// class's second shape (settled 2026-07-31, see `GitFailureBanner`). - case gitFailure(GitFailureBanner) /// Content that didn't arrive though nothing failed — folders skipped from a Finder drop, the /// app's own relocation and repair notices. Dismissable, warning tone: below the true failures above it, /// above the ambient notices below it (settled 2026-07-28, see `LossBanner`). case loss(LossBanner) /// History has stopped advancing. Condition, warning tone — the files are safe, only the undo - /// trail is degraded, which is a warning rather than an error. (m7's committer drives it.) + /// trail is degraded, which is a warning rather than an error. No producer today; the row waits + /// for whatever substrate can stall. case historySuspended(HistorySuspension) /// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse. - /// (m5's copy-shaped work and m7's git brackets drive it.) + /// (m5's copy-shaped work drives it.) case inProgress(InProgressOperation) /// A calm notice — the passive half of the info tone, ranking last of all. (m6's card window /// drives it, as 07-sync-collab.md's remote-change signpost.) @@ -322,9 +228,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)" case .historySuspended: "history-suspension" case let .inProgress(operation): "operation:\(operation.id.uuidString)" @@ -334,15 +238,9 @@ public enum BannerRow: Identifiable, Sendable { public var tone: BannerTone { switch self { - // 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). - // 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 .readOnlyLock, .reloadBreakage, .oneShot: .error + // A suspension is history failing to advance and retrying, which is degraded rather than + // broken — the files are safe either way, so it takes the warning tone the loss row does. case .historySuspended, .loss: .warning case .inProgress, .signpost: .info } @@ -361,9 +259,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 case let .historySuspended(suspension): BannerCenter.headline(for: suspension) case let .inProgress(operation): operation.label @@ -377,10 +273,9 @@ public enum BannerRow: Identifiable, Sendable { public var dismissID: UUID? { switch self { case let .oneShot(banner): banner.id - case let .gitFailure(banner): banner.id case let .loss(loss): loss.id case let .signpost(signpost): signpost.id - case .readOnlyLock, .reloadBreakage, .repositoryUnreadable, .historySuspended, .inProgress: nil + case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil } } @@ -464,12 +359,11 @@ public enum BannerRowControl: Identifiable, Sendable { /// /// ### What lives here and what does not /// -/// A center holds the state nothing else does: dismissable one-shot failures in both shapes (write -/// and git-operation), loss rows, the history suspension, in-progress operations, and passive -/// signposts. It deliberately does **not** hold the read-only lock or the reload breakage — those -/// are `BoardStore`'s truths, and copying them here would create a second place for them to be -/// stale. `BoardStore.bannerRows` composes both halves through -/// `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:gitFailures:)`, which is a +/// A center holds the state nothing else does: dismissable one-shot write failures, loss rows, the +/// history suspension, in-progress operations, and passive signposts. It deliberately does **not** +/// hold the read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying them +/// here would create a second place for them to be stale. `BoardStore.bannerRows` composes both +/// halves through `rows(lock:breakage:oneShots:losses:suspension:operations:signposts:)`, which is a /// *pure function* precisely so the precedence rule can be tested without a store, a window, or a /// filesystem. /// @@ -480,10 +374,7 @@ public enum BannerRowControl: Identifiable, Sendable { /// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no /// `default`**, so a Writer operation added without a sentence to say about it is a compile-time /// hole rather than a silent fallback. The same rule covers locks, breakage, and the history -/// suspension: their user-facing lines are here, not on the error types. **And the git operations -/// too** (settled 2026-07-31): `GitOperation` is that vocabulary's git-side twin — "the operation -/// named in the user's words plus the underlying error, phrasing still BannerCenter's" — which is -/// why `GitOperationFailure.operation`, a developer-facing string, never reaches a row. +/// suspension: their user-facing lines are here, not on the error types. /// /// ### One center per window, not per board /// @@ -502,37 +393,14 @@ public final class BannerCenter { /// turn — still order deterministically. public private(set) var oneShots: [OneShotBanner] = [] - /// The failure class's git-shaped half, newest first like `oneShots` — a failed undo restore, - /// a failed branch switch, and (pro-m2) a failed pull or push (settled 2026-07-31, see - /// `GitFailureBanner`). - /// - /// **A second array rather than a second case inside `OneShotBanner`**: the two shapes have no - /// payload in common — one carries the closed `WriteOperation` vocabulary and a path, the other - /// a git operation and libgit2's message — and every reader of a write failure would have to - /// start asking whether it was one. They meet where the design says they meet, in the strip's - /// order: `rows(...)` merges them into one precedence class, newest first across both. - public private(set) var gitFailures: [GitFailureBanner] = [] - /// Newest first, like `oneShots` — content that didn't arrive though nothing failed, /// dismissable and untimed for the same reason a one-shot failure is (settled 2026-07-28, see /// `LossBanner`). public private(set) var losses: [LossBanner] = [] - /// The standing "history isn't advancing" condition, or `nil` when commits are landing. + /// The standing "history isn't advancing" condition, or `nil` when history is keeping up. 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] = [] @@ -552,19 +420,6 @@ public final class BannerCenter { oneShots.insert(OneShotBanner(error: error), at: 0) } - /// **Records a git operation that didn't happen** — the failure class's second shape (settled - /// 2026-07-31, see `GitFailureBanner`): an undo or redo restore that failed cleanly - /// (06-history-undo.md ▸ Interaction with external writers), a branch switch that could not run - /// (06 ▸ Branch switching), and the remote pair when pro-m2 wires them. - /// - /// `reason` is the underlying error and nothing else — libgit2's own message, or the app's own - /// diagnosis of a repository it could not read. **Every user-facing word is composed here** - /// (`headline(for:)`), which is what keeps a caller from inventing a verb: the git layer's - /// `GitOperationFailure.operation` is a developer-facing string and never reaches the strip. - public func postGitFailure(_ operation: GitOperation, reason: String) { - gitFailures.insert(GitFailureBanner(operation: operation, reason: reason), at: 0) - } - /// Posts a loss row — content that didn't arrive though nothing failed (settled 2026-07-28, see /// `LossBanner`). Newest first, like the one-shots it shares a lifecycle with. /// @@ -804,32 +659,29 @@ public final class BannerCenter { nonisolated static let mixedTrashDragMessage = "Cards and lanes leave the trash separately \u{2014} restore one kind at a time" - /// Removes a dismissable row: a one-shot failure in either shape, a loss row, or a signpost. + /// Removes a dismissable row: a one-shot failure, a loss row, or a signpost. /// **An id that names an in-progress operation is ignored** rather than ending it, because /// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do /// the other. public func dismiss(_ id: UUID) { oneShots.removeAll { $0.id == id } - gitFailures.removeAll { $0.id == id } losses.removeAll { $0.id == id } signposts.removeAll { $0.id == id } } - /// Removes every dismissable row — both failure shapes, losses, and signposts alike. The strip's + /// Removes every dismissable row — failures, losses, and signposts alike. The strip's /// own "clear all" affordance later; today it is what a window uses when it re-homes its rows /// elsewhere (m6). public func dismissAllDismissableRows() { oneShots.removeAll() - gitFailures.removeAll() losses.removeAll() signposts.removeAll() } // MARK: History suspension - /// Raises (or refreshes) the "changes aren't being recorded to history" condition — m7's - /// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md - /// covers the lock itself, which is deliberately *not* a banner). + /// Raises (or refreshes) the "changes aren't being recorded to history" condition — for whatever + /// substrate can stall while the files themselves are landing fine. No producer today. /// /// A second call while already suspended keeps the original `since` and takes the newer /// `reason`: the condition never stopped being true, so restarting its clock would misreport @@ -838,39 +690,18 @@ public final class BannerCenter { historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date()) } - /// Clears it, on the first successful commit. Idempotent — clearing a condition that is not - /// standing is not an error, it is the ordinary shape of "commit succeeded". + /// Clears it, the moment history catches up. Idempotent — clearing a condition that is not + /// standing is not an error, it is the ordinary shape of "it worked". public func clearHistorySuspension() { 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. /// /// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the - /// partial copy, nothing lost" (02, settled). Git brackets pass `nil`. + /// partial copy, nothing lost" (02, settled). Everything else passes `nil`. @discardableResult public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID { let operation = InProgressOperation(label: label, cancel: cancel) @@ -880,9 +711,9 @@ public final class BannerCenter { /// **Relabels a running operation** — the same row, still spinning, now saying something else. /// - /// It exists for one sentence in 06-history-undo.md ▸ Interaction with external writers: - /// "contention outlasting the brief retry surfaces as a *waiting* state in the operation's - /// in-progress banner row ('waiting for another writer's git lock'), retrying on its cadence". + /// It exists for the waiting state a long operation can fall into — contention outlasting a brief + /// retry surfaces in the operation's *own* in-progress row ("waiting for another writer"), + /// retrying on its cadence. /// The waiting state is explicitly *the operation's own row*, not a second row and not a /// replacement — the operation has not restarted, it is explaining itself — so the id is stable /// and the view neither churns nor re-animates. @@ -937,25 +768,14 @@ public final class BannerCenter { /// a condition and a one-shot are not comparable by recency in any way a user would read as /// order — the condition's `since` is when it *started* being true, not when it happened — /// so they are ordered by kind, and recency orders only the one-shots among themselves. - /// - **The failure rank holds both shapes, interleaved by recency** (settled 2026-07-31): - /// "failures rank by what they are, not by which error vocabulary threw them", so a failed - /// undo posted a second ago sits above a failed move from a minute ago and below one from a - /// second before it. Ties — two rows sharing a `Date` to the microsecond — put the write - /// 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. + /// - **The failure rank is one class, ordered by recency** (settled 2026-07-31): "failures rank + /// by what they are, not by which error vocabulary threw them". The rank held two row shapes + /// while the git stack was wired, interleaved by `occurredAt`; the merge went with the second + /// shape and the rank is ready to take another the day one arrives. /// - /// - **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`). + /// `signposts` carries a default because its producer is m6's card window. 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?, @@ -963,9 +783,7 @@ public final class BannerCenter { losses: [LossBanner], suspension: HistorySuspension?, operations: [InProgressOperation], - signposts: [InfoSignpost] = [], - gitFailures: [GitFailureBanner] = [], - repositoryUnreadable: Bool = false + signposts: [InfoSignpost] = [] ) -> [BannerRow] { var rows: [BannerRow] = [] @@ -977,15 +795,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( - writes: ordered.filter { !$0.isAttachmentImport }, - git: newestFirst(gitFailures, by: \.occurredAt) - )) + rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot)) rows.append(contentsOf: newestFirst(losses, by: \.occurredAt).map(BannerRow.loss)) @@ -998,40 +810,13 @@ public final class BannerCenter { return rows } - /// **The failure rank, both shapes** (settled 2026-07-31): the non-attachment write failures and - /// the git-operation failures, merged into the one precedence class they share — "failures rank - /// by what they are, not by which error vocabulary threw them". - /// - /// A merge rather than a re-sort, because both inputs arrive newest-first already; ties keep the - /// write failure first, which is arbitrary and says so (`rows(...)`). - private nonisolated static func failureRank( - writes: [OneShotBanner], - git: [GitFailureBanner] - ) -> [BannerRow] { - var merged: [BannerRow] = [] - var writes = writes[...] - var git = git[...] - while let write = writes.first, let failure = git.first { - if failure.occurredAt > write.occurredAt { - merged.append(.gitFailure(failure)) - git = git.dropFirst() - } else { - merged.append(.oneShot(write)) - writes = writes.dropFirst() - } - } - merged.append(contentsOf: writes.map(BannerRow.oneShot)) - merged.append(contentsOf: git.map(BannerRow.gitFailure)) - return merged - } - /// Newest first, and **stable**: `sorted(by:)` is not, and two rows posted in the same run loop /// turn can share a `Date` to the microsecond. Ties fall back to the input order, which every /// `post…` maintains newest-first on insertion — so a tie renders in the order it was posted /// rather than in whatever order the sort happened to leave. /// - /// One function over a date key rather than one per class: the three dismissable classes order - /// by exactly the same rule, and three copies of it were three places for it to drift. + /// One function over a date key rather than one per class: the dismissable classes order + /// by exactly the same rule, and a copy per class was a place for it to drift. private nonisolated static func newestFirst(_ rows: [Row], by occurredAt: KeyPath) -> [Row] { rows .enumerated() @@ -1056,39 +841,6 @@ public final class BannerCenter { return cause.isEmpty ? action : "\(action) — \(cause)" } - /// The user-facing line for a git operation that didn't happen: what the app could not do, then - /// why — the write failure's shape exactly (settled 2026-07-31), because the two are one class. - /// - /// The cause is the underlying error verbatim, on `causePhrase(for:)`'s reasoning read one layer - /// down: libgit2's messages are specific in a way no re-phrasing of ours would be, and the - /// alternative to showing one is a shrug. An empty or whitespace-only reason leaves the action - /// clause alone rather than trailing a dash into nothing. - public nonisolated static func headline(for failure: GitFailureBanner) -> String { - let action = actionPhrase(for: failure.operation) - let cause = trimmed(failure.reason) - return cause.isEmpty ? action : "\(action) — \(cause)" - } - - /// **Exhaustive by construction — no `default`**, the `WriteOperation` rule applied to the git - /// vocabulary: an operation added to `GitOperation` without a sentence here fails to compile. - /// - /// The undo pair is named by **the command the user pressed** — the skipped-step row's rule - /// (`skippedStepMessage`), and for its reason: ⌘Z is the gesture, and "Couldn't restore an - /// earlier state" would describe machinery the user has no model of. The branch switch is named - /// by the control they used (the popover's branch picker) and stays plural — "branches" — rather - /// than naming the target, which the in-progress row this failure replaces already said - /// ("Switching to 'main'…") and which `GitOperation` deliberately doesn't carry. - private nonisolated static func actionPhrase(for operation: GitOperation) -> String { - switch operation { - case .undo: "Undo failed" - case .redo: "Redo failed" - case .branchSwitch: "Couldn't switch branches" - // Named by the control the user pressed, like the pair above — "Add Git" is the button, and - // the row is only ever seen by someone who pressed it and looked away. - case .addGit: "Couldn't add git to this board" - } - } - /// **Exhaustive by construction — no `default`.** A `WriteOperation` case added without a /// sentence here fails to compile, which is the settled contract ("a new Writer operation /// without a banner rendering is a compile-time hole, not a silent default"). @@ -1521,25 +1273,8 @@ 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 + /// flush-before-overwrite guarantee are degraded — rather than the mechanics, and carries /// the diagnosis as its tail. public nonisolated static func headline(for suspension: HistorySuspension) -> String { let reason = trimmed(suspension.reason) diff --git a/Kanban/LiveStore/BoardAnnouncer.swift b/Kanban/LiveStore/BoardAnnouncer.swift index 9e9db90..c687ddd 100644 --- a/Kanban/LiveStore/BoardAnnouncer.swift +++ b/Kanban/LiveStore/BoardAnnouncer.swift @@ -244,21 +244,6 @@ 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() {} } @@ -319,14 +304,6 @@ 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 } @@ -339,9 +316,6 @@ public enum BoardAnnouncer { if facts.breakageBefore != nil, facts.breakageAfter == nil { return AccessibilityPhrases.reloadBreakageCleared } - if facts.repositoryUnreadableBefore, !facts.repositoryUnreadableAfter { - return AccessibilityPhrases.repositoryUnreadableCleared - } return nil } } diff --git a/Kanban/LiveStore/BoardRegistry.swift b/Kanban/LiveStore/BoardRegistry.swift index 4fceea0..015df5a 100644 --- a/Kanban/LiveStore/BoardRegistry.swift +++ b/Kanban/LiveStore/BoardRegistry.swift @@ -158,16 +158,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { /// `UserDefaults`. public var remoteLocationWarned: Bool - /// **A bracketed git operation this app started and has not finished** (06-history-undo.md ▸ Rules - /// ▸ Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board - /// registry) before touching the repo"). - /// - /// `nil` for every board that is not mid-operation, which is every board almost all of the time: - /// the stamp is written immediately before the repository is touched and cleared as soon as the - /// operation is over, so finding one at open means the app died in between. See - /// `GitOperationStamp` for why it lives here rather than in the board folder or under `.git`. - public var gitOperationStamp: GitOperationStamp? - public init( id: UUID = UUID(), bookmark: Data, @@ -182,8 +172,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { pushOnCommit: Bool = false, remoteLocationWarned: Bool = false, icon: String? = nil, - iconColor: String? = nil, - gitOperationStamp: GitOperationStamp? = nil + iconColor: String? = nil ) { self.id = id self.bookmark = bookmark @@ -199,7 +188,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { self.remoteLocationWarned = remoteLocationWarned self.icon = icon self.iconColor = iconColor - self.gitOperationStamp = gitOperationStamp } // MARK: Codable @@ -227,7 +215,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { case iconColor case pushOnCommit case remoteLocationWarned - case gitOperationStamp } public init(from decoder: any Decoder) throws { @@ -246,11 +233,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor) pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false - // Tolerant twice over: absent on every record written before this key existed, and absent - // again — rather than fatal — if a future build's stamp `Kind` is one this build cannot name. - // A stamp that cannot be read is a stamp that cannot recover anything, which degrades to the - // pause-and-defer stance rather than to a quarantined registry. - gitOperationStamp = try? container.decodeIfPresent(GitOperationStamp.self, forKey: .gitOperationStamp) } public func encode(to encoder: any Encoder) throws { @@ -269,7 +251,6 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable { try container.encodeIfPresent(iconColor, forKey: .iconColor) try container.encode(pushOnCommit, forKey: .pushOnCommit) try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned) - try container.encodeIfPresent(gitOperationStamp, forKey: .gitOperationStamp) // An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's // forward tolerance is a decoding property, and nothing here preserves what it cannot read. } @@ -599,22 +580,6 @@ public final class BoardRegistry { update(id) { $0.remoteLocationWarned = true } } - /// **Records — or clears — the bracketed git operation this app is about to run** - /// (`GitOperationStamp`). - /// - /// It saves the file synchronously like every other setter here, and that is load-bearing rather - /// than incidental: the whole value of the stamp is that it is on disk *before* the repository is - /// touched, so a crash a millisecond later is still recognizable as this app's. `save()` writes - /// atomically, so the file a next launch reads is either the old one or this one. - public func setGitOperationStamp(id: UUID, _ stamp: GitOperationStamp?) { - update(id) { $0.gitOperationStamp = stamp } - } - - /// The stamp this board is carrying, if any — read once per session, at open. - public func gitOperationStamp(id: UUID) -> GitOperationStamp? { - record(id: id)?.gitOperationStamp - } - // MARK: - Reading /// Every known board, most recently opened first, each classified by whether its bookmark diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 9ca2a94..edc07c2 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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. diff --git a/Kanban/LiveStore/EchoLedger.swift b/Kanban/LiveStore/EchoLedger.swift index 51ffd1f..692ce20 100644 --- a/Kanban/LiveStore/EchoLedger.swift +++ b/Kanban/LiveStore/EchoLedger.swift @@ -318,22 +318,24 @@ public final class EchoLedger: Sendable { /// **Every receipt the ledger holds right now, with its heal mark — read, never consumed.** /// - /// Pro's auto-committer's one call (`GitAutoCommitter.harvest`), and it has to be a copy rather - /// than a read at commit time for an ordering reason worth stating here: receipts are *consumed* - /// by the landing reload that classifies them ("one write, one echo"), and the committer asks its - /// question a debounce later — by which time the receipt for the user's own card edit is long - /// gone, and reading the live ledger would attribute the user's own work to `Lanework External`. - /// So the committer copies at the close of each write bracket, when a receipt describes a - /// completed write and nothing has yet had a chance to retire it, and re-applies the satisfaction - /// rule against disk itself (`CommitAttribution`). + /// **The attribution surface** — the seam a provenance consumer harvests through, and it has to be + /// a copy rather than a read at attribution time for an ordering reason worth stating here: + /// receipts are *consumed* by the landing reload that classifies them ("one write, one echo"), and + /// a consumer asks its question a debounce later — by which time the receipt for the user's own + /// card edit is long gone, and reading the live ledger would attribute the user's own work to an + /// external writer. So a consumer copies at the close of each write bracket, when a receipt + /// describes a completed write and nothing has yet had a chance to retire it, and re-applies the + /// satisfaction rule against disk itself. /// /// Nothing is retired here, which is what makes this safe to call on every bracket: the - /// announcer's consumption still decides what speaks, and the committer's copy still decides what - /// each commit is authored by. - // The harvest speaks `HarvestedReceipt` — committer-side vocabulary from - // Git/CommitAttribution.swift, which the phone target doesn't compile (the mobile MVP has - // no git). The recording side above is what BoardWriter needs on every platform; this copy - // has exactly one caller and it is Mac-only. + /// announcer's consumption still decides what speaks, and a harvested copy still decides what each + /// batch is attributed to. + /// + /// **Kept with no live consumer** (`strategy/01-git-excision.md` ▸ What is kept): the harvest is + /// the foundation the foreign-change journal will be built on, so the surface stays even though + /// the git consumer that drove it has gone. + // Mac-only because `HarvestedReceipt` is, and the phone target doesn't compile it. The recording + // side above is what BoardWriter needs on every platform; this copy is Mac's alone. #if os(macOS) func outstandingEntries() -> [String: HarvestedReceipt] { receipts.withLock { store in diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 66353f3..5ede3fc 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -1546,7 +1546,7 @@ public struct LoadResult: Sendable { /// keeps the loader a pure function whose caller holds the whole of what the next call may reuse. /// /// A caller that ignores it gets a cold walk every time, which is exactly what - /// `TemplateEngine`, `GitHeadSnapshot` and every first load do. + /// `TemplateEngine` and every first load do. public var memo: BoardLoader.ParseMemo = BoardLoader.ParseMemo() /// **The typed defect stream** — everything this walk found that is pending *work* diff --git a/Kanban/UI/AccessibilityPhrases.swift b/Kanban/UI/AccessibilityPhrases.swift index bda4609..ba1d805 100644 --- a/Kanban/UI/AccessibilityPhrases.swift +++ b/Kanban/UI/AccessibilityPhrases.swift @@ -432,13 +432,4 @@ enum AccessibilityPhrases { /// Reload breakage clearing — the board is reading its files again, which is a smaller claim /// than the lock's and is deliberately phrased as one. static let reloadBreakageCleared = "The board is loading again" - - /// The unreadable repository clearing (06-history-undo.md ▸ Rules, ruled 2026-07-31: "the banner - /// clears when a later open or reload finds the repo readable"). - /// - /// Stated as the regained capability, `readOnlyLockCleared`'s rule: what the user was waiting on - /// is history advancing again, not libgit2 changing its mind about a folder. It stays the - /// smaller claim of the two — nothing about editing the board was ever blocked by this - /// condition, which is precisely what its own row says. - static let repositoryUnreadableCleared = "History is recording again" } diff --git a/Kanban/UI/Card/CardBodyEditSession.swift b/Kanban/UI/Card/CardBodyEditSession.swift index 74a10b3..e535f24 100644 --- a/Kanban/UI/Card/CardBodyEditSession.swift +++ b/Kanban/UI/Card/CardBodyEditSession.swift @@ -212,14 +212,12 @@ public final class CardBodyEditSession { } /// **Throws the buffer away and takes disk's word for it** — the Discard branch of the - /// save-or-discard step (06-history-undo.md ▸ Branch switching: "Discard reverts buffers and - /// uncommitted saves to HEAD"). + /// save-or-discard step (13-native-undo.md ▸ Undo routing; `SessionSettleGate`). /// - /// It reverts the *buffer* and ends the session; the uncommitted on-disk saves are the operation - /// behind the step's to undo, because only that operation knows which state it is restoring to - /// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)` reconciles the card's folder - /// against the working tree for exactly this reason). Splitting it that way is what keeps the two - /// halves from being two answers able to disagree: one pass writes the card's files, once. + /// It reverts the *buffer* and ends the session; any uncommitted on-disk saves are the wholesale + /// operation behind the step's to reconcile, because only that operation knows which state it is + /// restoring to. Splitting it that way is what keeps the two halves from being two answers able to + /// disagree: one pass writes the card's files, once. /// /// The pending debounce is cancelled first, which is the load-bearing half — a surviving timer /// would write the discarded text back over the restored card a moment later. diff --git a/KanbanTests/AppModelTests.swift b/KanbanTests/AppModelTests.swift index d97a6a8..362dc88 100644 --- a/KanbanTests/AppModelTests.swift +++ b/KanbanTests/AppModelTests.swift @@ -62,49 +62,6 @@ private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef return ref } -/// The same board with a **real repository** at its root, root commit and all — what a Pro session -/// detects as mode `git`, and therefore the only shape that composes a branch switcher to test the -/// settle step's seams through. -@MainActor -private func makeProGitBoard() throws -> WriterFixture { - let fixture = try makeMixedBoard() - guard case .success = GitRepository.create(at: fixture.root) else { - fixture.tearDown() - Issue.record("could not initialize a repository for the fixture board") - throw CocoaError(.fileWriteUnknown) - } - return fixture -} - -/// Opens a card window the way its host does — registered with the board's session — and leaves it -/// holding one fine step and an open Edit session, so it both *has* a stack to lose and answers the -/// save-or-discard step's `needsSettling` with `true`. -/// -/// The step is registered through `BoardStore.registerStep` rather than pushed onto the provider, so -/// it is routed by the same line production routes a card-window gesture with (`on:` → the window's -/// stack) and carries the raw write the close fold would look for. -@MainActor -@discardableResult -private func openCardWindow( - _ model: AppModel, - board: BoardWindowRef, - card id: String, - store: BoardStore -) -> CardWindowSession { - let window = CardWindowSession() - model.registerCardWindow(CardWindowRef(board: board, cardID: ItemID(rawValue: id)), session: window) - window.body.beginEditSession() - store.registerStep( - "Edit Card", - on: window.undo, - undoExpects: [.present(.card(ItemID(rawValue: id)), .body("after\n"))], - redoExpects: [.present(.card(ItemID(rawValue: id)), .body("before\n"))], - undo: { _ in }, - redo: { _ in } - ) - return window -} - // MARK: - Tests @MainActor @@ -319,101 +276,6 @@ struct AppModelTests { model.storeRegistry.release(try #require(model.session(for: ref)?.store)) } - // MARK: The branch switch's settle - - /// **"The settle also clears each open card window's fine undo stack"** (06-history-undo.md - /// ▸ Branch switching, ruled 2026-07-31): "pre-switch steps describe the branch being left — Save - /// All and Discard alike end with every window's stack empty … the windows stay open, following - /// their cards onto the new branch with fresh stacks." - /// - /// This is an `AppModel` test rather than a `GitBranchSwitcher` one because the clear is a fact - /// about the **composition**: the switcher's settle seam, the card-window registry and the stacks - /// themselves only meet in `wireBranchSwitching`, and a switcher wired by hand would be a test - /// asserting its own wiring (the `a381fac` lesson, applied one card later). - @Test( - "The branch switch's settle empties every open card window's fine stack", - arguments: [SessionSettleChoice.saveAll, .discard] - ) - func theSettleClearsEveryFineStack(answering choice: SessionSettleChoice) async throws { - let board = try makeProGitBoard() - defer { board.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: board.root) - let session = try #require(model.session(for: ref)) - let switcher = try #require(session.git?.switcher) - - let windows = [Ident.card1, Ident.card2].map { id in - openCardWindow(model, board: ref, card: id, store: session.store) - } - #expect(windows.allSatisfy { $0.undo.stack.canUndo }) - #expect(windows.allSatisfy { $0.undo.netEffect() != nil }, "a session with a net effect to fold") - - model.settleAsk = { _ in choice } - #expect(await switcher.settleSessions?() == .proceed) - - for window in windows { - #expect(!window.undo.stack.canUndo, "the stack describes the branch being left") - #expect(!window.undo.manager.canUndo, "and ⌘Z in that window answers with it") - } - // "The windows stay open, following their cards onto the new branch with fresh stacks." - #expect(model.session(for: ref)?.cardRefs.count == 2) - } - - /// **Closing the stack is not closing the window.** The coarse step a card window owes its board is - /// registered at *close*, folded from this stack (13-native-undo.md ▸ Rules ▸ "Window close - /// coarsens"); a settle clear registers nothing at all, which is exactly what - /// `registerCardSession` answering `false` — and the deferred purge staying the caller's — says. - @Test("A settle clear registers no coarse step — the fold that would have run finds nothing") - func theClearRegistersNoCoarseStep() async throws { - let board = try makeProGitBoard() - defer { board.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: board.root) - let session = try #require(model.session(for: ref)) - let switcher = try #require(session.git?.switcher) - let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store) - - model.settleAsk = { _ in .saveAll } - #expect(await switcher.settleSessions?() == .proceed) - - #expect(window.undo.netEffect() == nil, "nothing left to fold") - var purged = false - let registered = session.store.registerCardSession( - window.undo, - inCard: ItemID(rawValue: Ident.card1), - retiring: { purged = true } - ) - #expect(!registered, "a close arriving right after the switch registers nothing") - #expect(!purged, "and the deferred purge is still the caller's, not a step's") - } - - /// "**Cancel** keeps the current branch and the sessions" — and now their stacks with them. The - /// same `if` that withholds the staging release withholds this. - @Test("Cancel clears nothing") - func cancelKeepsTheFineStacks() async throws { - let board = try makeProGitBoard() - defer { board.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - model.currentTier = { .pro } - - let ref = try openBoard(model, at: board.root) - let session = try #require(model.session(for: ref)) - let switcher = try #require(session.git?.switcher) - let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store) - - model.settleAsk = { _ in .cancel } - #expect(await switcher.settleSessions?() == .cancelled) - #expect(window.undo.stack.canUndo) - #expect(window.undo.netEffect() != nil) - } - // MARK: Launch restoration /// App ▸ Settings…'s "Restore open boards at launch" (11-command-nexus.md) gates the flagged set diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index 960204a..7cf0233 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -962,128 +962,6 @@ struct AutoCommitCompositionTests { } } -// MARK: - The composition root - -/// **The wired-at-`beginSession` seams, pinned where they are wired** (02-architecture.md ▸ Layering; -/// 12-editions.md ▸ The provider seam). -/// -/// Every suite above composes its own committer by hand, which is what makes them readable and is -/// exactly why they cannot see the defect this suite exists for: `AppModel.beginSession` once composed -/// the committer *without* the store's `EchoLedger` (`HistoryStore.compose`'s default is a fresh one, -/// for the store-less callers), so every unit layer passed while every production commit misattributed -/// — the app's own writes arriving unvouched-for and authored `Lanework External`. It was fixed in -/// `a381fac` by passing `store.echoes`, and nothing but a test that opens a board *through the model* -/// could have caught it or can keep it caught. -/// -/// So the assertions here are about the **composition** and never about the units: not "the ledger -/// classifies" (`AutoCommitAttributionTests`) and not "a bracket announces at completion" -/// (`BoardAnnouncerTests`), but that a board opened the way a window opens one has those two wires in -/// it. -@MainActor -@Suite("Auto-commit ▸ the composition root") -struct AutoCommitCompositionRootTests { - - /// An `AppModel` whose app-side state lives in temp rather than in the app's real Application - /// Support home — `AppModelTests`' own fixture, for its reason. - private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { - let folder = FileManager.default.temporaryDirectory - .appendingPathComponent("AutoCommitCompositionTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - let model = AppModel( - registryStorageURL: folder.appendingPathComponent("board-registry.json"), - clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) - ) - model.currentTier = { .pro } - return (model, { try? FileManager.default.removeItem(at: folder) }) - } - - /// Opens a board the way `BoardWindowHost` does — record, acquire, flag, begin — so what is under - /// test is the real `beginSession` and not a hand-assembled session. - private func openBoard(_ model: AppModel, at url: URL) throws -> AppModel.BoardSession { - let ref = BoardWindowRef(url: url) - let recordID = model.boardRegistry.recordOpen(of: url) - let store = try model.storeRegistry.acquire(url) - model.boardRegistry.setOpenNow(id: recordID) - model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) - return try #require(model.session(for: ref)) - } - - /// **(a) The committer is composed with the session store's own ledger** — the same instance the - /// store's writes drop receipts into (`BoardStore.echoes`). - /// - /// Asserted through the one thing the ledger decides: **authorship**. An ordinary app-mediated - /// write through the store, committed by the session's own committer, is authored by this - /// machine's user. Composed with any *other* ledger it would be authored `Lanework External` — - /// which is not a hypothetical shape, it is what `AutoCommitAttributionTests`' - /// `foreignIsLaneworkExternal` pins for a write nobody vouched for, and what this board's every - /// commit did before `a381fac`. - @Test("beginSession composes the committer with the store's own EchoLedger") - func theCommitterIsComposedWithTheStoresLedger() async throws { - let (fixture, _, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - - let session = try openBoard(model, at: fixture.root) - let committer = try #require(session.git?.committer) - // Only the explicit flush commits, and it does not sit out a watcher that a temp directory may - // or may not deliver events for: this test is about *who* the commit is by. - committer.stop() - committer.debounceInterval = .seconds(30) - committer.coveringSnapshotDeadline = .milliseconds(50) - committer.coveringSnapshotPollInterval = .milliseconds(5) - - // An ordinary write through the store — the Writer boundary, receipt and all. Nothing here - // touches the ledger by hand, which is the whole point: the receipt has to travel from the - // store's own ledger to the committer's, and there is only one way for that to be true. - let outcome = session.store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "By the app.\n") - #expect(outcome == .written) - await committer.flushNow() - - let head = try #require(try history(at: fixture.root).first) - #expect(head.authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email) - #expect( - head.authorEmail != CommitAttribution.externalAuthorEmail, - "a committer composed over any other ledger would blame the outside world for this write" - ) - } - - /// **(b) The announcer outlet is bound** — the undo restore's bracket runs through the store's - /// `performWholesale(announcing:)`, so its subject reaches `BoardStore.announce`. - /// - /// `GitHistoryProvider.runBracketed` is optional and "`nil` runs the work bare, which is what a - /// repository-level test wants" — so an unwired seam is silent rather than broken, and every - /// repository-level suite in this file would keep passing over one. What a session owes it is the - /// store's bracket: the watcher suspension, the reload floor that locks the board if the closing - /// reload fails, and 10-accessibility.md's one sentence at completion. - @Test("beginSession binds the restore's bracket to the board's announcer outlet") - func theRestoreBracketReachesTheAnnouncer() async throws { - let (fixture, _, _) = try await makeGitBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - - let session = try openBoard(model, at: fixture.root) - session.git?.committer?.stop() - let store = session.store - let provider = try #require(session.history as? GitHistoryProvider, "a git board binds the git provider") - - var spoken: [String] = [] - store.announce = { if let phrase = $0 { spoken.append(phrase) } } - - let bracket = try #require(provider.runBracketed, "the restore has a bracket to run inside") - await bracket("Undid 'Add card'") { - try? fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Restored")) - } - // The bracket's closing reload — the one it armed, whichever way the operation went. - store.handleWatcherEvent(.treeChanged(.appMediated)) - await store.awaitQuiescence() - - #expect(spoken == ["Undid 'Add card'"], "the trail's sentence and the spoken one are the same one") - #expect(store.readOnlyLock == nil, "the closing reload succeeded, so nothing is locked") - } -} - // MARK: - The Edit-session flag /// What the card body still owes the commit model after the stage-around widened to the whole window diff --git a/KanbanTests/BannerCenterTests.swift b/KanbanTests/BannerCenterTests.swift index d3dd37f..62ad8fc 100644 --- a/KanbanTests/BannerCenterTests.swift +++ b/KanbanTests/BannerCenterTests.swift @@ -85,15 +85,8 @@ struct BannerCenterOrderingTests { message: "Pasted 'Fix login' without its 3 attachments", occurredAt: Date(timeIntervalSince1970: 150) ) - let operation = InProgressOperation(label: "Pulling…") + let operation = InProgressOperation(label: "Duplicating…") let signpost = InfoSignpost(message: "This card changed on the remote") - // The failure class's second shape (settled 2026-07-31) — newer than the failed move, so it - // leads the rank the two of them share. - let restore = GitFailureBanner( - operation: .undo, - reason: "could not write to 'index.md': Permission denied", - occurredAt: Date(timeIntervalSince1970: 120) - ) let rows = BannerCenter.rows( lock: .vanishedRoot, @@ -102,126 +95,27 @@ struct BannerCenterOrderingTests { losses: [loss], suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)), operations: [operation], - signposts: [signpost], - gitFailures: [restore], - repositoryUnreadable: true + signposts: [signpost] ) - // in-progress (pinned) > read-only lock > reload breakage > **the unreadable repository** > - // one-shot failures, both shapes > loss rows > commit and attachment failures > passive info - // rows. The two info classes sit at opposite ends of the strip, and the breakage class holds - // two rows now (06-history-undo.md ▸ Rules, ruled 2026-07-31): the reload breakage first, - // because it is the one saying the board on screen is not the board on disk. + // in-progress (pinned) > read-only lock > reload breakage > one-shot failures > loss rows > + // commit and attachment failures > passive info rows. The two info classes sit at opposite + // ends of the strip. #expect(rows.map(\.id) == [ "operation:\(operation.id.uuidString)", "read-only-lock", "reload-breakage", - "repository-unreadable", - "git-failure:\(restore.id.uuidString)", "one-shot:\(move.id.uuidString)", "loss:\(loss.id.uuidString)", "history-suspension", "one-shot:\(attachment.id.uuidString)", "signpost:\(signpost.id.uuidString)", ]) - #expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .error, .warning, .warning, .error, .info]) - #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false, false], + #expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info]) + #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false], "a spinner may never hide behind '+N more' — nothing else is pinned") } - /// **The corrupt-`.git` loud failure's row** (06-history-undo.md ▸ Rules, ruled 2026-07-31) — - /// it stands with the breakage class and above every one-shot, which is what "breakage-class" - /// buys it: a failed move posted a second ago never pushes it down the strip. - @Test("The unreadable repository outranks every failure, and only the breakage class outranks it") - func theUnreadableRepositoryStandsInTheBreakageClass() { - let move = OneShotBanner(error: error(.move(title: "Fix login"))) - let rows = BannerCenter.rows( - lock: nil, - breakage: nil, - oneShots: [move], - losses: [], - suspension: nil, - operations: [], - repositoryUnreadable: true - ) - - #expect(rows.map(\.id) == ["repository-unreadable", "one-shot:\(move.id.uuidString)"]) - #expect(rows.first?.tone == .error, "the ruling's word is breakage, and breakage is an error") - } - - @Test("A readable repository contributes no row at all") - func aReadableRepositoryIsSilent() { - let rows = BannerCenter.rows( - lock: nil, - breakage: nil, - oneShots: [], - losses: [], - suspension: nil, - operations: [], - repositoryUnreadable: false - ) - - #expect(rows.isEmpty) - } - - @Test("Both failure shapes share one rank, interleaved by recency") - func theFailureRankHoldsBothShapes() { - // "Failures rank by what they are, not by which error vocabulary threw them" (02 § The - // banner surface, settled 2026-07-31): the two shapes are one precedence class, so recency - // — not vocabulary — decides which of them a user reads first. - let oldMove = OneShotBanner(error: error(.move(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1)) - let newMove = OneShotBanner(error: error(.move(title: "New")), occurredAt: Date(timeIntervalSince1970: 3)) - let oldSwitch = GitFailureBanner( - operation: .branchSwitch, - reason: "your local changes would be overwritten", - occurredAt: Date(timeIntervalSince1970: 2) - ) - let newUndo = GitFailureBanner( - operation: .undo, - reason: "the repository is locked", - occurredAt: Date(timeIntervalSince1970: 4) - ) - - let rows = BannerCenter.rows( - lock: nil, - breakage: nil, - oneShots: [oldMove, newMove], - losses: [LossBanner(message: "Folders can't be attached — 1 skipped")], - suspension: nil, - operations: [], - gitFailures: [oldSwitch, newUndo] - ) - - #expect(rows.map(\.id).prefix(4) == [ - "git-failure:\(newUndo.id.uuidString)", - "one-shot:\(newMove.id.uuidString)", - "git-failure:\(oldSwitch.id.uuidString)", - "one-shot:\(oldMove.id.uuidString)", - ]) - #expect(rows.map(\.tone) == [.error, .error, .error, .error, .warning], - "and every one of them is a failure, above the warning-tone loss row") - } - - @Test("A git failure outranks a loss row however much older it is — the compromise is retired") - func aGitFailureOutranksALossRow() { - // The shipped build posted these as loss rows, which put a failed ⌘Z *below* a folder-drop - // notice and painted it warning-tone. Both halves of that are retired (settled 2026-07-31). - let ancient = GitFailureBanner( - operation: .redo, - reason: "the repository is locked", - occurredAt: Date(timeIntervalSince1970: 1) - ) - let fresh = LossBanner(message: "Folders can't be attached — 2 skipped", occurredAt: Date(timeIntervalSince1970: 900)) - - let rows = BannerCenter.rows( - lock: nil, breakage: nil, oneShots: [], losses: [fresh], suspension: nil, operations: [], - gitFailures: [ancient] - ) - - #expect(rows.map(\.id) == ["git-failure:\(ancient.id.uuidString)", "loss:\(fresh.id.uuidString)"]) - #expect(rows.map(\.tone) == [.error, .warning]) - } - @Test("An attachment failure ranks below other one-shots even when it is newer") func attachmentFailuresRankLast() { let attachment = OneShotBanner( @@ -400,40 +294,14 @@ struct BannerCenterLifecycleTests { #expect(center.losses.count == 1, "a loss survives everything except its own dismissal") } - @Test("A git failure dismisses individually and is untimed — the one-shot's lifecycle exactly") - func gitFailuresDismissByIDAndNeverExpire() throws { - let center = BannerCenter() - center.postGitFailure(.undo, reason: "the repository is locked") - center.postGitFailure(.branchSwitch, reason: "your local changes would be overwritten") - #expect(center.gitFailures.count == 2) - #expect(center.gitFailures.map(\.operation) == [.branchSwitch, .undo], "newest first on insertion") - - let doomed = try #require(center.gitFailures.first) - center.dismiss(doomed.id) - - #expect(center.gitFailures.count == 1) - #expect(center.gitFailures.first?.id != doomed.id, "dismissing one must not take its neighbour") - - // No timer, no auto-expiry: an error never evaporates unread, whichever vocabulary raised it. - let id = center.beginOperation(label: "Switching to 'main'…", cancel: nil) - center.endOperation(id) - center.suspendHistory(reason: "disk full") - center.clearHistorySuspension() - #expect(center.gitFailures.count == 1, "a failure survives everything except its own dismissal") - - // And it is a *failure*, so nothing about it lands in the loss class. - #expect(center.losses.isEmpty) - #expect(center.oneShots.isEmpty) - } - - @Test("Dismissing all dismissable rows clears losses along with both failure shapes and signposts") + @Test("Dismissing all dismissable rows clears losses along with the failures and signposts") func dismissAllClearsLosses() { let center = BannerCenter() center.postLoss("Pasted 'Fix login' without its 3 attachments") - center.postGitFailure(.redo, reason: "the repository is locked") + center.postSignpost("This card changed on the remote") center.dismissAllDismissableRows() #expect(center.losses.isEmpty) - #expect(center.gitFailures.isEmpty) + #expect(center.signposts.isEmpty) } @Test("postSkippedFolders no-ops when nothing was skipped") @@ -471,51 +339,6 @@ struct BannerCenterLifecycleTests { ).isEmpty) } - /// **The corrupt-`.git` loud failure** (06-history-undo.md ▸ Rules, ruled 2026-07-31): the row - /// is raised at detection, stands with no dismiss, and *heals* — "the banner clears when a later - /// open or reload finds the repo readable". - @Test("The unreadable repository is a standing condition that heals, never a dismissable row") - func theUnreadableRepositoryIsAHealingCondition() throws { - let center = BannerCenter() - #expect(!center.isRepositoryUnreadable) - - center.raiseRepositoryUnreadable() - #expect(center.isRepositoryUnreadable) - - let rows = BannerCenter.rows( - lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [], - repositoryUnreadable: center.isRepositoryUnreadable - ) - #expect(rows.count == 1) - #expect(rows[0].tone == .error) - #expect(rows[0].dismissID == nil, "a condition is never dismissed while it is still true") - - // 06's own sentence, verbatim — the three clauses being what is wrong, what it costs, and - // the promise that makes waiting safe. - #expect(rows[0].headline - == "This board's git repository can't be read — history is paused; Lanework leaves the repository untouched") - #expect(rows[0].headline == BannerCenter.repositoryUnreadableMessage) - - center.clearRepositoryUnreadable() - #expect(!center.isRepositoryUnreadable) - #expect(BannerCenter.rows( - lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [], - repositoryUnreadable: center.isRepositoryUnreadable - ).isEmpty) - } - - @Test("Raising and clearing are idempotent — a re-read that confirms the condition changes nothing") - func raisingTheUnreadableRepositoryIsIdempotent() { - let center = BannerCenter() - center.raiseRepositoryUnreadable() - center.raiseRepositoryUnreadable() - #expect(center.isRepositoryUnreadable) - - center.clearRepositoryUnreadable() - center.clearRepositoryUnreadable() - #expect(!center.isRepositoryUnreadable) - } - @Test("Re-suspending keeps the original start and takes the newer diagnosis") func resuspendingKeepsTheClock() throws { let center = BannerCenter() @@ -544,7 +367,7 @@ struct BannerCenterLifecycleTests { Issue.record("expected an in-progress row") return } - #expect(!operation.isCancelable, "git brackets get no Cancel — settled") + #expect(!operation.isCancelable, "an operation that cannot be abandoned gets no Cancel — settled") center.endOperation(id) rows = BannerCenter.rows( @@ -651,9 +474,9 @@ struct BannerRowControlsTests { #expect(cancelled.value) } - @Test("A git bracket's row offers no control at all — no Cancel, nothing to dismiss") + @Test("An uncancelable bracket's row offers no control at all — no Cancel, nothing to dismiss") func uncancelableInProgressRowsOfferNothing() { - let row = BannerRow.inProgress(InProgressOperation(label: "Pulling…")) + let row = BannerRow.inProgress(InProgressOperation(label: "Rebuilding…")) #expect(row.controls.isEmpty) } @@ -664,11 +487,8 @@ struct BannerRowControlsTests { let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments") let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win") - let gitFailure = GitFailureBanner(operation: .undo, reason: "the repository is locked") - for (row, id) in [ (BannerRow.oneShot(banner), banner.id), - (BannerRow.gitFailure(gitFailure), gitFailure.id), (BannerRow.loss(loss), loss.id), (BannerRow.signpost(signpost), signpost.id), ] { @@ -688,8 +508,7 @@ struct BannerRowControlsTests { let rows: [BannerRow] = [ .readOnlyLock(.vanishedRoot), .reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))), - .repositoryUnreadable, - .historySuspended(HistorySuspension(reason: "the repository is corrupt")), + .historySuspended(HistorySuspension(reason: "the volume is full")), ] for row in rows { @@ -706,10 +525,9 @@ struct BannerRowControlsTests { .readOnlyLock(.vanishedRoot), .reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))), .oneShot(OneShotBanner(error: error(.move(title: "Fix login")))), - .gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")), .loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")), .historySuspended(HistorySuspension(reason: "disk full")), - .inProgress(InProgressOperation(label: "Pulling…")), + .inProgress(InProgressOperation(label: "Duplicating…")), .signpost(InfoSignpost(message: "This card changed on the remote")), ] @@ -797,53 +615,6 @@ struct BannerCenterPhrasingTests { #expect(uneditable.contains("frontmatter")) } - @Test("Every git operation names itself in the user's words, with the error as the tail") - func everyGitOperationSaysSomethingDistinct() { - // The vocabulary is closed and the sentences are here, not at the call sites (02 § The - // banner surface, settled 2026-07-31: "the operation named in the user's words plus the - // underlying error, phrasing still BannerCenter's"). `CaseIterable` is what keeps this test - // honest when pro-m2 adds pull and push. - let headlines = GitOperation.allCases.map { - BannerCenter.headline(for: GitFailureBanner(operation: $0, reason: "the repository is locked")) - } - - for (operation, headline) in zip(GitOperation.allCases, headlines) { - #expect(!headline.isEmpty, "\(operation) has no headline") - #expect(headline.hasSuffix(" — the repository is locked"), "\(operation) drops the underlying error") - #expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice") - } - #expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong") - } - - @Test("The git failure's sentences are the ruling's own") - func gitFailureSentencesArePinned() { - // Pinned as literals, unlike most phrasing here, because 02 wrote these two shapes by hand - // and the third is their mirror: the undo pair names the command the user pressed, the - // switch names the control they used. - #expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: "the repository is locked")) - == "Undo failed — the repository is locked") - #expect(BannerCenter.headline(for: GitFailureBanner(operation: .redo, reason: "the repository is locked")) - == "Redo failed — the repository is locked") - #expect(BannerCenter.headline(for: GitFailureBanner( - operation: .branchSwitch, - reason: "your local changes would be overwritten" - )) == "Couldn't switch branches — your local changes would be overwritten") - - // The tail is trimmed like every other diagnostic tail, and an absent one leaves the action - // clause alone rather than trailing a dash into nothing. - #expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " the disk is full. ")) - == "Undo failed — the disk is full") - #expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " ")) == "Undo failed") - } - - @Test("The restore pair maps from the direction the provider crossed in") - func restoreOperationsMapFromDirection() { - // The provider knows which key was pressed and nothing else about banners; this is the whole - // of the translation, kept in one place so no wiring can get it backwards. - #expect(GitOperation.restore(.undo) == .undo) - #expect(GitOperation.restore(.redo) == .redo) - } - @Test("Every lock reason says what is wrong and that the view is still the last good one") func lockHeadlinesReassure() { let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot] @@ -1004,52 +775,19 @@ struct BannerCenterStoreTests { let store = try BoardStore(rootURL: fixture.root) store.enterUnwritableLock(.permissionDenied) - store.banners.postGitFailure(.undo, reason: "the working tree is locked") store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full"))) store.banners.postLoss("Pasted 'Fix login' without its 3 attachments") store.banners.suspendHistory(reason: "the disk is full") store.banners.beginOperation(label: "Duplicating…", cancel: nil) store.banners.postSignpost("This card changed on the remote") - // The git failure posts before the write failure, so recency (and the tie rule alike) - // puts the write one-shot first within the shared failure rank. #expect(store.bannerRows.map(\.id) == [ "operation:\(store.banners.operations[0].id.uuidString)", "read-only-lock", "one-shot:\(store.banners.oneShots[0].id.uuidString)", - "git-failure:\(store.banners.gitFailures[0].id.uuidString)", "loss:\(store.banners.losses[0].id.uuidString)", "history-suspension", "signpost:\(store.banners.signposts[0].id.uuidString)", ]) } - - /// **The row the git state raises, through the store** (06-history-undo.md ▸ Rules, the - /// corrupt-`.git` loud failure, ruled 2026-07-31) — raised and healed by - /// `noteRepositoryUnreadable(_:)`, which is the seam `AppModel.beginSession` wires the - /// committer's pause transitions to, and **announced** both ways per 10-accessibility.md. - @Test("The unreadable repository stands on the strip and is spoken when it appears and clears") - func theUnreadableRepositoryRowIsRaisedAndSpoken() throws { - let fixture = try makeBoardWithUneditableLane() - defer { fixture.tearDown() } - let store = try BoardStore(rootURL: fixture.root) - - var spoken: [String] = [] - store.announce = { if let line = $0 { spoken.append(line) } } - - store.noteRepositoryUnreadable(true) - - #expect(store.bannerRows.map(\.id) == ["repository-unreadable"]) - #expect(store.bannerRows[0].headline == BannerCenter.repositoryUnreadableMessage) - #expect(spoken == ["Error: \(BannerCenter.repositoryUnreadableMessage)"], - "a standing banner is announced when it appears — the row's own sentence, tone first") - - // The 15 s re-read confirming what is already standing must not say it again. - store.noteRepositoryUnreadable(true) - #expect(spoken.count == 1) - - store.noteRepositoryUnreadable(false) - #expect(store.bannerRows.isEmpty) - #expect(spoken.last == "History is recording again") - } } diff --git a/KanbanTests/BoardAnnouncerTests.swift b/KanbanTests/BoardAnnouncerTests.swift index 7226468..1e3fd79 100644 --- a/KanbanTests/BoardAnnouncerTests.swift +++ b/KanbanTests/BoardAnnouncerTests.swift @@ -441,50 +441,6 @@ struct BoardAnnouncerSpeechTests { ) } - /// **The corrupt-`.git` loud failure, spoken** (06-history-undo.md ▸ Rules, ruled 2026-07-31: - /// "announced per 10-accessibility.md"). It ranks last of the raised conditions, matching the - /// strip's own precedence: the two above it describe the board's files, this one the history - /// over them. - @Test("The unreadable repository announces on arrival, under the conditions about the files") - func raisedRepositoryUnreadable() { - var facts = BoardAnnouncer.ReloadFacts() - facts.repositoryUnreadableAfter = true - - #expect( - BoardAnnouncer.speech(for: facts) - == AccessibilityPhrases.bannerLabel( - tone: .error, - headline: BannerCenter.repositoryUnreadableMessage - ) - ) - - // A breakage standing beside it leads: the board on screen not being the board on disk is - // the more consequential of the two. - facts.breakageAfter = breakage() - #expect( - BoardAnnouncer.speech(for: facts) - == AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage())) - ) - } - - @Test("A repository that heals is announced too, as the regained capability") - func clearedRepositoryUnreadable() { - var facts = BoardAnnouncer.ReloadFacts() - facts.repositoryUnreadableBefore = true - facts.repositoryUnreadableAfter = false - - #expect(BoardAnnouncer.speech(for: facts) == "History is recording again") - } - - @Test("A standing unreadable repository is not repeated on every re-read") - func standingRepositoryUnreadableIsNotRepeated() { - var facts = BoardAnnouncer.ReloadFacts() - facts.repositoryUnreadableBefore = true - facts.repositoryUnreadableAfter = true - - #expect(BoardAnnouncer.speech(for: facts) == nil, "the 15 s re-read confirms; it does not narrate") - } - @Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears") func clearedLock() { var facts = BoardAnnouncer.ReloadFacts() diff --git a/KanbanTests/BoardDecisionSurfaceTests.swift b/KanbanTests/BoardDecisionSurfaceTests.swift index fad034f..b79390e 100644 --- a/KanbanTests/BoardDecisionSurfaceTests.swift +++ b/KanbanTests/BoardDecisionSurfaceTests.swift @@ -1,5 +1,4 @@ import Foundation -import SwiftGitX import Testing @testable import Kanban @@ -653,117 +652,3 @@ struct BoardDecisionSurfaceAttendanceTests { #expect(second.access == nil) } } - -// MARK: - The Pro repair commit - -/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer -/// that made the commits (`AutoCommitTests`' rule, kept: nothing here shells out to `git`). -private func repairHistory(at boardRoot: URL, limit: Int = 8) throws -> [(subject: String, authorName: String, authorEmail: String, committerName: String)] { - let repository = try Repository.open(at: boardRoot) - guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] } - - var records: [(String, String, String, String)] = [] - var current: Commit? = tip - while let commit = current, records.count < limit { - records.append((commit.summary, commit.author.name, commit.author.email, commit.committer.name)) - current = (try? commit.parents)?.first - } - return records -} - -@MainActor -@Suite("Decision surface ▸ the Pro repair commit") -struct BoardDecisionSurfaceRepairCommitTests { - - /// An `AppModel` whose app-side state lives in temp rather than in the app's real Application - /// Support home, and which reads as Pro — `AutoCommitCompositionRootTests`' fixture. - private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { - let folder = FileManager.default.temporaryDirectory - .appendingPathComponent("DecisionRepairCommit-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - let model = AppModel( - registryStorageURL: folder.appendingPathComponent("board-registry.json"), - clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) - ) - model.currentTier = { .pro } - return (model, { try? FileManager.default.removeItem(at: folder) }) - } - - /// **A repaired Pro board's first flush is one heal commit, authored by the integrity identity** - /// (01-storage-format.md § Malformed input: "On Pro boards the repairs drop heal-marked receipts - /// and commit separately as one repair commit, never folded into anyone else's work"; - /// 06-history-undo.md ▸ Commit messages ▸ Healing mutations commit separately). - /// - /// The whole chain is exercised end to end, because every link in it can fail silently and the - /// symptom is identical each time — a commit blaming the outside world for the app's own repair: - /// - /// 1. The surface's default resolution is the minted repair. - /// 2. `BoardRepairRun` writes it store-lessly and marks every receipt in its own ledger. - /// 3. The store built by the following walk **adopts** that ledger (`EchoLedger.adopt`) — - /// before `beginSession`, which is where Pro's committer is composed and started. - /// 4. `GitAutoCommitter.start()` harvests, so the debounce it arms can see receipts that were - /// dropped before any write bracket of this session existed. - /// 5. `CommitAttribution.split` sorts the repaired path into the heal class, and the heal class - /// is authored `Lanework Integrity ` with the user as committer. - @Test("A repaired board under Pro + git produces a separate heal commit") - func aRepairCommitsAsTheIntegrityIdentity() async throws { - let fixture = try WriterFixture() - defer { fixture.tearDown() } - // A board whose root is missing `schema` — the minted stamp's own case. The lane is here so - // the repository has an ordinary tree around the file being repaired. - try fixture.item("", "---\ntitle: Needs A Stamp\ncreated: 2026-01-01T09:00:00Z\n---\nBoard.\n") - try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n") - - let (model, tearDown) = try makeModel() - defer { tearDown() } - - // The repository, with everything as it stands committed — including the broken root, which is - // what makes the repair a real change rather than a fresh file. - let git = HistoryStore.compose(boardRoot: fixture.root, ledger: EchoLedger()) - #expect(await git.addGit()) - let commitsBefore = try repairHistory(at: fixture.root).count - git.stopAutoCommit() - - // — The surface, exactly as the window builds it. - let surface = BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root) - #expect(surface.canRepairAndOpen, "a lone missing root schema is a minted repair, preselected") - - // — Repair and Open's write half. - let outcome = BoardRepairRun.apply(surface.plannedRepairs, boardRoot: fixture.root) - #expect(outcome.failure == nil) - - // — The walk that follows, and the store it builds. Built directly rather than through the - // registry so this case is about the repair's commit and not about the open's *other* heals - // (the agent guide, the `.gitignore` seed), which the registry's acquire also fires. - let result = try BoardLoader.load(boardRoot: fixture.root, skipping: surface.skipSet) - let store = BoardStore(rootURL: fixture.root, loaded: result, skipping: surface.skipSet) - // — The adoption, before the session composes the committer. - store.echoes.adopt(outcome.ledger) - - let ref = BoardWindowRef(url: fixture.root) - let recordID = model.boardRegistry.recordOpen(of: fixture.root) - model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) - - let committer = try #require(model.session(for: ref)?.git?.committer) - // The debounce `start()` armed is not what this asserts; the explicit flush is. - committer.stop() - committer.debounceInterval = .seconds(30) - committer.coveringSnapshotDeadline = .milliseconds(50) - committer.coveringSnapshotPollInterval = .milliseconds(5) - await committer.flushNow() - - let log = try repairHistory(at: fixture.root) - #expect(log.count == commitsBefore + 1, "one repair commit, never folded and never split further") - - let head = try #require(log.first) - #expect(head.authorName == CommitAttribution.integrityAuthorName) - #expect(head.authorEmail == CommitAttribution.integrityAuthorEmail) - #expect( - head.authorEmail != CommitAttribution.externalAuthorEmail, - "the app's own repair must never be blamed on the outside world" - ) - // "the committer stays the user (the recorded-by convention)". - #expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name) - #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "the flush leaves nothing dirty") - } -} diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index 870f263..bee87b0 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -803,32 +803,17 @@ struct BoardStoreReloadMemoTests { #expect(store.landedReloads == 2) } - @Test("A value-equal reload still lands for the auto-commit seam and the covering gate") + @Test("A value-equal reload still lands for the covering gate") func aValueEqualReloadStillLands() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let landings = LandingCount() - store.commitSeam = HistoryCommitSeam( - willWrite: {}, - writeBracketDidClose: {}, - reloadDidLand: { _ in landings.value += 1 } - ) _ = await reload(store) - // The covering gate counts landings, not assignments (`GitAutoCommitter.landedReloads`), and - // the commit seam is armed by every landing whether or not the snapshot moved — "a landing - // that finds nothing to commit is the silent no-op, not a wasted trip". - #expect(landings.value == 1) + // The covering gate counts landings, not assignments: a walk covers what it walked whether or + // not the tree turned out to differ. #expect(store.landedReloads == 1) #expect(store.snapshotGeneration == 0) } } - -/// What the reload path told the history seam — a box, because `HistoryCommitSeam` is a struct of -/// closures and a captured `var` cannot be read back after the reload has landed. -@MainActor -private final class LandingCount { - var value = 0 -} diff --git a/KanbanTests/CardSessionCommitTests.swift b/KanbanTests/CardSessionCommitTests.swift deleted file mode 100644 index ea8a9bf..0000000 --- a/KanbanTests/CardSessionCommitTests.swift +++ /dev/null @@ -1,570 +0,0 @@ -import Foundation -import SwiftGitX -import Testing -@testable import Kanban - -/// **The card window's session as the commit unit** (06-history-undo.md ▸ Rules ▸ Auto-commit, -/// widened 2026-07-31; 13-native-undo.md ▸ Interaction with the trash; 05-card-window.md ▸ The -/// comments column). -/// -/// > Board history sees **card-window sessions, not gestures** … while a card's window is open, -/// > everything happening inside it … stays **uncommitted**, and the committer **stages around the -/// > whole open card folder** … **window close flushes the session as one commit**. -/// -/// The claims here are all about *when* a commit exists, which is exactly the class of thing that -/// looks right in a running app and is wrong: a comment post that quietly landed its own commit, a -/// session's body arriving under `Lanework External` because an interim flush spent its receipt, a -/// `comments/.trash/` purge that committed separately from the delete it belongs to. So every test -/// runs a **real** repository over bundled libgit2, drives the window through the same seams -/// `CardWindowHost` wires, and reads every commit back through libgit2 rather than through the engine -/// that made it. Nothing shells out to `git` (`AutoCommitTests`' rule, kept). - -// MARK: - Fixtures - -private let cardID = ItemID(rawValue: Ident.card1) -private let cardPath = "\(Ident.lane1)/\(Ident.card1)" -private let earlierComment = CommentIdent.one - -/// One card window on a Pro git board — the store, the committer, and the session, wired to each -/// other exactly as `AppModel.beginSession` and `CardWindowHost` wire them. -/// -/// The stage-around is opened and closed through `open()` / `close()` below, which spell what -/// `AppModel.setCardSession(_:for:)` does; that method's *own* wiring — that a card window's -/// registration is what opens it — is pinned separately in `CardSessionStagingWiringTests`, over a -/// real `AppModel`. -@MainActor -private final class Window { - - let fixture: WriterFixture - let store: BoardStore - let git: HistoryStore - let committer: GitAutoCommitter - let session = CardWindowSession() - - /// Commits the board already had when the window opened — every assertion here is a delta, so a - /// board-open heal landing in the setup cannot be mistaken for a session's commit. - private(set) var baseline = 0 - - private var token: UUID? - - init(fixture: WriterFixture, store: BoardStore, git: HistoryStore, committer: GitAutoCommitter) { - self.fixture = fixture - self.store = store - self.git = git - self.committer = committer - } - - var comments: CardComments { session.comments } - var body: CardBodyEditSession { session.body } - - /// Commits landed since the window opened. - var commits: Int { committer.commitCount - baseline } - - func recordBaseline() { - baseline = committer.commitCount - } - - /// The window joins its board — `AppModel.registerCardWindow`, whose one git consequence is this - /// exclusion. - func open() { - let token = UUID() - self.token = token - committer.beginCardSession(token) { [weak store] in - guard let store, - let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil } - return path.folder(under: store.rootURL) - } - session.comments.open() - } - - /// The close, in the order production runs it: the session's own writes land, *then* the folder is - /// released, *then* the store settles, *then* the pipeline flushes (`CardWindowHost.finish`, - /// `CloseFlushCoordinator.flushPendingWork` — "the store's pipeline, then the editor saves, then - /// the pending commit"). - /// - /// The quiescence matters to the *message*, not to the commit: the composer diffs the store's - /// snapshot against HEAD's tree, so a flush that raced the session's own reload would describe the - /// window by its comment events alone. Production gets the same ordering from the committer's - /// two-second debounce outliving the watcher's. - func close() async { - await session.endSession() - if let token { committer.endCardSession(token) } - token = nil - await settle() - await committer.flushNow() - } - - /// Brings the store's snapshot up to what the session wrote, then waits for it to settle — the - /// close flush's own first step (`CloseFlushCoordinator.flushPendingWork`: "the store's pipeline, - /// then the editor saves, then the pending commit"). - /// - /// The reload is delivered by hand because this store has no watcher: the registry is what wires - /// `FolderWatcher` to `handleWatcherEvent(_:)` in production, and a suite that acquired one would - /// be testing FSEvents. What matters here is the *ordering* — the composer diffs the store's - /// snapshot against HEAD's tree, so a flush that ran ahead of the session's own reload would - /// describe the window by its comment events alone and lose the body edit. - func settle() async { - store.handleWatcherEvent(.treeChanged(.appMediated)) - await store.awaitQuiescence() - } - - /// The half of the close that happens before the release — used to prove the release is what - /// unblocks the commit rather than the passage of time. - func endSessionOnly() async { - await session.endSession() - } - - func releaseAndFlush() async { - if let token { committer.endCardSession(token) } - token = nil - await settle() - await committer.flushNow() - } -} - -/// A board with a card, one already-posted comment, a repository, and a root commit that has all of -/// it — the state a card window opens over. -@MainActor -private func makeWindow() async throws -> Window { - let fixture = try WriterFixture() - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) - try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) - try fixture.item(commentPath(earlierComment, inCard: cardPath), commentText(body: "posted earlier\n")) - - // The store first, and settled, so the board-open heals (the agent guide) are on disk *before* - // the root commit rather than arriving as a mystery commit in the middle of a test. - let store = try BoardStore(rootURL: fixture.root) - await store.awaitQuiescence() - - let git = HistoryStore.compose(boardRoot: fixture.root, ledger: store.echoes) - #expect(await git.addGit()) - - let committer = try #require(git.committer) - // Long enough that **only** an explicit `flushNow()` commits: "zero commits until close" has to be - // a fact about the stage-around, not about a debounce that had not fired yet. - committer.debounceInterval = .seconds(60) - committer.lockRetryDelay = .milliseconds(5) - committer.currentSnapshot = { [weak store] in store?.snapshot } - store.commitSeam = .binding(to: committer) - - let window = Window(fixture: fixture, store: store, git: git, committer: committer) - - // The window's own seams, `CardWindowHost.configureSession`'s three lines. - CardWindowHost.configureUndo(window.session, store: store, cardID: cardID) - CardWindowHost.configureComments(window.session.comments, store: store, cardID: cardID, on: window.session.undo) - window.session.comments.isEditable = true - window.session.comments.cardFolder = fixture.url(cardPath) - window.session.body.save = { [weak store] text in - store?.writeCardBody(inCard: cardID, body: text) ?? .vanished - } - window.session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body) - - // One reconciling reload lands the board-open heals (the agent guide), and whatever the setup - // left dirty commits now — so every assertion below is about the session and nothing else. - await window.settle() - await committer.flushNow() - #expect(isClean(at: fixture.root), "the window opens over a settled tree") - window.recordBaseline() - return window -} - -@MainActor -private func editBody(_ window: Window, to text: String) { - window.body.beginEditSession() - window.body.edited(text) - window.body.endEditSession() -} - -@MainActor -@discardableResult -private func postComment(_ window: Window, body: String) -> ItemID? { - window.comments.composer.edited(body) - _ = window.comments.composer.flush() - return window.comments.composer.postNow() -} - -// MARK: Reading the repository back - -private struct Landed: Equatable { - let subject: String - let message: String - let authorEmail: String -} - -/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer. -private func landed(at boardRoot: URL, limit: Int = 32) throws -> [Landed] { - let repository = try Repository.open(at: boardRoot) - guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] } - var records: [Landed] = [] - var current: Commit? = tip - while let commit = current, records.count < limit { - records.append(Landed( - subject: commit.summary, - message: commit.message, - authorEmail: commit.author.email - )) - current = (try? commit.parents)?.first - } - return records -} - -private func isClean(at boardRoot: URL) -> Bool { - GitCommitOperation.changedPaths(at: boardRoot).isEmpty -} - -private func tracked(at boardRoot: URL) -> Set { - Set(GitRepository.trackedPaths(at: boardRoot)) -} - -// MARK: - The close flush - -@MainActor -@Suite("Card session commits ▸ the close flush") -struct CardSessionCloseFlushTests { - - @Test("A body edit, a comment post and a comment delete commit nothing until the window closes") - func theSessionIsTheCommitUnit() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - window.open() - - editBody(window, to: "Edited in the window.\n") - let posted = try #require(postComment(window, body: "A remark.\n")) - #expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true) - - // Not "the debounce has not fired": the flush runs, sees the whole card folder staged around, - // and commits nothing. - await window.committer.flushNow() - #expect(window.commits == 0, "no gesture inside an open card window is a commit") - #expect(!isClean(at: window.fixture.root), "the session's writes are on disk, uncommitted") - #expect(window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)"), - "and the purge has not run: it belongs inside the close flush") - - await window.close() - - #expect(window.commits == 1, "window close flushes the session as one commit") - #expect(isClean(at: window.fixture.root)) - - // One commit, three changes: "'Update card 'Fix login''-shaped, the composer folding the - // card-scoped diff, body bullets carrying the events" (06 ▸ Rules ▸ Auto-commit) — the model - // event keeps the subject, the thread rides in the body. - let head = try #require(try landed(at: window.fixture.root).first) - #expect(head.subject == "Edit card 'Fix login'") - // A set, because the thread's two events sort by comment id and the posted one's is minted - // fresh every run — the *events* are the claim, not their order among themselves. - #expect(Set(head.message.split(separator: "\n").filter { $0.hasPrefix("- ") }) == [ - "- Edit card 'Fix login'", - "- Comment on 'Fix login'", - "- Delete comment on 'Fix login'", - ]) - - let paths = tracked(at: window.fixture.root) - #expect(paths.contains("\(cardPath)/comments/\(posted.rawValue)/index.md"), - "the post is in the commit") - #expect(!paths.contains("\(cardPath)/comments/\(earlierComment)/index.md"), - "so is the delete") - #expect(!paths.contains { $0.hasPrefix("\(cardPath)/comments/.trash/") }, - "and the purge — delete plus purge net to a removal (13 ▸ Interaction with the trash)") - #expect(!window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)")) - } - - @Test("The release is what unblocks the commit, not the end of the session's writes") - func theReleaseIsTheGate() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - window.open() - - editBody(window, to: "Edited in the window.\n") - - // Everything the session owed disk is written, and the folder is still held. - await window.endSessionOnly() - await window.committer.flushNow() - #expect(window.commits == 0) - - await window.releaseAndFlush() - #expect(window.commits == 1) - } - - @Test("A session with no net change registers no commit at all") - func anEmptySessionCommitsNothing() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - window.open() - - await window.close() - - #expect(window.commits == 0, "a window that was only read is not an event") - #expect(window.committer.lastFailure == nil, "an empty window is a no-op, never a failure") - #expect(isClean(at: window.fixture.root)) - } - - @Test("A session mixing two model events keeps the card's name in the subject") - func aMixedSessionNamesItsCard() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - window.open() - - // Two *model* kinds — an edit and a restyle — so no single verb can head the window. 06's - // retired "Update board" is exactly the subject that could not say which card this was. - editBody(window, to: "Edited in the window.\n") - window.store.applyStyle(to: .items([cardID]), background: .set("#334455"), on: window.session.undo) - postComment(window, body: "A remark.\n") - - await window.close() - - #expect(window.commits == 1) - let head = try #require(try landed(at: window.fixture.root).first) - #expect(head.subject == "Mixed update — 3 changes to card 'Fix login'") - } - - @Test("A draft the session never posted rides the close flush too, as one commit") - func theDraftRidesTheClose() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - window.open() - - window.comments.composer.edited("half a thought\n") - _ = window.comments.composer.flush() - await window.committer.flushNow() - #expect(window.commits == 0, "the draft-save cadence never becomes a commit stream") - - await window.close() - #expect(window.commits == 1) - #expect(tracked(at: window.fixture.root).contains("\(cardPath)/comments/.draft/index.md")) - } -} - -// MARK: - Board-side work, and the split - -@MainActor -@Suite("Card session commits ▸ what an open window does not hold back") -struct CardSessionInterimCommitTests { - - @Test("Board-side changes commit normally while a card window is open") - func theRestOfTheBoardIsUnaffected() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - window.open() - - editBody(window, to: "Edited in the window.\n") - try window.fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) - window.committer.noteReloadLanded(sawForeignChange: true) - await window.committer.flushNow() - - #expect(window.commits == 1, "the board's own change is not held by somebody's card window") - #expect(tracked(at: window.fixture.root).contains("\(Ident.lane2)/\(BoardLoader.indexFileName)")) - #expect(!isClean(at: window.fixture.root), "and the session folder is still held back") - - await window.close() - #expect(window.commits == 2) - #expect(isClean(at: window.fixture.root)) - } - - @Test("A held window mixing foreign work with the session's splits into two commits at close") - func theTwoCommitSplitSurvivesTheHeldWindow() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - window.open() - - // The app's own gesture, vouched for by a receipt in the store's ledger. - postComment(window, body: "Mine.\n") - - // Somebody else's, inside the same card folder — an agent dropping a file the app never - // witnessed. It is held back by the same exclusion, so the close flush is the first moment it - // can land, and the split is what keeps it out of the user's commit. - try window.fixture.file("\(cardPath)/attachments/notes.txt", Data("theirs\n".utf8)) - window.committer.noteReloadLanded(sawForeignChange: true) - - // An interim flush that commits nothing must not spend the session's receipts — this is the - // line the whole split depends on. - await window.committer.flushNow() - #expect(window.commits == 0) - - await window.close() - - #expect(window.commits == 2, "foreign and app-mediated never mix in one commit") - let trail = try landed(at: window.fixture.root) - let user = GitCommitOperation.userIdentity(at: window.fixture.root).email - #expect(trail.first?.authorEmail == user, "the user's overwrite lands after the foreign version") - #expect(trail.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail) - #expect(trail.first?.subject.contains("Comment on 'Fix login'") == true) - #expect(isClean(at: window.fixture.root)) - } - - @Test("A second card window's session is held independently of the first's") - func sessionsAreHeldPerCard() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - try window.fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) - window.committer.noteReloadLanded(sawForeignChange: true) - await window.committer.flushNow() - window.recordBaseline() - - window.open() - let other = UUID() - window.committer.beginCardSession(other) { window.fixture.url("\(Ident.lane1)/\(Ident.card2)") } - - editBody(window, to: "Edited in the window.\n") - try window.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/theirs.txt", Data("typing\n".utf8)) - window.committer.noteReloadLanded(sawForeignChange: true) - await window.committer.flushNow() - #expect(window.commits == 0, "two held folders, nothing to commit") - - await window.close() - #expect(window.commits == 1, "the first window's session, and only it") - #expect(!isClean(at: window.fixture.root), "the second card is still somebody's open session") - } -} - -// MARK: - Crossing the session commit - -@MainActor -@Suite("Card session commits ▸ undo crosses the session") -struct CardSessionRestoreTests { - - @Test("Board ⌘Z after the close crosses the session commit and restores the deleted comment") - func theSessionCommitIsOneUndoStep() async throws { - let window = try await makeWindow() - defer { window.fixture.tearDown() } - - let provider = GitHistoryProvider(boardRoot: window.fixture.root) - provider.flushPendingCommit = { [weak committer = window.committer] in await committer?.flushNow() } - provider.isHeld = { [weak committer = window.committer] in committer?.pause != nil } - provider.suspendCommitting = { [weak committer = window.committer] in committer?.stop() } - provider.resumeCommitting = { [weak committer = window.committer] in committer?.start() } - window.committer.reportLanded = { [weak provider] landed in provider?.noteLanded(landed) } - await provider.reseed() - - window.open() - editBody(window, to: "Edited in the window.\n") - postComment(window, body: "A remark.\n") - #expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true) - await window.close() - await provider.settled() - - #expect(window.commits == 1) - #expect(provider.canUndo, "the close commit is an ordinary step on the board's stack") - - await provider.cross(.undo) - - // A forward restore, never a rewrite (14-git-operations.md ▸ The forward-restore model). - let trail = try landed(at: window.fixture.root) - #expect(trail.first?.subject.hasPrefix("Undo: ") == true) - #expect(try FrontmatterDocument.parse(window.fixture.indexText(cardPath)).body - != "Edited in the window.\n", "the session's body edit is undone") - #expect(window.fixture.exists("\(cardPath)/comments/\(earlierComment)"), - "and the purged comment came back out of history — the whole session, in one step") - } -} - -// MARK: - The staging wiring - -/// What `AppModel` itself owes the rule: which moment opens the exclusion, which closes it, and the -/// settle step's release. Over a real model and a real board session, because every one of these is a -/// claim about production wiring rather than about the committer's own grammar. -@MainActor -@Suite("Card session commits ▸ the staging wiring") -struct CardSessionStagingWiringTests { - - private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { - let folder = FileManager.default.temporaryDirectory - .appendingPathComponent("CardSessionCommitTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - let model = AppModel( - registryStorageURL: folder.appendingPathComponent("board-registry.json"), - clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) - ) - model.currentTier = { .pro } - return (model, { try? FileManager.default.removeItem(at: folder) }) - } - - private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef { - let ref = BoardWindowRef(url: url) - let recordID = model.boardRegistry.recordOpen(of: url) - let store = try model.storeRegistry.acquire(url) - model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) - return ref - } - - /// A Pro git board, opened through the model — so the committer under test is the one production - /// composes, ledger and all. - private func makeBoard(_ model: AppModel) async throws -> (fixture: WriterFixture, ref: BoardWindowRef) { - let fixture = try WriterFixture() - try fixture.item("", Item.board) - try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) - try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) - let seed = HistoryStore.compose(boardRoot: fixture.root) - #expect(await seed.addGit()) - return (fixture, try openBoard(model, at: fixture.root)) - } - - @Test("Registering a card window opens the stage-around; unregistering releases it") - func theWindowIsTheUnit() async throws { - let (model, tearDown) = try makeModel() - defer { tearDown() } - let (fixture, ref) = try await makeBoard(model) - defer { fixture.tearDown() } - let committer = try #require(model.session(for: ref)?.git?.committer) - - let card = CardWindowRef(board: ref, cardID: cardID) - model.registerCardWindow(card, session: CardWindowSession()) - #expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1], - "the whole open card folder, from the moment the window joins its board") - - model.unregisterCardWindow(card) - #expect(committer.stagedAroundFolders.isEmpty) - - model.storeRegistry.release(try #require(model.session(for: ref)?.store)) - } - - @Test("The settle step releases every open session's staging, and the operation's end restores it") - func theSettleReleasesTheStaging() async throws { - let (model, tearDown) = try makeModel() - defer { tearDown() } - let (fixture, ref) = try await makeBoard(model) - defer { fixture.tearDown() } - let committer = try #require(model.session(for: ref)?.git?.committer) - let switcher = try #require(model.session(for: ref)?.git?.switcher) - - let card = CardWindowRef(board: ref, cardID: cardID) - model.registerCardWindow(card, session: CardWindowSession()) - #expect(!committer.stagedAroundFolders.isEmpty) - - // A window that is merely *open* answers `needsSettling` with `false` — no dirty buffer, no - // raw source — so no modal is presented and the gate proceeds. Its folder is still held, and - // a checkout over a held folder is the dirty tree the settle exists to prevent. - #expect(await switcher.settleSessions?() == .proceed) - #expect(committer.stagedAroundFolders.isEmpty, - "the widened stage-around releases at settle, modal or no modal") - - switcher.resumeCommitting?() - #expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1], - "and the still-open window is a session again on the other side") - - model.unregisterCardWindow(card) - model.storeRegistry.release(try #require(model.session(for: ref)?.store)) - } - - @Test("The board's close flush releases each session before the pipeline flushes") - func theCloseFlushReleasesBeforeItCommits() async throws { - let (model, tearDown) = try makeModel() - defer { tearDown() } - let (fixture, ref) = try await makeBoard(model) - defer { fixture.tearDown() } - let committer = try #require(model.session(for: ref)?.git?.committer) - let store = try #require(model.session(for: ref)?.store) - - let card = CardWindowRef(board: ref, cardID: cardID) - model.registerCardWindow(card, session: CardWindowSession()) - // The session's uncommitted work — the state a quit must not leave behind. - _ = store.writeCardBody(inCard: cardID, body: "Typed and never committed.\n") - await committer.flushNow() - #expect(!isClean(at: fixture.root), "held, as an open window's folder should be") - - await model.closeBoard(ref: ref, cause: .quit) - - #expect(isClean(at: fixture.root), - "nothing settled is left uncommitted by closing (06 ▸ Rules ▸ Auto-commit)") - } -} diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index 767cd49..3fb9f7b 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -587,7 +587,7 @@ struct BoardSessionHistoryTests { defer { tearDown() } let bound = FakeHistoryProvider() - model.makeHistoryProvider = { _, _ in bound } + model.makeHistoryProvider = { _ in bound } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) @@ -605,14 +605,13 @@ struct BoardSessionHistoryTests { defer { tearDown() } // 12-editions.md ▸ The entitlement: the tier is read at composition, once, and recorded. - // **PIVOT 2026-08-07**: it is no longer *handed* anywhere. This test used to pin the argument - // arriving at `makeHistoryProvider` (`{ _, tier, _ in }`); the parameter is gone, so what is - // pinned now is the pair of facts that replaced it — the seam takes a store and a git state - // and nothing else, and the session still carries the tier for the base/Pro split yet to be - // ruled. The substrate matrix itself is `GitUndoBindingTests`'. + // **PIVOT 2026-08-07**: it is no longer *handed* anywhere, and the 2026-08-08 excision took + // the last argument beside it — the seam takes a store and nothing else. What is pinned here + // is what replaced the old matrix: one call per board, and a session still carrying the tier + // for the base/Pro split yet to be ruled. var seen = 0 model.currentTier = { .pro } - model.makeHistoryProvider = { _, _ in + model.makeHistoryProvider = { _ in seen += 1 return NativeHistoryProvider() } diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift index 5d5952d..d83dec4 100644 --- a/KanbanTests/HistoryStoreTests.swift +++ b/KanbanTests/HistoryStoreTests.swift @@ -623,96 +623,3 @@ struct GitPathHistoryTests { #expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld") } } - -// MARK: - Session composition - -@MainActor -private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { - let folder = FileManager.default.temporaryDirectory - .appendingPathComponent("HistoryStoreTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - let model = AppModel( - registryStorageURL: folder.appendingPathComponent("board-registry.json"), - clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) - ) - return (model, { try? FileManager.default.removeItem(at: folder) }) -} - -@MainActor -@discardableResult -private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef { - let ref = BoardWindowRef(url: url) - let recordID = model.boardRegistry.recordOpen(of: url) - let store = try model.storeRegistry.acquire(url) - model.boardRegistry.setOpenNow(id: recordID) - model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) - return ref -} - -@MainActor -@Suite("Board sessions ▸ the git state they compose") -struct BoardSessionGitTests { - - @Test("A free-tier session composes a git state too, and detects the repository it finds") - func freeSessionsComposeAGitStateAsWell() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - try plantGitDirectory(in: fixture) - let (model, tearDown) = try makeModel() - defer { tearDown() } - - // **PIVOT 2026-08-07** (12-editions.md). The inverse of what this test used to pin: the free - // tier carried no git state at all, reported mode `none` on this very board, and injected - // nothing into the loader. Git left the paywall, so the session composes exactly what a Pro - // session composes — same call, no tier in it. - model.currentTier = { .free } - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - - #expect(session.git != nil) - #expect(session.gitMode == .git, "the mode is the disk's answer, not the tier's") - #expect(session.store.makeIdentityHistoryRanker != nil, "and the loader's rung is wired") - #expect(session.tier == .free, "the tier is still recorded — it just decides nothing here") - } - - @Test("A session on a git board composes git mode and wires the loader's ranker") - func sessionsCarryTheDetectedMode() async throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - - // A real repository, so the ranker has something to read. - let seed = HistoryStore.compose(boardRoot: fixture.root) - #expect(await seed.addGit()) - - model.currentTier = { .pro } - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - - #expect(session.gitMode == .git) - let provider = try #require(session.store.makeIdentityHistoryRanker) - let ranker = try #require(provider()) - #expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil) - - // The provider binding arrived with the undo/redo card: a session on a git board binds the - // git substrate over exactly this mode (12-editions.md ▸ The provider seam). - #expect(session.history is GitHistoryProvider) - } - - @Test("A session on a plain board is mode none and injects nothing") - func sessionsOnPlainBoardsInjectNothing() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let (model, tearDown) = try makeModel() - defer { tearDown() } - - model.currentTier = { .pro } - let ref = try openBoard(model, at: fixture.root) - let session = try #require(model.session(for: ref)) - - #expect(session.gitMode == .none) - let provider = try #require(session.store.makeIdentityHistoryRanker, "the wiring is there") - #expect(provider() == nil, "and it answers nothing on a board with no repository") - } -}