The tier axis comes out of the git stack — compose unconditional, postures mode-driven

HistoryStore.compose(boardRoot📒) returns non-optional and runs for
every session — the nil the gate produced was the only nil it ever had.
makeHistoryProvider is a one-axis decision: git-mode boards bind the git
provider, everything else native, in every tier; Session.tier stays
recorded, dormant. BoardGitSection shrinks to the four mode postures
(.absent and .proPointer die, BoardGitNote and the .git probe with them);
every board carries all three popover tabs (BoardInfoTab.available
retired); the titlebar branch shows on any git-mode board; the settings
sheet and card History section stop reading tier. InertGitTests is
repurposed as UntouchedGitTests — the file layer still never opens .git,
now load-bearing for mode-none boards. The accessibility audit reaches the
settings sheet at last: the fixture board hosts it in every tier, so the
free-fixture disabled-row test becomes an open-and-audit test.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-07 20:21:28 -04:00
parent da0d7fd2d7
commit 798a8bac73
32 changed files with 830 additions and 904 deletions
+159 -141
View File
@@ -335,14 +335,20 @@ public final class AppModel {
/// **The tier a board session composes under**, as an injectable seam. /// **The tier a board session composes under**, as an injectable seam.
/// ///
/// Defaulted to the real entitlement's local read and separated from it for `makeHistoryProvider`'s /// Defaulted to the real entitlement's local read and separated from it so a test binds a tier
/// reason exactly: a test binds a tier without needing a StoreKit transaction, an App Store /// without needing a StoreKit transaction, an App Store account, or a second `AppModel`
/// account, or a second `AppModel` initializer. `@MainActor` on the closure type because the /// initializer. `@MainActor` on the closure type because the entitlement it reads is main-actor
/// entitlement it reads is main-actor state, and `@ObservationIgnored` because nothing renders /// state, and `@ObservationIgnored` because nothing renders from it the tier reaches the UI,
/// from it the tier reaches the UI, where it reaches it at all, through `entitlement`. /// where it reaches it at all, through `entitlement`.
/// ///
/// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse /// **Read once per session, at composition, and never again** (12 The entitlement: "a lapse
/// never interrupts an open session"). `beginSession` is the only caller. /// 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.
@ObservationIgnored @ObservationIgnored
public var currentTier: @MainActor () -> Tier = { .free } public var currentTier: @MainActor () -> Tier = { .free }
@@ -351,12 +357,14 @@ public final class AppModel {
/// **The composition root for `HistoryProviding`** (12-editions.md The provider seam): what a /// **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. /// board session's undo stack is built by, called once per board as its session begins.
/// ///
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 12 The /// **The provider follows the board and, since PIVOT 2026-08-07, the board alone**
/// provider seam; 13-native-undo.md's header; 06 Rules): a board's substrate is decided by what /// (re-ruled 2026-07-31 12 The provider seam; 13-native-undo.md's header; 06 Rules; then
/// the board *is*, and the tier only decides whether git is on the table at all. The rule it /// 12 PIVOT 2026-08-07, git leaves the paywall). A board's substrate is decided by what the
/// replaced bound the native stack free-tier-wide and nothing at all on Pro's gitless boards, /// board *is*. The 2026-07-31 re-ruling took the tier out of every row but one it still decided
/// which made subscribing *remove* undo from a mode-none board an upgrade that takes a feature /// whether git was on the table at all and the pivot took it out of that one too: "every tier
/// away. /// 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.
/// ///
/// It takes the store because that is what a provider is a history *of*: the git provider needs /// 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 /// the board root it is a repository at, and the native one's steps are computed from the same
@@ -364,21 +372,24 @@ public final class AppModel {
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because /// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it. /// nothing renders from it.
/// ///
/// ### The two answers, and the `nil` that is no longer one of them /// ### The two answers, and the two `nil`s that are no longer among them
/// ///
/// - **No `HistoryStore` at all** the free tier, where `HistoryStore.compose` returns `nil` /// - **Mode `git`** the git provider: undo as forward restore commits over HEAD's first-parent
/// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it /// ancestry (06). **In every tier** since the pivot; this row used to read "Pro only no other
/// everywhere (any `.git` inert)" (13), and 12 The free tier and `.git` names the boards that /// tier composes a git state", because `HistoryStore.compose` answered `nil` off Pro without so
/// covers by hand "a formerly-subscribed user's board, a 1.x board, a repo-nested board /// much as a `stat`.
/// native undo runs". The absent git state *is* the tier test; nothing here reads a flag. /// - **Mode `none`, `repoNested`, and `unverifiable` alike** the **native stack**. "Boards
/// - **Mode `git`** (Pro only no other tier composes a git state) the git provider: undo as /// without app-managed git repo-nested included bind 13-native-undo.md's native stack in
/// forward restore commits over HEAD's first-parent ancestry (06). /// **every** tier" (03-board-ui.md Toolbar Catalog, re-ruled 2026-07-31 twice; 12 The
/// - **Mode `none`, `repoNested`, and `unverifiable` alike** the **native stack**, exactly as /// provider seam; 13's header). `unverifiable` joins the same branch structurally a denial
/// in the free tier. "Boards without app-managed git repo-nested included bind /// can never be told apart from a repository actually being there, so it takes `repoNested`'s
/// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md Toolbar Catalog, /// posture, undo included (06 Rules Detection). This is where a board nobody opted git in
/// re-ruled 2026-07-31 twice; 12 The provider seam; 13's header). `unverifiable` joins the /// for lands, permanently: opt-in per board is exactly what the pivot left standing (13's
/// same branch structurally a denial can never be told apart from a repository actually /// header, read through 12 PIVOT 2026-08-07).
/// being there, so it takes `repoNested`'s posture, undo included (06 Rules Detection). /// - **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.
/// ///
/// **The repo-nested no-undo case is gone** (re-ruled 2026-07-31): 06's leave-strictly-alone /// **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, /// stance "concerns *git*, and this stack never touches git memory-only, journal-free,
@@ -402,7 +413,7 @@ public final class AppModel {
/// one event that changes a board's answer under an open session add-git's commanded flip, /// 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. /// which swaps `none`'s native stack for `git`'s trail.
@ObservationIgnored @ObservationIgnored
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in public var makeHistoryProvider: (BoardStore, HistoryStore?) -> (any HistoryProviding)? = { store, git in
guard let git else { return NativeHistoryProvider() } guard let git else { return NativeHistoryProvider() }
switch git.mode { switch git.mode {
case .git: return GitHistoryProvider(boardRoot: store.rootURL) case .git: return GitHistoryProvider(boardRoot: store.rootURL)
@@ -429,8 +440,9 @@ public final class AppModel {
/// window). It lives here for the store's reason exactly: the session is what every window /// 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". /// over this board shares, and "undo is board-local".
/// ///
/// Which implementation it is, is the tier's answer and nobody else's /// Which implementation it is, is the board's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`. /// (12-editions.md The provider seam, read through PIVOT 2026-08-07) see
/// `AppModel.makeHistoryProvider`.
/// ///
/// **`nil` is a board with no undo at all, and no board the app composes is one any more** /// **`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, /// (re-ruled 2026-07-31 see `AppModel.makeHistoryProvider`): boards without app-managed git,
@@ -443,33 +455,35 @@ public final class AppModel {
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single /// 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 /// 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 /// native out, git in, the in-session steps discarded with it
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it `tier` has no setter. /// (`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.
public var history: (any HistoryProviding)? public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement). /// **The tier this board composed under** (12-editions.md The entitlement) recorded,
/// and **dormant since PIVOT 2026-08-07**.
/// ///
/// A `let`, on a value type, set once by `beginSession` which is the entire mechanism /// A `let`, on a value type, set once by `beginSession`. That is the entire mechanism behind
/// behind "a lapse never interrupts an open session: an open board finishes with the provider /// "a lapse never interrupts an open session": there is no setter, no observation, and
/// it composed; the next open composes the native stack over inert `.git`". There is no /// nothing anywhere that re-evaluates a live session's tier, so a subscription ending
/// setter, no observation, and nothing anywhere that re-evaluates a live session's tier: a /// mid-session is a fact about the *next* open and about nothing already on screen.
/// subscription ending mid-session is a fact about the *next* open and about nothing that is
/// already on screen.
/// ///
/// It is recorded rather than merely used-and-discarded because the provider it selects is /// **Nothing git-shaped reads it.** Git left the paywall (12 PIVOT 2026-08-07), so the
/// not the only thing that will ever ask. pro-m1's surfaces the card window's History /// substrate, the committer, the branch controls and the History surfaces are all decided by
/// section, View History (12 Tier matrix) are per-board questions asked long after /// the board's mode alone. It stays recorded because the entitlement's machinery stays built
/// composition, and they must get the answer this board actually opened with rather than /// and correct for whatever the re-decided base/Pro split gates, and because the fact a board
/// whatever the entitlement happens to say when the sidebar renders. /// 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 public let tier: Tier
/// **This board's git state** (06-history-undo.md Rules; 02-architecture.md Components /// **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 /// 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. /// add-git action the popover offers on a board that has none.
/// ///
/// `nil` under the free tier, and that is the inert posture made structural rather than /// **Never `nil` on a session the app composes**, since PIVOT 2026-08-07 (12-editions.md):
/// remembered: with no object there is nothing to consult, nothing to detect with, and no /// `HistoryStore.compose` runs detection for every board in every tier and returns a store.
/// path by which a free-tier session could touch `.git` (12-editions.md The free tier and /// It used to answer `nil` under the free tier the inert-`.git` posture made structural
/// `.git`). `HistoryStore.compose` is the one place the tier decides it. /// 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 /// 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 /// settled at composition and cannot change under an open session. What can change is the
@@ -477,15 +491,15 @@ public final class AppModel {
public let git: HistoryStore? public let git: HistoryStore?
/// The mode this board is being edited in, `none` when there is no git state at all which /// The mode this board is being edited in, `none` when there is no git state at all which
/// is every free-tier session ("The free tier ships exactly one mode: `none`", /// no session the app composes is any more (see `git` above), and which a board nobody has
/// 12-editions.md Tier matrix). /// added git to detects anyway.
/// ///
/// **Its first consumer is the provider seam** `makeHistoryProvider` reads exactly this to /// **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* /// know whether the board has a repository to be an undo stack for, and it is the *mode* and
/// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider, /// nothing else that decides (re-ruled 2026-07-31; the tier's last say went with PIVOT
/// `none` and `repoNested` alike the native stack. The popover's git section is the other /// 2026-08-07): `git` binds the git provider, `none` and `repoNested` alike the native stack.
/// reader and the one place the two gitless modes still differ, since add-git is offered on /// The popover's git section is the other reader and the one place the two gitless modes
/// one and explained away on the other. /// 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 /// `@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 /// type's isolation, and everything that asks a session what mode it is in is main-actor
@@ -886,17 +900,19 @@ public final class AppModel {
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) { func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
// **The entitlement read** (12-editions.md The entitlement): "Pro state is read from // **The entitlement read** (12-editions.md The entitlement): "Pro state is read from
// StoreKit's signed on-device transaction store at board-session composition the open path // StoreKit's signed on-device transaction store at board-session composition the open path
// gains no network dependency." Synchronous, over facts already in memory, on the same line // gains no network dependency." Synchronous, over facts already in memory. It is the *only*
// as the provider it selects which is the shape that makes "the open path never waits on // time this board asks: the answer becomes `BoardSession.tier` and nothing re-derives it.
// the App Store" checkable by reading four lines rather than by auditing a call graph. It is //
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing // **Recorded, and consulted by nothing below** (12 PIVOT 2026-08-07 git left the
// re-derives it. // 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.
let tier = currentTier() let tier = currentTier()
// **Mode detection** (06-history-undo.md Rules Detection: "checked at every board // **Mode detection** (06-history-undo.md Rules Detection: "checked at every board
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil` // open") unconditionally, in every tier. One `stat` per open, freshly, so a board that
// without looking at the disk at all the inert posture is unconditional there and under // gained or lost a `.git` since its last open opens in the mode it now has. Until the
// `.pro` it is one `stat` per open, freshly, so a board that gained or lost a `.git` since // 2026-08-07 pivot this call took the tier and answered `nil` under `.free`, which bought
// its last open opens in the mode it now has. // 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 // 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." // running session keeps its mode, and the watcher does not scan for `.git` appearing."
@@ -911,93 +927,91 @@ public final class AppModel {
// callers (the add-git surface, unit tests), and a session that took that default would hand // 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 // 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. // foreign and be authored `Lanework External`. The default is a fallback, never this path's.
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes) 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` // 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 // 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 Pro's git boards // 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 // it is instead the repository's own trail, which survives everything (06 Rules Undo
// survives relaunch) the seam's whole point. // survives relaunch) the seam's whole point.
let history = makeHistoryProvider(store, tier, git) let history = makeHistoryProvider(store, git)
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md Fractal // **The loader's earlier-occurrence-wins history rung** (01-storage-format.md Fractal
// layout Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker, // layout Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
// everything else keeps injecting nothing. A *provider* rather than a ranker because each // everything else keeps injecting nothing. A *provider* rather than a ranker because each
// load wants its own see `BoardStore.makeIdentityHistoryRanker` and because add-git // 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 // 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. // at the moment of each load rather than at composition.
if let git { store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker }
store.makeIdentityHistoryRanker = { [weak git] in git?.identityHistoryRanker } // **The auto-commit engine, wired into the session it commits for** (06-history-undo.md
// **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
// Rules Auto-commit). Called on every Pro session and not only on git-mode ones, // add-git can flip a board mid-session and the committer it builds then must land in
// because add-git can flip a board mid-session and the committer it builds then must land // exactly this shape `activateAutoCommit` remembers the wiring for that.
// in exactly this shape `activateAutoCommit` remembers the wiring for that. git.activateAutoCommit { [weak store] committer in
git.activateAutoCommit { [weak store] committer in guard let store else { return }
guard let store else { return } committer.currentSnapshot = { [weak store] in store?.snapshot }
committer.currentSnapshot = { [weak store] in store?.snapshot } // **The flush awaits the snapshot that covers it** (06 Rules Auto-commit, ruled
// **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
// 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
// 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
// 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
// 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
// wired here rather than reached for: the engine holds the *policy* (when to wait, how // long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`).
// long), the session supplies the two facts (`GitAutoCommitter.awaitCoveringSnapshot`). //
// // The generation the gate counts is `landedReloads` completed *walks* rather than
// The generation the gate counts is `landedReloads` completed *walks* rather than // applied snapshots because a value-equal reload skips the assignment and its
// 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
// counter since 2026-07-31, and a walk covers a flush's paths whether or not it found // anything to change (`BoardStore.landedReloads`).
// anything to change (`BoardStore.landedReloads`). committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() }
committer.awaitReloadQuiescence = { [weak store] in await store?.awaitQuiescence() } committer.landedReloads = { [weak store] in store?.landedReloads }
committer.landedReloads = { [weak store] in store?.landedReloads } // 02-architecture.md Write-failure surfacing, through the strip the board window
// 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
// 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
// stopped advancing", which is exactly what the standing suspension row says. Lock // contention and a held repository never reach here neither is a failure.
// contention and a held repository never reach here neither is a failure. committer.reportFailure = { [weak store] failure in
committer.reportFailure = { [weak store] failure in store?.banners.suspendHistory(reason: failure.message)
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 committer.reportRecovery = { [weak store] in
// mid-session mode flip; 13-native-undo.md's header "discards the in-session native store?.banners.clearHistorySuspension()
// 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, // **The corrupt-`.git` loud failure's standing row** (06-history-undo.md Rules,
// ruled 2026-07-31): add-git answers inline in the form that asked, and lands here instead // ruled 2026-07-31): a repository the app cannot open pauses the whole git surface
// when that form has been dismissed before the answer arrived "inline is the primary // and says so on the strip, "announced per 10-accessibility.md" and the same seam
// surface, never a silence trap". The banner enumeration is the same one branch switch and // heals it, since the paused engine's own 15 s re-read is what notices a repository
// undo restore post into, one row per failure. // repaired in a terminal. Distinct from the suspension above: that row is history
git.reportFailure = { [weak store] failure in // failing to advance and retrying, this one is there being nothing to advance into.
store?.banners.postGitFailure(.addGit, reason: failure.message) 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)
} }
// **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)
} }
// **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 binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the // **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
// store is that boundary every app-mediated mutation goes out through one of its write // store is that boundary every app-mediated mutation goes out through one of its write
// methods so it is the store that computes each inverse and registers it. What it cannot // methods so it is the store that computes each inverse and registers it. What it cannot
@@ -1291,15 +1305,17 @@ public final class AppModel {
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at /// 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` /// 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 /// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
/// the provider it composed" is a rule about a **tier** lapsing, which cannot change a running /// the provider it composed" was a rule about a **tier** lapsing, which never could change a
/// session at all; and a board that visibly starts accumulating commits while Z answers from 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. /// 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 /// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
/// `none`, and flips to `.git` before it fires `didAddGit`. /// `none`, and flips to `.git` before it fires `didAddGit`.
func bindHistoryProvider(for ref: BoardWindowRef) { func bindHistoryProvider(for ref: BoardWindowRef) {
guard var session = sessions[ref], let git = session.git, git.mode == .git else { return } guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return } guard let history = makeHistoryProvider(session.store, git) else { return }
guard history !== session.history else { return } guard history !== session.history else { return }
// Before the reassignment, while `session.history` is still the substrate being replaced: the // 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 // in-flight native steps go with it, and any closure that outlives this line finds an empty
@@ -1351,8 +1367,9 @@ public final class AppModel {
/// ///
/// This is the honest seam between the two halves of the rule: the host knows a window exists, the /// 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 /// 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 the free tier, a Pro board with /// to and how to reach its committer. A board with no committer any board nobody has added git
/// no repository records nothing, which is the same `nil` every other git seam takes. /// 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 card's folder is handed over as a **closure**, not a URL: a card can change lane, or be /// 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 /// moved into the trash, in the middle of a session, and what must be staged around is wherever
@@ -1693,8 +1710,9 @@ public final class AppModel {
// pending auto-commit before teardown; nothing settled is ever left unsaved or // 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 // 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 // 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 the // all lands in exactly one commit. `nil` on every board with no committer, which is
// whole free tier. // 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 committerFlush: { [weak self] in
await self?.sessions[ref]?.git?.committer?.flushNow() await self?.sessions[ref]?.git?.committer?.flushNow()
}, },
+18 -13
View File
@@ -113,8 +113,9 @@ struct BoardWindowHost: View {
/// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`). /// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`).
/// ///
/// It cannot live anywhere else: the repairs run before a store exists and the receipts have to /// It cannot live anywhere else: the repairs run before a store exists and the receipts have to
/// reach that store's ledger before `beginSession` composes Pro's committer, or the app's own /// reach that store's ledger before `beginSession` composes a git board's committer, or the app's
/// repair commits as `Lanework External`. Cleared once adopted. /// own repair commits as `Lanework External`. (Pro's committer, until 12-editions.md PIVOT
/// 2026-08-07 made it every git board's.) Cleared once adopted.
@State private var repairLedger: EchoLedger? @State private var repairLedger: EchoLedger?
@State private var phase: Phase = .opening @State private var phase: Phase = .opening
@@ -396,7 +397,8 @@ struct BoardWindowHost: View {
// **The repair's receipts, into the board's own ledger before the session composes** // **The repair's receipts, into the board's own ledger before the session composes**
// (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one // (01: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
// repair commit"). `beginSession` is where Pro's committer is built and started, and the // repair commit" every git board's since 12-editions.md PIVOT 2026-08-07, the sentence
// otherwise unchanged). `beginSession` is where that committer is built and started, and the
// committer harvests the ledger it is handed; receipts adopted after that line would be // committer harvests the ledger it is handed; receipts adopted after that line would be
// receipts the repair commit never sees, and the app's own repair would be authored // receipts the repair commit never sees, and the app's own repair would be authored
// `Lanework External`. // `Lanework External`.
@@ -730,31 +732,34 @@ struct BoardWindowHost: View {
// load rather than at attach because it carries the store; the controller installs it once, // load rather than at attach because it carries the store; the controller installs it once,
// whichever of the two arrives second. // whichever of the two arrives second.
// //
// The tier and the git state come from the **session**, which `start()` began a moment ago, // The git state comes from the **session**, which `start()` began a moment ago, rather than
// rather than from the entitlement or the disk: a board's popover must describe the board as // from the entitlement or the disk: a board's popover must describe the board as it opened
// it opened (12-editions.md The entitlement, "an open board finishes with the provider it // (12-editions.md The entitlement, "an open board finishes with the provider it composed";
// composed"; 06-history-undo.md Rules, mode is an open-time fact). A `nil` session cannot // 06-history-undo.md Rules, mode is an open-time fact). A `nil` session cannot happen on
// happen on this path `beginSession` precedes `configureWindow` and reads as the free // this path `beginSession` precedes `configureWindow` and reads as a board with no
// tier's posture, which is the harmless direction. // repository, which is the harmless direction.
//
// **The tier is no longer passed down** (12 PIVOT 2026-08-07): git is tier-independent, so
// every one of these surfaces reads the board's mode and nothing else. `BoardSession.tier`
// still exists and is still recorded it just has no git-facing consumer here.
let session = appModel.session(for: ref) let session = appModel.session(for: ref)
// The settings sheet's two doors validate on the same pair, so they are adopted here rather // The settings sheet's two doors validate on the same fact, so it is adopted here rather
// than read again somewhere else: the popover's Board Settings row and Board Board // than read again somewhere else: the popover's Board Settings row and Board Board
// Settings must never disagree about whether this board has setup to show // Settings must never disagree about whether this board has setup to show
// (`BoardSettingsAvailability`). The mode *inside* the git state stays live add-git flipping // (`BoardSettingsAvailability`). The mode *inside* the git state stays live add-git flipping
// it re-resolves the sheet's sections and both doors, which is the one mid-session transition // it re-resolves the sheet's sections and both doors, which is the one mid-session transition
// 06 sanctions. // 06 sanctions.
boardSettings.adopt(tier: session?.tier ?? .free, git: session?.git) boardSettings.adopt(git: session?.git)
windowController.installTitlebarAccessory( windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory( boardInfoTitlebarAccessory(
store: store, store: store,
recents: appModel.styleRecents, recents: appModel.styleRecents,
tier: session?.tier ?? .free,
git: session?.git, git: session?.git,
presentation: boardInfo, presentation: boardInfo,
settings: boardSettings settings: boardSettings
) )
) )
// The widget above now says the board's name (and, on a git-mode Pro board, its branch) // The widget above now says the board's name (and, on a git-mode board, its branch)
// itself, so the system title display would only repeat it the card-window seam // itself, so the system title display would only repeat it the card-window seam
// (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for // (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for
// the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched // the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched
+8 -4
View File
@@ -213,7 +213,9 @@ final class CardWindowSession: CardSessionFlushing {
// net effect, and the `comments/.trash/` purge, which must not run while a board step's undo // net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
// still restores comments out of it. Registering answers whether the step took the purge on // still restores comments out of it. Registering answers whether the step took the purge on
// and a board whose substrate keeps no steps has already run it by the time that answer comes // and a board whose substrate keeps no steps has already run it by the time that answer comes
// back, which is how Pro keeps purging at the close flush without a word about tiers here. // back, which is how a git board keeps purging at the close flush without a word about tiers
// here (and there is none to say since 12-editions.md PIVOT 2026-08-07 the substrate is
// the board's mode, not a subscription).
// //
// **This window stops owning its card's comment trash first** (ruled 2026-08-06 the // **This window stops owning its card's comment trash first** (ruled 2026-08-06 the
// open-window carve-out). While the window is open the store defers every purge of this // open-window carve-out). While the window is open the store defers every purge of this
@@ -418,9 +420,11 @@ struct CardWindowHost: View {
/// **This card's commit trail, or nothing at all** (05-card-window.md History). /// **This card's commit trail, or nothing at all** (05-card-window.md History).
/// ///
/// `nil` is the section's absence rule, read from the board's own git state rather than from a /// `nil` is the section's absence rule, read from the board's own git state rather than from a
/// flag: no `HistoryStore` means the free tier (12-editions.md where the section never exists), /// flag: a mode other than `git` means a board the app manages no history for, on any tier. The
/// and a mode other than `git` means a board the app manages no history for. The object is held /// question stopped having a tier half at 12-editions.md PIVOT 2026-08-07 every session
/// by this host so it survives every snapshot, `thumbnails`' reason exactly. /// composes a git state now, so what decides the section is whether *this board's* history is
/// git-backed. The object is held by this host so it survives every snapshot, `thumbnails`'
/// reason exactly.
private var cardHistory: CardHistory? { private var cardHistory: CardHistory? {
guard appModel.session(for: ref.board)?.gitMode == .git else { return nil } guard appModel.session(for: ref.board)?.gitMode == .git else { return nil }
return history return history
+4 -2
View File
@@ -39,8 +39,10 @@ import Foundation
public enum BoardGitMode: String, Sendable, Equatable, CaseIterable { public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
/// No `.git` at the board root and none above it **every** ancestor check answered not-found, /// No `.git` at the board root and none above it **every** ancestor check answered not-found,
/// "clean none" in 06's own words. The only mode the free tier ships (12-editions.md Tier /// "clean none" in 06's own words. The mode of every board nobody has opted into git for which
/// matrix), the one add-git moves a board out of, and now that this axis exists the one mode /// is what a board without app-managed git *is* now that the tier axis is gone (12-editions.md
/// PIVOT 2026-08-07; it used to be the only mode the free tier shipped, over the retired
/// inert-`.git` posture). The one add-git moves a board out of, and now that this axis exists the one mode
/// add-git's own re-detection requires before it will act: a raced or stale read that turns out /// add-git's own re-detection requires before it will act: a raced or stale read that turns out
/// to be `.unverifiable` or `.repoNested` refuses the init exactly as those modes always did. /// to be `.unverifiable` or `.repoNested` refuses the init exactly as those modes always did.
case none case none
+8 -5
View File
@@ -44,12 +44,15 @@ public struct GitLandedWindow: Sendable, Equatable {
/// **Every settled change becomes a commit** (06-history-undo.md Rules Auto-commit), debounced /// **Every settled change becomes a commit** (06-history-undo.md Rules Auto-commit), debounced
/// past drag and typing churn, on git-mode boards and nowhere else. /// past drag and typing churn, on git-mode boards and nowhere else.
/// ///
/// ### Structurally unreachable off Pro /// ### Structurally unreachable on a board with no repository
/// ///
/// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro /// One of these exists per `HistoryStore` in mode `git` and nowhere else. Until the 2026-08-07 pivot
/// (`HistoryStore.compose` is the tier gate). The free tier therefore has no committer to disable, /// there was a second gate above it a `HistoryStore` existed only under Pro and that one is gone
/// no debounce to cancel and no `.git` to touch 12-editions.md's inert posture as a shape rather /// (12-editions.md PIVOT 2026-08-07: git left the paywall, and every tier composes the git stack
/// than as a flag, which `InertGitTests` pins against real bytes. /// on git-mode boards). What remains is the stronger of the two anyway, because it never depended on
/// a subscription: git is **opt-in per board** (06 Rules), so a board the user never added git to
/// detects `none`, composes no committer, and has no debounce to cancel and no `.git` to touch. The
/// file layer's own indifference to `.git` is pinned against real bytes by `UntouchedGitTests`.
/// ///
/// ### What arms it /// ### What arms it
/// ///
+5 -3
View File
@@ -254,13 +254,15 @@ public final class GitHistoryProvider: HistoryProviding {
// MARK: - HistoryProviding // MARK: - HistoryProviding
/// **Deliberately nothing except the one thing a dropped step is owed.** On a git board an undo /// **Deliberately nothing except the one thing a dropped step is owed.** On a git board an undo
/// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate /// step is a commit, and the Writer boundary's inverse operations are the *gitless* board's
/// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and /// substrate (13-native-undo.md the tier axis it once read as went with 12-editions.md
/// PIVOT 2026-08-07; the substrate is the board's mode alone). `BoardStore` registers against
/// whatever provider the session bound, and
/// this one has a repository to read instead so the registrations arrive and are dropped, which /// this one has a repository to read instead so the registrations arrive and are dropped, which
/// is exactly what "the commit trail itself is the substrate" (14 C1) means in code. /// is exactly what "the commit trail itself is the substrate" (14 C1) means in code.
/// ///
/// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the /// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the
/// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is /// substrate split in 13's purge rule structural rather than conditional: "on Pro the substrate is
/// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge /// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge
/// rides the close flush there as before" (13 Interaction with the trash). A card window's close /// rides the close flush there as before" (13 Interaction with the trash). A card window's close
/// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs /// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs
+5 -5
View File
@@ -377,12 +377,12 @@ enum GitHousekeeping {
/// **When a housekeeping pass runs** (06-history-undo.md Repository hygiene) one per git-mode /// **When a housekeeping pass runs** (06-history-undo.md Repository hygiene) one per git-mode
/// board session, scheduled at board open and never again. /// board session, scheduled at board open and never again.
/// ///
/// ### Structurally unreachable off Pro /// ### Structurally unreachable on a board with no repository
/// ///
/// One of these exists per `HistoryStore` in mode `git`, and a `HistoryStore` exists only under Pro /// One of these exists per `HistoryStore` in mode `git` and nowhere else `GitAutoCommitter`'s
/// (`HistoryStore.compose` is the tier gate) `GitAutoCommitter`'s rule, for its reason. The free /// rule, for its reason, including the tier gate that used to sit above it and no longer does
/// tier has no housekeeper to disable and no `.git` to pack (12-editions.md The free tier and /// (12-editions.md PIVOT 2026-08-07). A board the user never added git to detects `none`, so there
/// `.git`, which `InertGitTests` pins against real bytes). /// is no housekeeper to disable and no `.git` to pack.
/// ///
/// ### Off the open path, on purpose /// ### Off the open path, on purpose
/// ///
+5 -3
View File
@@ -7,9 +7,11 @@ import Foundation
/// ///
/// ### Why a struct of closures rather than a reference to the committer /// ### Why a struct of closures rather than a reference to the committer
/// ///
/// `BoardStore` lives in the live store and must not learn what a repository is: the free tier /// `BoardStore` lives in the live store and must not learn what a repository is: most boards have no
/// composes no `HistoryStore`, so the engine has to be *structurally* unreachable there rather than /// committer at all git is opt-in per board (06 Rules), and since the 2026-08-07 pivot that is
/// switched off, and a store holding an optional committer would be a store that knows about git. /// the *only* reason a board lacks one (12-editions.md) so the engine has to be *structurally*
/// unreachable there rather than switched off, and a store holding an optional committer would be a
/// store that knows about git.
/// One optional value, `nil` on every board that has no committer, is the same shape `watcherBrackets` /// One optional value, `nil` on every board that has no committer, is the same shape `watcherBrackets`
/// and `history` already take, and it keeps the three orderings before the write, after the /// and `history` already take, and it keeps the three orderings before the write, after the
/// bracket, after the landing stated in one type instead of three properties that could drift. /// bracket, after the landing stated in one type instead of three properties that could drift.
+38 -25
View File
@@ -7,19 +7,25 @@ import os
/// opened in, the repository behind it when there is one, and the two operations that can change /// opened in, the repository behind it when there is one, and the two operations that can change
/// either the app's own add-git, and nothing else. /// either the app's own add-git, and nothing else.
/// ///
/// ### One per board session, composed under the tier /// ### One per board session, composed at every open in every tier
/// ///
/// `compose(boardRoot:tier:)` is the whole gate: **the free tier gets no `HistoryStore` at all**, so /// `compose(boardRoot:ledger:)` runs detection and hands back a store for **every** session
/// a free-tier session runs no detection, opens no repository, and does not so much as `stat` a /// (12-editions.md PIVOT 2026-08-07 git left the paywall: "every tier composes the git stack on
/// `.git` "any `.git` is inert the app never reads history, never commits, never touches `.git` /// git-mode boards exactly as Pro did"). It used to be the gate: the free tier got no object at all,
/// in any way" (12-editions.md The free tier and `.git`), which `InertGitTests` pins against real /// so a free session ran no detection and did not so much as `stat` a `.git` the inert-`.git`
/// bytes. Nothing in this type is conditional on a tier, because the tier decided whether the type /// posture made structural rather than remembered. That posture is **retired**. A `.git` at a board
/// exists. /// root is live in every tier, detection runs at every board open off the same path Pro's always
/// used, and nothing in this type ever asks what anybody paid.
///
/// What the pivot does **not** change is why mode `none` still exists at all: git stays **opt-in per
/// board** (06 Rules "No silent auto-init, ever"). A board whose user never asked for a
/// repository composes here, detects `none`, and builds no committer, no switcher and no
/// housekeeper nothing that could touch a `.git` it does not have.
/// ///
/// ### What it does not do yet /// ### What it does not do yet
/// ///
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history /// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
/// ranker. **The provider binding reads `mode` and nothing else about a tier** the composition /// ranker. **The provider binding reads `mode` and nothing else** the composition
/// root binds the git provider on mode `git` and the native stack on modes `none` and `repoNested` /// root binds the git provider on mode `git` and the native stack on modes `none` and `repoNested`
/// alike (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the board, and /// alike (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the board, and
/// what a repo-nested board denies is app-managed history, never Z). Auto-commit, commit messages, /// what a repo-nested board denies is app-managed history, never Z). Auto-commit, commit messages,
@@ -82,9 +88,11 @@ public final class HistoryStore {
/// **The auto-commit engine** (06-history-undo.md Rules Auto-commit), or `nil` on a board /// **The auto-commit engine** (06-history-undo.md Rules Auto-commit), or `nil` on a board
/// there is no repository to commit into. /// there is no repository to commit into.
/// ///
/// Its existence is exactly `mode == .git`, and that invariant is the tier gate one level down: /// Its existence is exactly `mode == .git`, and since the 2026-08-07 pivot (12-editions.md) that
/// no `HistoryStore` off Pro means no committer anywhere off Pro, with nothing to disable and no /// invariant carries the whole story on its own: what keeps a committer off a board is the
/// flag to forget. /// board's own mode git is opt-in per board, so a user who never asked for a repository has
/// nothing here to disable and no flag anyone could forget. It used to rest on a tier gate one
/// level up (no `HistoryStore` off Pro meant no committer off Pro); the gate is gone.
/// ///
/// **Composed inert and started separately.** Composition happens on the board-open path, where /// **Composed inert and started separately.** Composition happens on the board-open path, where
/// nothing may block and where a session does not exist yet; `activateAutoCommit(_:)` is what /// nothing may block and where a session does not exist yet; `activateAutoCommit(_:)` is what
@@ -256,14 +264,19 @@ public final class HistoryStore {
housekeeper.schedule() housekeeper.schedule()
} }
/// **The tier gate and the open-time detection, in one line** (12-editions.md The provider /// **The open-time detection** (06-history-undo.md Rules Detection: "checked at every board
/// seam; 06-history-undo.md Rules Detection) called by `AppModel.beginSession` beside the /// open") called by `AppModel.beginSession`, unconditionally, once per board.
/// entitlement read that supplies `tier`.
/// ///
/// `nil` under `.free` means exactly what it says: no git state exists for that session, so no /// ### This was the tier gate, and is not one any more
/// caller can accidentally consult one. Under `.pro` the mode is whatever the filesystem says ///
/// right now, and a board that has changed mode since its last open simply opens in the new one /// It took a `tier` and answered `nil` under `.free`: no git state existed for such a session, so
/// "the app just reflects what it finds". /// no caller could consult one and no free open ever stat'ed a `.git`. **PIVOT 2026-08-07**
/// (12-editions.md) retired that whole axis "detection runs at every board open" in every tier
/// so the parameter is *removed* rather than ignored, and the zero-stat promise dies with it:
/// every open now pays the same handful of `stat`s Pro's opens always paid, off this same path.
///
/// The mode is whatever the filesystem says right now, and a board that has changed mode since
/// its last open simply opens in the new one "the app just reflects what it finds".
/// ///
/// **Adoption needs no step of its own**: a board whose root already carries `.git` lands in /// **Adoption needs no step of its own**: a board whose root already carries `.git` lands in
/// `.git` here, silently, with no dialog and nothing to confirm "the repo's presence *is* the /// `.git` here, silently, with no dialog and nothing to confirm "the repo's presence *is* the
@@ -274,8 +287,7 @@ public final class HistoryStore {
/// store-less `HistoryStore` still composes: an empty ledger vouches for nothing, which is the /// store-less `HistoryStore` still composes: an empty ledger vouches for nothing, which is the
/// honest answer for a git state with no session behind it (everything reads foreign, the /// honest answer for a git state with no session behind it (everything reads foreign, the
/// launch-catch-up doctrine). /// launch-catch-up doctrine).
public static func compose(boardRoot: URL, tier: Tier, ledger: EchoLedger = EchoLedger()) -> HistoryStore? { public static func compose(boardRoot: URL, ledger: EchoLedger = EchoLedger()) -> HistoryStore {
guard tier == .pro else { return nil }
let mode = BoardGitMode.detect(boardRoot: boardRoot) let mode = BoardGitMode.detect(boardRoot: boardRoot)
logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)") logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)")
return HistoryStore(boardRoot: boardRoot, mode: mode, ledger: ledger) return HistoryStore(boardRoot: boardRoot, mode: mode, ledger: ledger)
@@ -286,10 +298,11 @@ public final class HistoryStore {
/// **Opt-in init** (06-history-undo.md Rules): initializes a repository at the board root and /// **Opt-in init** (06-history-undo.md Rules): initializes a repository at the board root and
/// immediately commits the whole tree as "Initial board state". /// immediately commits the whole tree as "Initial board state".
/// ///
/// Reachable from one place the board settings sheet's Git section under Pro and from nowhere /// Reachable from one place the board settings sheet's Git section, in every tier since the
/// else: /// 2026-08-07 pivot (12-editions.md) and from nowhere else:
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo /// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
/// under every board it opened. /// under every board it opened. Opt-in is what the pivot deliberately left standing: git leaving
/// the paywall widened *who* may ask, never *whether* asking is required.
/// ///
/// **It flips the open board's mode immediately**, which is the design's one sanctioned /// **It flips the open board's mode immediately**, which is the design's one sanctioned
/// mid-session transition: "clicking it flips the open board into git mode immediately the /// mid-session transition: "clicking it flips the open board into git mode immediately the
@@ -419,8 +432,8 @@ public final class HistoryStore {
// MARK: - The loader's history seam // MARK: - The loader's history seam
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md Fractal layout Rules; /// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md Fractal layout Rules;
/// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for the /// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for
/// free tier and modes `none`/`repoNested` alike, all of which fall through to the ladder's /// modes `none`, `repoNested` and `unverifiable` alike, all of which fall through to the ladder's
/// remaining rungs (birth date, then traversal order). /// remaining rungs (birth date, then traversal order).
/// ///
/// **A fresh ranker per ask, deliberately.** Each one computes its map at most once, lazily, and /// **A fresh ranker per ask, deliberately.** Each one computes its map at most once, lazily, and
+5 -4
View File
@@ -13,11 +13,12 @@ import Foundation
/// ///
/// ### Two jobs, and the second is why this is a type rather than a stored provider /// ### Two jobs, and the second is why this is a type rather than a stored provider
/// ///
/// **The fine stack**: an ordinary `NativeHistoryProvider`, in *both* tiers. The steps a card window /// **The fine stack**: an ordinary `NativeHistoryProvider`, on *every* board. The steps a card window
/// registers are values-based inverses at the Writer boundary the same shape whatever substrate the /// registers are values-based inverses at the Writer boundary the same shape whatever substrate the
/// board's own history has so a Pro git board's card window still walks its own gestures with the /// board's own history has so a git board's card window still walks its own gestures with the
/// native grammar, and only the *coarse* close unit is tier-split ("one native board step, or one /// native grammar, and only the *coarse* close unit splits by substrate ("one native board step, or
/// commit" 06-history-undo.md Undo routing). /// one commit" 06-history-undo.md Undo routing). The split was written as a tier split and is a
/// board split since 12-editions.md PIVOT 2026-08-07; nothing in this type ever read either.
/// ///
/// **The fold**: window close registers "one coarse step ... whose undo restores the card subtree to /// **The fold**: window close registers "one coarse step ... whose undo restores the card subtree to
/// its session-start state ... and whose redo reapplies the net effect". That net effect is exactly /// its session-start state ... and whose redo reapplies the net effect". That net effect is exactly
+13 -9
View File
@@ -262,21 +262,25 @@ public struct HistoryStep {
/// seam from a window is the one coarse step its close registers. /// seam from a window is the one coarse step its close registers.
/// ///
/// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds /// `AppModel.BoardSession` is where the board half's ownership lives, and the composition root binds
/// which implementation it gets **following the board, not the tier alone** (re-ruled 2026-07-31): /// which implementation it gets **following the board, and since the 2026-08-07 pivot the board
/// a board without app-managed git repo-nested included (re-ruled 2026-07-31) binds /// alone** (re-ruled 2026-07-31; 12-editions.md PIVOT 2026-08-07): a board without app-managed git
/// `NativeHistoryProvider` (two step stacks over the inverses registered at the Writer boundary) in /// repo-nested included (re-ruled 2026-07-31) binds `NativeHistoryProvider` (two step stacks over
/// every tier, a Pro git board binds the git provider (undo as forward restore commits over HEAD's /// the inverses registered at the Writer boundary), and a git-mode board binds the git provider (undo
/// first-parent ancestry 06-history-undo.md), and Teams inherits Pro's. /// as forward restore commits over HEAD's first-parent ancestry 06-history-undo.md). Both answers
/// are the same in every tier; the tier used to decide whether git was on the table at all, and no
/// longer does.
/// ///
/// ### What this protocol deliberately does not say /// ### What this protocol deliberately does not say
/// ///
/// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation /// - **No `NSUndoManager`, anywhere in the signature.** It is the native provider's implementation
/// detail, and a seam that vended one would be a seam only one provider could ever satisfy the /// detail, and a seam that vended one would be a seam only one provider could ever satisfy the
/// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof /// opposite of the reason the seam exists at all ("the free tier's native undo is the first proof
/// the seam is real", 12). AppKit still needs an `UndoManager` to hand the responder chain; that /// the seam is real", 12 written when native undo was the free tier's; the proof it names is
/// adapter is `BoardUndoManager`, which sits *over* this protocol rather than inside it. /// two working substrates, which the pivot leaves standing). AppKit still needs an `UndoManager`
/// - **No persistence promise.** The native stack dies with the session (13); Pro's survives /// to hand the responder chain; that adapter is `BoardUndoManager`, which sits *over* this
/// relaunch because git does (06). Both are honest implementations of these seven members. /// protocol rather than inside it.
/// - **No persistence promise.** The native stack dies with the session (13); the git provider's
/// survives relaunch because git does (06). Both are honest implementations of these seven members.
/// - **No routing.** Which surface Z reaches is focus's answer, not the substrate's /// - **No routing.** Which surface Z reaches is focus's answer, not the substrate's
/// (06 Undo routing, tier-independent) `BoardUndoRouting`. /// (06 Undo routing, tier-independent) `BoardUndoRouting`.
/// ///
+4 -2
View File
@@ -2,8 +2,10 @@ import Foundation
// MARK: - NativeHistoryProvider // MARK: - NativeHistoryProvider
/// The free tier's undo substrate: one stack per board session (13-native-undo.md) **and the /// The undo substrate of every board without app-managed git: one stack per board session
/// substrate of every open card window's stack, in either tier** (re-ruled 2026-07-31, the /// (13-native-undo.md, whose header lost its tier axis with the 2026-08-07 pivot the substrate is
/// the board's mode alone, and git is opt-in per board, so a board nobody opted in keeps this stack
/// for good) **and the substrate of every open card window's stack, on any board** (re-ruled 2026-07-31, the
/// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the /// session-coarsening model): a window's fine-grained gestures are values-based inverses whatever the
/// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of /// board's own substrate is, so `CardWindowUndo` holds one of these too. Nothing below knows which of
/// the two it is; both need the same four-line grammar. /// the two it is; both need the same four-line grammar.
+8 -4
View File
@@ -448,10 +448,14 @@ struct BoardInfoCommand: View {
/// ### Validation: scope, then reachability and never the lock /// ### Validation: scope, then reachability and never the lock
/// ///
/// The row stays **visible and disabled** where the sheet cannot exist (`BoardSettingsAvailability`, /// The row stays **visible and disabled** where the sheet cannot exist (`BoardSettingsAvailability`,
/// which carries the reasoning): the free tier, and a Pro board nested inside someone else's /// which carries the reasoning): a board nested inside someone else's repository, one whose ancestor
/// repository. That is standard menu validation, and it is the deliberate asymmetry with the popover /// check was denied, and one whose own repository will not open. That is standard menu validation,
/// row, which is *absent* there instead a menu is an inventory of the app, a popover section is a /// and it is the deliberate asymmetry with the popover row, which is *absent* there instead a menu
/// description of this board. /// is an inventory of the app, a popover section is a description of this board.
///
/// **The free tier used to be the fourth of those**, and is not since 12-editions.md PIVOT
/// 2026-08-07: git is tier-independent, so what this row validates on is the board in front of the
/// user and nothing about their subscription.
/// ///
/// The read-only lock does not close it, for Board Info's reason: a settings sheet is *configuration* /// The read-only lock does not close it, for Board Info's reason: a settings sheet is *configuration*
/// (04-interactions.md The map's carve-out), a locked board is exactly when a user may want to read /// (04-interactions.md The map's carve-out), a locked board is exactly when a user may want to read
+52 -117
View File
@@ -15,26 +15,25 @@ import SwiftUI
/// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own /// `sectionHeader("Git")` and a `Divider()`. The tabs already sit under the popover header's own
/// divider; a second rule and a second "Git" would be the surface naming itself twice. /// divider; a second rule and a second "Git" would be the surface naming itself twice.
/// ///
/// ### Membership is the posture, so this view never renders an empty state /// ### Every board has this tab (pivot 2026-08-07)
/// ///
/// `BoardInfoTab.available(tier:mode:hasGitDirectory:)` drops the Git tab from the strip exactly when /// 12-editions.md PIVOT 2026-08-07 took git off the paywall, and 03's Git-tab note records what
/// `BoardGitSection` resolves to `.absent`, so this view is never built for the free tier's ordinary /// that does to this surface: with no free-only postures left, every board resolves one of the four
/// board. That is 12-editions.md's "absent, no placeholder" rule carried up to the tab strip a /// mode postures below and **the tab is always in the strip**. The membership rule the Git session
/// standing Git tab on every free board would be the standing ad for Pro that 12 forbids. /// ruled (the strip asks the posture) stands structurally in `BoardInfoTab` it simply never hears
/// "absent" any more, because there is no longer a posture that says nothing.
// MARK: - The posture // MARK: - The posture
/// **What the Git tab shows, for one board** (03-board-ui.md Board popover Git tab; /// **What the Git tab shows, for one board** (03-board-ui.md Board popover Git tab;
/// 06-history-undo.md Rules; 12-editions.md The free tier and `.git`) a pure function of two /// 06-history-undo.md Rules) a pure function of the board's **mode**, so the posture matrix is
/// facts, so the posture matrix is provable without a popover on screen. /// provable without a popover on screen.
/// ///
/// The free tier's two cases are settled 2026-07-27: absent on an ordinary board, a one-line Pro /// The four cases are the mode, one to one and the mode-`none` and repo-nested pair is where the
/// pointer on a board carrying an inert `.git`. The Pro cases are the mode, one to one and the /// design is most insistent: a repo-nested board gets **prose, not a disabled button**. "The option
/// mode-`none` and repo-nested pair is where the design is most insistent: a repo-nested board gets /// is absent because it *can't* apply, and the UI should teach that rather than look broken" (06
/// **prose, not a disabled button**. "The option is absent because it *can't* apply, and the UI /// Rules). `unverifiable` (the git-detection axis) is structurally identical to `repoNested` but
/// should teach that rather than look broken" (06 Rules). `unverifiable` (the git-detection axis) /// worded as its own honest prose a denial is not a nesting.
/// joins as a fourth Pro case, structurally identical to `repoNested` but worded as its own honest
/// prose a denial is not a nesting.
/// ///
/// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup /// **The 2026-07-31 popover/sheet split thinned two of these cases without removing either.** Setup
/// left the popover for the board settings sheet, so mode `none` no longer renders an action here at /// left the popover for the board settings sheet, so mode `none` no longer renders an action here at
@@ -43,59 +42,49 @@ import SwiftUI
/// and the identity fields. What each case still *is* is a posture, which is why the matrix and its /// and the identity fields. What each case still *is* is a posture, which is why the matrix and its
/// test survived the move unchanged. /// test survived the move unchanged.
/// ///
/// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** the same six cases, now /// **The 2026-08-07 tab restructure rehomed the surface, not the matrix** the same cases, now
/// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's /// rendered as one tab each by `BoardGitTabView` rather than as a closing section of the popover's
/// single pane. `.absent` grew one further job in the move: it is what `BoardInfoTab.available` reads /// single pane.
/// to decide whether the Git tab is in the strip at all, which is why membership can never disagree ///
/// with what the tab would have rendered. /// **The 2026-08-07 pivot took the tier axis out of it** (12-editions.md PIVOT 2026-08-07, the
/// same day). The two free-tier cases `.absent` on an ordinary board, `.proPointer` on a board
/// carrying an inert `.git`, both settled 2026-07-27 described a gate that no longer exists: git
/// is tier-independent, a `.git` at a board root is live in every tier, and detection runs at every
/// board open. So the free-tier branch, the `hasGitDirectory` input it asked for, and both cases are
/// **retired**, and what is left is the mode which is what the postures were always about.
enum BoardGitSection: Equatable, CaseIterable { enum BoardGitSection: Equatable, CaseIterable {
/// Nothing at all the free tier's ordinary board, where "the popover is rename + style, /// Mode `none`: a board that could have a history and has none. There is no daily surface for
/// complete in itself". Since the restructure this is a **missing tab**, not an empty one /// that the tab is one caption stating the fact above the Board Settings door, where add-git
/// (`BoardInfoTab.available`). /// now lives (03 Board settings sheet). The header-plus-door posture blessed 2026-08-06,
case absent /// restated for a surface whose header is now the tab label.
///
/// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer /// **Every tier's posture since the pivot** (03 Git tab, pivot note 2026-08-07), and git stays
/// to Pro (12 Tier naming). /// **opt-in per board**: the door is an offer, never an auto-init.
case proPointer
/// Pro, mode `none`: a board that could have a history and has none. There is no daily surface
/// for that the tab is one caption stating the fact above the Board Settings door, where
/// add-git now lives (03 Board settings sheet). The header-plus-door posture blessed
/// 2026-08-06, restated for a surface whose header is now the tab label.
case noRepository case noRepository
/// Pro, repo-nested: the honest explanation, no action and no Board Settings row either, /// Repo-nested: the honest explanation, no action and no Board Settings row either, since
/// since nothing setup-shaped can apply (`BoardSettingsAvailability`). /// nothing setup-shaped can apply (`BoardSettingsAvailability`).
case repoNested case repoNested
/// Pro, unverifiable: **not** `.repoNested` a denied ancestor check, not a found repository /// Unverifiable: **not** `.repoNested` a denied ancestor check, not a found repository
/// (06 Rules Detection, "Denial is not absence"). Structurally identical to `.repoNested` /// (06 Rules Detection, "Denial is not absence"). Structurally identical to `.repoNested`
/// (no action, no Board Settings row, `BoardSettingsAvailability` false), but its own case so /// (no action, no Board Settings row, `BoardSettingsAvailability` false), but its own case so
/// the view renders its own honest prose rather than the nested sentence "unverifiable" is not /// the view renders its own honest prose rather than the nested sentence "unverifiable" is not
/// "nested". /// "nested".
case unverifiable case unverifiable
/// Pro, git mode: the branch/source line with the **switch** picker, the abnormal-state /// Git mode: the branch/source line with the **switch** picker, the abnormal-state explanation
/// explanation when the surface is held, and the Board Settings row. The remote half /// when the surface is held, and the Board Settings row. The remote half tracking, Pull/Push,
/// tracking, Pull/Push, the status badges is 07-sync-collab.md's own card and joins this same /// the status badges is 07-sync-collab.md's own card and joins this same posture.
/// posture.
case branch case branch
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection { static func resolve(mode: BoardGitMode) -> BoardGitSection {
switch tier { switch mode {
case .free: case .none: return .noRepository
// Detection never runs under the free tier, so the mode is not consulted here the one case .git: return .branch
// question asked is whether the folder carries a `.git`, which is what the pointer is case .repoNested: return .repoNested
// about (12: "any `.git` is inert a stray like any other, preserved verbatim"). case .unverifiable: return .unverifiable
return hasGitDirectory ? .proPointer : .absent
case .pro:
switch mode {
case .none: return .noRepository
case .git: return .branch
case .repoNested: return .repoNested
case .unverifiable: return .unverifiable
}
} }
} }
} }
@@ -144,71 +133,29 @@ private struct BoardGitUnverifiableNote: View {
} }
} }
/// The contextual git note **a quiet signpost, not a feature** (12-editions.md The free tier and
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old
/// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is
/// one line, shown only when it is true and it is **the one in-context pointer to Pro**, the second
/// of the three places the app names it (12 Tier naming; the other two are `AboutBox` and the
/// Settings Pro section).
///
/// On an ordinary board nothing instantiates this type at all the board has no Git tab to put it in
/// (`BoardInfoTab.available`), matching the card window's absent History section (12: "absent, no
/// placeholder"). Only a board that carries an inert `.git` (12 the inert posture: "any `.git` is
/// inert" the free tier never reads or writes it, whether the board's own or one a lapsed
/// subscription left behind) earns this note, worded exactly as 12 rules: an honest explanation of
/// what the folder is, named exactly where the question arises, never a standing ad for Pro.
///
/// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly
/// (a fixture board with `.git` true, without false), and `BoardInfoView.init` is its one caller
/// in the app the popover asks the question once at open and hands the answer down, both to this
/// tab and to `BoardInfoTab.available`.
struct BoardGitNote: View {
var body: some View {
Text("This board has a git history. Lanework Pro works with it.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
/// Whether `boardRoot` carries a `.git` entry the free tier's entire detection story, and a deliberately
/// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact
/// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder
/// watch by design (§ .git filtering it exists to ignore git churn), so there is no reload
/// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken
/// once, at the moment the popover is built, is the honest amount of machinery for a single quiet
/// line. A `.git` added or removed while the popover happens to be open is stale until the next
/// open a gap this note's own posture makes harmless, since it is a signpost, not a control.
static func hasGitDirectory(at boardRoot: URL) -> Bool {
FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)
}
}
// MARK: - The tab // MARK: - The tab
/// The Git tab's surface: whichever of the six postures this board is in, and the Board Settings /// The Git tab's surface: whichever of the four postures this board is in, and the Board Settings
/// door where it applies. /// door where it applies.
struct BoardGitTabView: View { struct BoardGitTabView: View {
let store: BoardStore let store: BoardStore
/// The tier and the git state this board's **session** composed with, handed down from /// The git state this board's **session** composed with, handed down from `BoardInfoView` rather
/// `BoardInfoView` rather than re-derived 12-editions.md The entitlement ("a lapse never /// than re-derived 12-editions.md The entitlement ("a lapse never interrupts an open
/// interrupts an open session"). `git` is `@Observable`, so a branch switch or a pause landing /// session"). `git` is `@Observable`, so a branch switch or a pause landing while the tab is open
/// while the tab is open repaints it. /// repaints it.
let tier: Tier ///
/// Optional because a caller with no session in hand (previews, the accessory-installation tests)
/// has none to hand over; a `nil` reads as mode `none`, which is the honest description of a
/// board nothing has detected anything about yet. Since the 2026-08-07 pivot it is no longer a
/// *tier* signal every session composes one (12 PIVOT 2026-08-07).
let git: HistoryStore? let git: HistoryStore?
/// The window's settings sheet, so this tab can carry the **Board Settings** row that opens it. /// The window's settings sheet, so this tab can carry the **Board Settings** row that opens it.
/// `nil` where there is no window to present a sheet on, which reads as a tab with no door. /// `nil` where there is no window to present a sheet on, which reads as a tab with no door.
let settings: BoardSettingsPresentation? let settings: BoardSettingsPresentation?
/// The free tier's one question about the board's folder, asked once in `BoardInfoView.init` and
/// passed down the same answer the tab strip's membership was computed from, so the tab and its
/// content can never disagree about which posture this board is in.
let hasGitDirectory: Bool
/// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and /// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s and
/// `BoardThemeTabView`'s own parameter the tab pads by this amount instead of restating the /// `BoardThemeTabView`'s own parameter the tab pads by this amount instead of restating the
/// derivation. /// derivation.
@@ -231,18 +178,7 @@ struct BoardGitTabView: View {
/// header's rule. /// header's rule.
@ViewBuilder @ViewBuilder
private var posture: some View { private var posture: some View {
switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) { switch BoardGitSection.resolve(mode: git?.mode ?? .none) {
case .absent:
// Unreachable, and kept honest rather than fatal: `BoardInfoTab.available` drops the Git
// tab from the strip on exactly this posture, so nothing ever selects a tab that would
// land here. Written as `EmptyView` because that is what the case *means* if the
// membership rule ever loosened, the free tier's ordinary board would render nothing,
// which is still the design's answer for it (12: "absent, no placeholder").
EmptyView()
case .proPointer:
BoardGitNote()
case .noRepository: case .noRepository:
// Nothing daily to show on a board with no repository so the tab is the fact and the // Nothing daily to show on a board with no repository so the tab is the fact and the
// door. Add-git itself moved to the sheet with the 2026-07-31 split; what stays here is // door. Add-git itself moved to the sheet with the 2026-07-31 split; what stays here is
@@ -285,7 +221,6 @@ struct BoardGitTabView: View {
@ViewBuilder @ViewBuilder
private var boardSettingsRow: some View { private var boardSettingsRow: some View {
if let settings, BoardSettingsAvailability.resolve( if let settings, BoardSettingsAvailability.resolve(
tier: tier,
mode: git?.mode ?? .none, mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
) { ) {
+45 -88
View File
@@ -13,8 +13,10 @@ import SwiftUI
/// which the pre-tab body's mode-aware git section rehomed into whole postures, notes, and the /// which the pre-tab body's mode-aware git section rehomed into whole postures, notes, and the
/// Board Settings row, none of them re-ruled by the move. /// Board Settings row, none of them re-ruled by the move.
/// ///
/// **Tab membership is the git posture's** (`BoardInfoTab.available(tier:mode:hasGitDirectory:)`), /// **Tab membership is the git posture's**, and **selection resets to Info on every open** both
/// and **selection resets to Info on every open** both the Git session's rulings. /// the Git session's rulings. Since 12-editions.md PIVOT 2026-08-07 the first of those is a
/// structural rule with nothing left to exclude: every board carries all three tabs (03 Board
/// popover, the same-day pivot note) see `BoardInfoTab`.
/// ///
/// ### One home, deliberately /// ### One home, deliberately
/// ///
@@ -62,8 +64,8 @@ extension FocusedValues {
// MARK: - The window-title widget // MARK: - The window-title widget
/// The titlebar widget: the board's name and, on a git-mode Pro board, its branch with a /// The titlebar widget: the board's name and, on a git-mode board, its branch with a trailing
/// trailing disclosure chevron, whose one job is this popover. /// disclosure chevron, whose one job is this popover.
/// ///
/// **The popover is anchored to the widget itself** it hangs from the button rather than from the /// **The popover is anchored to the widget itself** it hangs from the button rather than from the
/// window or the board which is what makes the affordance and the surface read as one thing. A /// window or the board which is what makes the affordance and the surface read as one thing. A
@@ -80,12 +82,14 @@ struct BoardInfoWidget: View {
let store: BoardStore let store: BoardStore
let recents: StyleRecents let recents: StyleRecents
/// The tier and the git state this board's **session** composed with read once, at the moment /// The git state this board's **session** composed with read once, at the moment the widget is
/// the widget is installed, and never re-derived (12-editions.md The entitlement: "a lapse /// installed, and never re-derived (12-editions.md The entitlement: "a lapse never interrupts
/// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git /// an open session"). `git` is a reference type and `@Observable`, so add-git flipping the mode,
/// flipping the mode, or a branch switch, redraws the widget without anything here being /// or a branch switch, redraws the widget without anything here being re-created.
/// re-created. ///
let tier: Tier /// It stopped being a tier signal at 12 PIVOT 2026-08-07 every session composes a git state
/// now, whatever the tier so a `nil` here means only "this caller had no session to hand over"
/// (previews, the accessory-installation tests), which reads as mode `none`.
let git: HistoryStore? let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation @Bindable var presentation: BoardInfoPresentation
@@ -105,7 +109,6 @@ struct BoardInfoWidget: View {
BoardInfoTitlebarSummary( BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value, snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL, rootURL: store.rootURL,
tier: tier,
mode: git?.mode ?? .none, mode: git?.mode ?? .none,
branch: git?.branch branch: git?.branch
) )
@@ -173,7 +176,7 @@ struct BoardInfoWidget: View {
// costs nothing on the other four postures. // costs nothing on the other four postures.
.task { await git?.refreshBranch() } .task { await git?.refreshBranch() }
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) { .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, tier: tier, git: git, settings: settings) BoardInfoView(store: store, recents: recents, git: git, settings: settings)
} }
} }
@@ -212,23 +215,26 @@ struct BoardInfoWidget: View {
/// with plain values and no fixture board on disk the one duplication this card leaves behind /// with plain values and no fixture board on disk the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites. /// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
/// ///
/// **Branch.** Shown only when the board is actually git-mode under Pro `tier == .pro && mode == /// **Branch.** Shown only when the board is actually git-mode `mode == .git` with a non-`nil`
/// .git` with a non-`nil` branch the same condition family `BoardGitSection.resolve`'s `.branch` /// branch the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause
/// case covers. The free tier and an inert `.git` (mode `.none` or `.repoNested`) show no branch; /// is gone** (12-editions.md PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a
/// neither does a git-mode board whose branch has not been read yet (`HistoryStore.branch` starts /// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode ==
/// `nil` until `refreshBranch()` answers, which the widget's own `.task` kicks off at open). /// .git` until that day is recorded here rather than restated in code. A board with no repository or
/// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a
/// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until
/// `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
struct BoardInfoTitlebarSummary: Equatable { struct BoardInfoTitlebarSummary: Equatable {
let title: String let title: String
let branch: String? let branch: String?
init(snapshotTitle: String?, rootURL: URL, tier: Tier, mode: BoardGitMode, branch: String?) { init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) {
if let snapshotTitle, !snapshotTitle.isEmpty { if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle self.title = snapshotTitle
} else { } else {
self.title = rootURL.deletingPathExtension().lastPathComponent self.title = rootURL.deletingPathExtension().lastPathComponent
} }
self.branch = (tier == .pro && mode == .git) ? branch : nil self.branch = mode == .git ? branch : nil
} }
} }
@@ -240,14 +246,13 @@ struct BoardInfoTitlebarSummary: Equatable {
/// window, removed on detach for the same reason it owns the delegate proxying: the window is /// window, removed on detach for the same reason it owns the delegate proxying: the window is
/// SwiftUI's, and anything hung on it has to be taken back off. /// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor @MainActor
/// `tier`/`git` default to the free tier's posture a popover with no git section at all and /// `git` defaults to no session and `settings` to no sheet, so that a caller with none in hand (the
/// `settings` to no sheet, so that a caller with no session in hand (the accessory-installation /// accessory-installation tests, which are about AppKit plumbing rather than about git) describes a
/// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather /// board honestly rather than by accident: a Git tab in its no-repository posture, and no
/// than by accident. The app's own call site passes the session's values explicitly. /// Board Settings row behind it. The app's own call site passes the session's values explicitly.
func boardInfoTitlebarAccessory( func boardInfoTitlebarAccessory(
store: BoardStore, store: BoardStore,
recents: StyleRecents, recents: StyleRecents,
tier: Tier = .free,
git: HistoryStore? = nil, git: HistoryStore? = nil,
presentation: BoardInfoPresentation, presentation: BoardInfoPresentation,
settings: BoardSettingsPresentation? = nil settings: BoardSettingsPresentation? = nil
@@ -256,7 +261,6 @@ func boardInfoTitlebarAccessory(
rootView: BoardInfoWidget( rootView: BoardInfoWidget(
store: store, store: store,
recents: recents, recents: recents,
tier: tier,
git: git, git: git,
presentation: presentation, presentation: presentation,
settings: settings settings: settings
@@ -291,26 +295,16 @@ enum BoardInfoTab: String, CaseIterable, Identifiable {
var id: Self { self } var id: Self { self }
/// **Which tabs this board actually offers** membership *is* the git posture (03-board-ui.md // **Membership is the git posture's, and the posture never says "absent" any more.** The Git
/// § Board popover, the Git session's ruling, 2026-08-07): the Git tab joins the strip only when // session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has
/// the git section has something true to say, and drops out exactly where `BoardGitSection` // something true to say, which then meant dropping it on a free board with no `.git` the
/// resolves to `.absent` the free tier's ordinary board, which shows Info | Theme alone. // "absent, no placeholder" rule carried up to the strip. **12-editions.md PIVOT 2026-08-07**,
/// // the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and
/// That carries 12-editions.md's "absent, no placeholder" rule up to the tab strip: a standing // `.proPointer` died with it, and 03-board-ui.md Board popover records the consequence "the
/// Git tab on every free board would be the standing ad for Pro that 12 forbids, and an empty tab // absent posture is unreachable and every board carries all three tabs. The membership rule
/// behind it would be the placeholder 12 forbids twice over. // stands structurally the strip still asks the posture it just never hears 'absent'
/// // anymore." So there is no `available()` filter here to ask it with: membership is `allCases`,
/// **Delegated, never restated.** The posture matrix is settled, test-pinned design // in `allCases`' own order (Info, Theme, Git), which is what the filter answered anyway.
/// (`BoardGitSectionTests`), and a second reading of "is there git to show here" is a reading
/// that can disagree with the tab it labels so this asks `BoardGitSection.resolve` the same
/// question `BoardGitTabView` asks, from the same three facts.
///
/// Order is `allCases`' own Info, then Theme, then Git so the Git tab, where present, is
/// always last and the two standing tabs never move under a user who switched boards.
static func available(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> [BoardInfoTab] {
let posture = BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGitDirectory)
return allCases.filter { $0 != .git || posture != .absent }
}
} }
// MARK: - The popover's content // MARK: - The popover's content
@@ -326,7 +320,6 @@ struct BoardInfoView: View {
let store: BoardStore let store: BoardStore
let recents: StyleRecents let recents: StyleRecents
let tier: Tier
let git: HistoryStore? let git: HistoryStore?
let settings: BoardSettingsPresentation? let settings: BoardSettingsPresentation?
@@ -337,17 +330,6 @@ struct BoardInfoView: View {
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more. /// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
@State private var tab: BoardInfoTab = .info @State private var tab: BoardInfoTab = .info
/// Whether this board carries a `.git` checked once, off disk, when the view is built (which
/// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance).
/// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here.
///
/// **The free tier's input only.** Under Pro the Git tab reads the session's detected mode
/// instead a fact settled at open, which is where 06-history-undo.md puts detection and this
/// stays what it always was: the one quiet question the free tier asks of a board's folder. Since
/// the tab restructure it answers for the tab *strip* as well as for the tab's content
/// (`tabs` below), which is why it is read once here and handed down rather than asked twice.
private let hasGitDirectory: Bool
/// The style editor brings its own padding, so the sections around it carry the same number by /// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it **the editor's own figure** /// hand instead of an outer padding that would double up on it **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales /// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
@@ -356,36 +338,16 @@ struct BoardInfoView: View {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize) StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
} }
/// **The tab strip's membership** (`BoardInfoTab.available`), read inside `body` so the mode it
/// delegates to is live `HistoryStore` is `@Observable`, and add-git flipping a board's mode
/// must not leave a stale strip behind it.
///
/// **Stable across an open, in practice and by construction.** `tier` is the session's, fixed at
/// the widget's birth (12-editions.md The entitlement: "a lapse never interrupts an open
/// session"), and `hasGitDirectory` is one `init`-time read; the mode is the only live input, and
/// `BoardGitSection.resolve` answers `.absent` only under the free tier, where it never consults
/// the mode at all. So no live change can move the Git tab in or out from under the selection.
/// Add-git the one gesture that flips a mode runs from the settings sheet anyway, which the
/// Board Settings row dismisses this popover to reach.
private var tabs: [BoardInfoTab] {
BoardInfoTab.available(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory)
}
init( init(
store: BoardStore, store: BoardStore,
recents: StyleRecents, recents: StyleRecents,
tier: Tier = .free,
git: HistoryStore? = nil, git: HistoryStore? = nil,
settings: BoardSettingsPresentation? = nil settings: BoardSettingsPresentation? = nil
) { ) {
self.store = store self.store = store
self.recents = recents self.recents = recents
self.tier = tier
self.git = git self.git = git
self.settings = settings self.settings = settings
// Asked only where it is the answer: under Pro the mode already knows, and a free-tier
// board is the only one this question is for (12-editions.md The free tier and `.git`).
self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL)
} }
var body: some View { var body: some View {
@@ -432,10 +394,12 @@ struct BoardInfoView: View {
// The tab bar: a segmented control rather than a `TabView`, because the popover is a // The tab bar: a segmented control rather than a `TabView`, because the popover is a
// compact settings surface and the segmented idiom is the macOS shape for switching // compact settings surface and the segmented idiom is the macOS shape for switching
// between a handful of peer panes inside one. The label is hidden visually but stays // between a handful of peer panes inside one. The label is hidden visually but stays
// the control's accessibility name. It iterates `tabs`, not `allCases` membership is // the control's accessibility name. It iterates `allCases` the strip's membership is
// this board's git posture (`BoardInfoTab.available`). // still the board's git posture in principle (the Git session's ruling), and since
// 12-editions.md PIVOT 2026-08-07 that posture is never "absent", so every board
// carries all three tabs (03-board-ui.md Board popover, the same-day pivot note).
Picker("Board configuration", selection: $tab) { Picker("Board configuration", selection: $tab) {
ForEach(tabs) { tab in ForEach(BoardInfoTab.allCases) { tab in
Text(tab.rawValue) Text(tab.rawValue)
} }
} }
@@ -452,14 +416,7 @@ struct BoardInfoView: View {
case .theme: case .theme:
BoardThemeTabView(store: store, inset: inset) BoardThemeTabView(store: store, inset: inset)
case .git: case .git:
BoardGitTabView( BoardGitTabView(store: store, git: git, settings: settings, inset: inset)
store: store,
tier: tier,
git: git,
settings: settings,
hasGitDirectory: hasGitDirectory,
inset: inset
)
} }
} }
// The style editor's popover width, taken from the editor rather than restated the number // The style editor's popover width, taken from the editor rather than restated the number
+2 -2
View File
@@ -57,8 +57,8 @@ struct BoardInfoMetrics: Equatable {
/// gathered in one pass so the four numbers describe the same instant. /// gathered in one pass so the four numbers describe the same instant.
/// ///
/// **Taken once per appearance, never live.** The tab measures when it appears and shows that /// **Taken once per appearance, never live.** The tab measures when it appears and shows that
/// the `hasGitDirectory` posture one section over: a read-only fact refreshed by reopening, not a /// a read-only fact refreshed by reopening, not a live subscription. `FolderWatcher` deliberately
/// live subscription. `FolderWatcher` deliberately filters `.git` churn out of the reload stream, /// filters `.git` churn out of the reload stream,
/// so there is no event these numbers could honestly hang off; and a size that ticked while the /// so there is no event these numbers could honestly hang off; and a size that ticked while the
/// user watched would be motion without meaning on a settings surface. /// user watched would be motion without meaning on a settings surface.
struct BoardDiskFootprint: Equatable, Sendable { struct BoardDiskFootprint: Equatable, Sendable {
+49 -45
View File
@@ -39,51 +39,55 @@ import SwiftUI
/// than per board or per app): one per window, `@State` in `BoardWindowHost`, published through the /// than per board or per app): one per window, `@State` in `BoardWindowHost`, published through the
/// focus system so Board Board Settings means "the board in front". /// focus system so Board Board Settings means "the board in front".
/// ///
/// **It carries the session's tier and git state** where the popover flag carries nothing, and for a /// **It carries the session's git state** where the popover flag carries nothing, and for a reason
/// reason the popover does not have: both of this sheet's doors have to *validate*, and one of them /// the popover does not have: both of this sheet's doors have to *validate*, and one of them is a
/// is a menu row with no view around it to ask. The facts are adopted once, from the session, at the /// menu row with no view around it to ask. The fact is adopted once, from the session, at the same
/// same moment the titlebar widget adopts them (`BoardWindowHost.configureWindow`) and never /// moment the titlebar widget adopts it (`BoardWindowHost.configureWindow`) and never re-derived
/// re-derived 12-editions.md The entitlement, "a lapse never interrupts an open session". The /// 12-editions.md The entitlement, "a lapse never interrupts an open session". The *mode* inside
/// *mode* inside the git state is `@Observable` and does move, by add-git alone, which is exactly the /// the git state is `@Observable` and does move, by add-git alone, which is exactly the transition
/// transition this sheet is where the user performs: the sections re-resolve under it live. /// this sheet is where the user performs: the sections re-resolve under it live.
///
/// **The tier came out at 12 PIVOT 2026-08-07.** It was adopted here alongside the git state until
/// that day, because the sheet was Pro's; git is tier-independent now, so what the doors validate on
/// is the board's mode alone.
@MainActor @MainActor
@Observable @Observable
final class BoardSettingsPresentation { final class BoardSettingsPresentation {
var isPresented = false var isPresented = false
/// The tier this window's board composed under the free tier's default until a session says /// This window's board git state, `nil` on a window whose session has not been adopted yet
/// otherwise, which is the harmless direction (an unreachable sheet). /// which reads as mode `none`, the harmless direction (an add-git sheet, offered to a board that
private(set) var tier: Tier = .free /// may well already have a repository, is nothing anyone can act on before adoption lands).
/// This window's board git state, `nil` under the free tier and on a window whose session has not
/// been adopted yet.
private(set) var git: HistoryStore? private(set) var git: HistoryStore?
/// Called once per window, from the same place the titlebar widget is handed the same two facts. /// Called once per window, from the same place the titlebar widget is handed the same fact.
func adopt(tier: Tier, git: HistoryStore?) { func adopt(git: HistoryStore?) {
self.tier = tier
self.git = git self.git = git
} }
/// What the sheet would show right now and therefore, when empty, that there is no sheet to /// What the sheet would show right now and therefore, when empty, that there is no sheet to
/// show (`BoardSettingsAvailability`). /// show (`BoardSettingsAvailability`).
///
/// **An unadopted window has none**, and that is a `nil` check rather than a mode reading: every
/// control this sheet hosts writes *through* the git state (`section(_:)` renders nothing without
/// one), so a window that has not been handed its session yet would otherwise offer a sheet of
/// bare headers. It was the free tier's default that kept this shut before 12 PIVOT 2026-08-07;
/// what keeps it shut now is the honest absence of a session, which is the only thing a `nil`
/// ever meant here.
var sections: [BoardSettingsSection] { var sections: [BoardSettingsSection] {
BoardSettingsSection.resolve( guard let git else { return [] }
tier: tier, return BoardSettingsSection.resolve(
mode: git?.mode ?? .none, mode: git.mode,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false isRepositoryUnreadable: git.isRepositoryUnreadable
) )
} }
/// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row /// Both doors' validation: the menu row's `disabled` state and whether the popover shows its row
/// at all. /// at all. Derived from `sections` rather than from `BoardSettingsAvailability` directly, so the
/// unadopted case above cannot answer one way here and another there.
var isReachable: Bool { var isReachable: Bool {
BoardSettingsAvailability.resolve( !sections.isEmpty
tier: tier,
mode: git?.mode ?? .none,
isRepositoryUnreadable: git?.isRepositoryUnreadable ?? false
)
} }
/// **Opening, not toggling** unlike I. A sheet is modal to its window and carries its own /// **Opening, not toggling** unlike I. A sheet is modal to its window and carries its own
@@ -118,22 +122,30 @@ extension FocusedValues {
// MARK: - What the sheet holds // MARK: - What the sheet holds
/// **The sheet's inventory for one board**, as a pure function of the tier and the mode the shape /// **The sheet's inventory for one board**, as a pure function of the mode the shape
/// `BoardGitSection.resolve` has one surface over, and for the same reason: the *contents* are the /// `BoardGitSection.resolve` has one surface over, and for the same reason: the *contents* are the
/// part worth pinning and the SwiftUI that renders them is not. /// part worth pinning and the SwiftUI that renders them is not.
/// ///
/// **The tier axis came out at 12-editions.md PIVOT 2026-08-07**: this resolved `guard tier ==
/// .pro else { return [] }` first and the mode second until git left the paywall. Every board can
/// reach the setup it has now, and what it has is still the mode's answer the sheet's whole
/// vocabulary is repository-shaped, so a board with nothing repository-shaped to say still hosts
/// nothing.
///
/// Ordered as the sheet lays them out, top to bottom. pro-m2's cards each add a case here and a /// Ordered as the sheet lays them out, top to bottom. pro-m2's cards each add a case here and a
/// branch in `BoardSettingsSheet.section(_:)` nothing else. /// branch in `BoardSettingsSheet.section(_:)` nothing else.
enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable { enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
/// Pro, mode `none`: **add-git** (06-history-undo.md Rules Opt-in init). /// Mode `none`: **add-git** (06-history-undo.md Rules Opt-in init) the offer, on every
/// tier since the pivot, and still never an auto-init (git is opt-in per board, 12 PIVOT
/// 2026-08-07).
case git case git
/// Pro, mode `git`: **branch creation**. Switching stays in the popover (03 Board popover); /// Mode `git`: **branch creation**. Switching stays in the popover (03 Board popover);
/// create-and-switch runs 06's identical settle sequence from here. /// create-and-switch runs 06's identical settle sequence from here.
case branch case branch
/// Pro, mode `git`: the **commit identity** name/email that repo-local `.git/config` carries /// Mode `git`: the **commit identity** name/email that repo-local `.git/config` carries
/// (06 Interaction with external writers). /// (06 Interaction with external writers).
case commitIdentity case commitIdentity
@@ -154,14 +166,9 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
/// `git` every other mode has no repository for the probe to have failed on, and a caller /// `git` every other mode has no repository for the probe to have failed on, and a caller
/// that has no git state to ask is describing one of those boards. /// that has no git state to ask is describing one of those boards.
static func resolve( static func resolve(
tier: Tier,
mode: BoardGitMode, mode: BoardGitMode,
isRepositoryUnreadable: Bool = false isRepositoryUnreadable: Bool = false
) -> [BoardSettingsSection] { ) -> [BoardSettingsSection] {
// **The free tier has no setup to host** (12-editions.md The free tier and `.git`): git is
// the Pro subscription's, "any `.git` is inert", and 03 gives the free tier's whole git story
// as the popover's one-line pointer. There is nothing for a sheet to be about.
guard tier == .pro else { return [] }
switch mode { switch mode {
case .none: case .none:
return [.git] return [.git]
@@ -204,15 +211,14 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
/// Reachable **iff the sheet has something to show**, which is the rule rather than a shortcut: a /// Reachable **iff the sheet has something to show**, which is the rule rather than a shortcut: a
/// surface whose whole job is hosting setup controls has no honest empty state, and deriving the /// surface whose whole job is hosting setup controls has no honest empty state, and deriving the
/// answer from the inventory is what keeps the two from drifting when pro-m2's sections land. In /// answer from the inventory is what keeps the two from drifting when pro-m2's sections land. In
/// today's terms that reads: Pro, on a board whose mode is `none` or `git`. /// today's terms that reads: a board whose mode is `none` or `git`, on **any tier** the Pro
/// requirement that stood beside it retired with 12-editions.md PIVOT 2026-08-07.
/// ///
/// The two unreachable postures are unreachable for *different* reasons, and both are the design's: /// The unreachable postures are unreachable for reasons that are all the board's, and all the
/// /// design's `BoardSettingsSection.resolve`'s own comments carry them one by one: **repo-nested**
/// - **The free tier**: no setup exists there at all (03-board-ui.md Board popover; /// and **unverifiable**, where nothing setup-shaped can apply, and **git mode over a repository that
/// 12-editions.md). The popover's own postures are untouched by this card an ordinary board /// will not open**, where both sections would be writes into something the app cannot open. In each,
/// shows nothing, a board carrying an inert `.git` shows the one-line Pro pointer. /// the popover's prose stands and no door opens.
/// - **Pro, repo-nested**: `BoardSettingsSection.resolve`'s own comment carries this one nothing
/// setup-shaped can apply, so the popover's prose stands and no door opens.
/// ///
/// The menu **row stays visible and disabled** either way (standard menu validation a command that /// The menu **row stays visible and disabled** either way (standard menu validation a command that
/// does not apply here is still a command this app has), while the **popover row appears only where /// does not apply here is still a command this app has), while the **popover row appears only where
@@ -221,12 +227,10 @@ enum BoardSettingsSection: String, Equatable, CaseIterable, Identifiable {
enum BoardSettingsAvailability { enum BoardSettingsAvailability {
static func resolve( static func resolve(
tier: Tier,
mode: BoardGitMode, mode: BoardGitMode,
isRepositoryUnreadable: Bool = false isRepositoryUnreadable: Bool = false
) -> Bool { ) -> Bool {
!BoardSettingsSection.resolve( !BoardSettingsSection.resolve(
tier: tier,
mode: mode, mode: mode,
isRepositoryUnreadable: isRepositoryUnreadable isRepositoryUnreadable: isRepositoryUnreadable
).isEmpty ).isEmpty
+2 -1
View File
@@ -44,7 +44,8 @@ extension NSToolbarItem.Identifier {
/// enable exactly when that board has a step to cross and no read-only lock stands /// enable exactly when that board has a step to cross and no read-only lock stands
/// (13-native-undo.md Rules). **Every board has undo in every tier**, so there is no tier-shaped /// (13-native-undo.md Rules). **Every board has undo in every tier**, so there is no tier-shaped
/// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which /// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which
/// only a Pro subscription binds. /// a board binds by being in git mode on any tier since 12-editions.md PIVOT 2026-08-07, and
/// under a Pro subscription before it.
/// ///
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the /// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so /// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
+9 -3
View File
@@ -83,9 +83,15 @@ enum CardHistoryRows {
/// ### Its existence is the section's visibility rule /// ### Its existence is the section's visibility rule
/// ///
/// "The section is **absent** on boards without app-managed git (mode none, repo-nested) same /// "The section is **absent** on boards without app-managed git (mode none, repo-nested) same
/// honesty rule as the popover's git section" (05 History), and the free tier has no git state at /// honesty rule as the popover's git section" (05 History). So the host builds one of these only
/// all (12-editions.md). So the host builds one of these only in mode `git`, and `nil` is the whole /// where the board's history is genuinely git-backed its `HistoryStore` in mode `git` and `nil`
/// of the absence no placeholder, no empty header, nothing to explain. /// is the whole of the absence: no placeholder, no empty header, nothing to explain.
///
/// **Per board, never per tier** (12-editions.md PIVOT 2026-08-07). 12's old tier matrix listed
/// the card History sidebar as a Pro row, so the section's absence used to have two causes at once
/// a gitless board, or a free-tier session with no git state to ask. Git is tier-independent now:
/// the one question left is whether *this board* has a repository the app manages, which is the
/// question 05 was always asking.
/// ///
/// ### It re-reads rather than subscribes /// ### It re-reads rather than subscribes
/// ///
+10 -6
View File
@@ -97,8 +97,8 @@ struct CardWindowView: View {
/// the close can fold it, which is why it arrives here rather than being made here. /// the close can fold it, which is why it arrives here rather than being made here.
let undo: CardWindowUndo let undo: CardWindowUndo
/// **This card's commit trail** (05 History), or `nil` on every board with no app-managed git /// **This card's commit trail** (05 History), or `nil` on every board with no app-managed git
/// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule; /// mode none, repo-nested, unverifiable. The `nil` *is* the section's absence rule; see
/// see `historySlot`. /// `historySlot`. A per-board question on every tier since 12-editions.md PIVOT 2026-08-07.
let history: CardHistory? let history: CardHistory?
/// The whole-window file drop (05 Attachments: "the drop surface remains the **whole /// The whole-window file drop (05 Attachments: "the drop surface remains the **whole
/// window**"). `nil` only where a caller has no store to import through. /// window**"). `nil` only where a caller has no store to import through.
@@ -338,10 +338,14 @@ struct CardWindowView: View {
/// commit trail, read-only newest first semantic subject, relative date, author"). /// commit trail, read-only newest first semantic subject, relative date, author").
/// ///
/// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without /// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without
/// app-managed git (mode none, repo-nested) same honesty rule as the popover's git section", /// app-managed git (mode none, repo-nested) same honesty rule as the popover's git section".
/// and the free tier has no git state at all (12-editions.md The free tier and `.git`). The host /// The host builds a `CardHistory` only where the board's history is git-backed (mode `git`), so
/// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about: /// there is no placeholder here to decide about: what the slot reserves is the **position**, and
/// what the slot reserves is the **position**, and on every other board that position is empty. /// on every other board that position is empty.
///
/// **The tier is not one of the inputs** (12-editions.md PIVOT 2026-08-07 git left the
/// paywall, retiring 12's tier matrix row that made this a Pro surface): a git board shows its
/// trail on any tier, a gitless one shows nothing on any tier.
/// ///
// A later card: View History, which focuses this section (11-command-nexus.md). // A later card: View History, which focuses this section (11-command-nexus.md).
@ViewBuilder @ViewBuilder
+16 -12
View File
@@ -32,7 +32,7 @@ private func makeBoard() throws -> WriterFixture {
private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) { private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) {
let fixture = try makeBoard() let fixture = try makeBoard()
let ledger = EchoLedger() let ledger = EchoLedger()
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: ledger)) let git = HistoryStore.compose(boardRoot: fixture.root, ledger: ledger)
#expect(await git.addGit()) #expect(await git.addGit())
return (fixture, git, ledger) return (fixture, git, ledger)
} }
@@ -805,7 +805,7 @@ struct AutoCommitContentionTests {
encoding: .utf8 encoding: .utf8
) )
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git) #expect(git.mode == .git)
let committer = try quickCommitter(git) let committer = try quickCommitter(git)
@@ -902,29 +902,33 @@ struct FlushBeforeOverwriteTests {
} }
} }
// MARK: - Composition and the tier gate // MARK: - Composition
@MainActor @MainActor
@Suite("Auto-commit ▸ composition") @Suite("Auto-commit ▸ composition")
struct AutoCommitCompositionTests { struct AutoCommitCompositionTests {
@Test("The free tier composes no committer, because it composes no git state at all") @Test("A board carrying a `.git` composes a committer — there is no tier to compose under")
func theFreeTierHasNoCommitter() throws { func aGitBearingBoardComposesACommitter() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
// The tier gate is one level up: no `HistoryStore` means no committer, nothing to disable, // **PIVOT 2026-08-07** (12-editions.md git left the paywall). This test used to assert the
// and no path by which a free-tier session could touch `.git` (12-editions.md). // gate one level up: `compose(tier: .free)` was `nil`, so there was no committer, nothing to
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) // disable and no path by which a free session could touch `.git`. What decides now is the
// board's own mode, and this board has a `.git` at its root.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
#expect(git.committer != nil, "the committer's existence is exactly mode == .git")
} }
@Test("A Pro board without a repository has no committer either") @Test("A board without a repository has no committer")
func modeNoneHasNoCommitter() throws { func modeNoneHasNoCommitter() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none) #expect(git.mode == .none)
#expect(git.committer == nil, "the committer's existence is exactly mode == .git") #expect(git.committer == nil, "the committer's existence is exactly mode == .git")
} }
@@ -934,7 +938,7 @@ struct AutoCommitCompositionTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
var wired = 0 var wired = 0
git.activateAutoCommit { _ in wired += 1 } git.activateAutoCommit { _ in wired += 1 }
#expect(git.committer == nil) #expect(git.committer == nil)
@@ -949,7 +953,7 @@ struct AutoCommitCompositionTests {
let (fixture, _, _) = try await makeGitBoard() let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let reopened = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let reopened = HistoryStore.compose(boardRoot: fixture.root)
let committer = try #require(reopened.committer) let committer = try #require(reopened.committer)
// Composition happens on the board-open path, where arming a debounce would be a side effect // Composition happens on the board-open path, where arming a debounce would be a side effect
// of *detection*. `activateAutoCommit` is what starts it. // of *detection*. `activateAutoCommit` is what starts it.
+1 -1
View File
@@ -719,7 +719,7 @@ struct BoardDecisionSurfaceRepairCommitTests {
// The repository, with everything as it stands committed including the broken root, which is // 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. // what makes the repair a real change rather than a fresh file.
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger())) let git = HistoryStore.compose(boardRoot: fixture.root, ledger: EchoLedger())
#expect(await git.addGit()) #expect(await git.addGit())
let commitsBefore = try repairHistory(at: fixture.root).count let commitsBefore = try repairHistory(at: fixture.root).count
git.stopAutoCommit() git.stopAutoCommit()
+64 -142
View File
@@ -2,63 +2,32 @@ import Foundation
import Testing import Testing
@testable import Kanban @testable import Kanban
/// **The board popover's git-note detection** (12-editions.md The free tier and `.git`, ruled /// **The popover's git slot, posture by posture** (03-board-ui.md Board popover Git tab;
/// 2026-07-27): the free tier's whole git story is one line, shown only on a board that carries an /// 06-history-undo.md Rules).
/// inert `.git`. The seam
/// that decides *whether* to show it `BoardGitNote.hasGitDirectory(at:)` is a pure, read-only
/// `FileManager` check, pinned here against real bytes on disk. Everything else about the note (its
/// wording, its placement in `BoardGitTabView`) is SwiftUI rendering and deliberately untested.
/// ///
/// Since the 2026-08-07 tab restructure this same answer decides one thing more: whether the popover /// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the board's
/// carries a Git tab at all on a free board (`BoardInfoTabAvailabilityTests` below). /// **mode** precisely so the matrix is assertable the views it selects are SwiftUI and stay
struct BoardInfoPopoverTests { /// untested.
@Test("A board with a .git at its root reports true")
func trueForABoardWithGit() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
#expect(BoardGitNote.hasGitDirectory(at: fixture.root))
}
@Test("An ordinary board with no .git reports false")
func falseForAnOrdinaryBoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(!BoardGitNote.hasGitDirectory(at: fixture.root))
}
}
/// **The popover's git slot, posture by posture** (03-board-ui.md Board popover; 06-history-undo.md
/// Rules; 12-editions.md The free tier and `.git`).
/// ///
/// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the tier and the /// **The tier axis came out at 12-editions.md PIVOT 2026-08-07** (03's Git-tab note records the
/// board's mode precisely so the matrix is assertable the views it selects are SwiftUI and stay /// consequence): git left the paywall, so the two free-tier postures `.absent` on an ordinary
/// untested, exactly as the note's wording and placement do above. /// board and the `.proPointer` on one carrying an inert `.git`, both settled 2026-07-27 described
/// a gate that no longer exists and retired with it. With them went the `.git`-on-disk probe that
/// fed them (`BoardGitNote.hasGitDirectory(at:)`) and the tab-strip membership filter that read
/// `.absent` (`BoardInfoTab.available`, whose suite retired here the same day): detection now runs at
/// every board open on every tier, and the mode is the one input left.
@Suite("Board popover ▸ the git section's posture") @Suite("Board popover ▸ the git section's posture")
struct BoardGitSectionTests { struct BoardGitSectionTests {
@Test("The free tier: absent on an ordinary board, a Pro pointer on a board carrying an inert .git") @Test("Mode none has no repository to describe, git mode shows the branch")
func theFreeTierIsContextual() { func thePostureFollowsTheMode() {
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: false) == .absent) #expect(BoardGitSection.resolve(mode: .none) == .noRepository)
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: true) == .proPointer) #expect(BoardGitSection.resolve(mode: .git) == .branch)
}
@Test("Pro: mode none has no repository to describe, git mode shows the branch")
func proFollowsTheMode() {
#expect(BoardGitSection.resolve(tier: .pro, mode: .none, hasGitDirectory: false) == .noRepository)
#expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch)
} }
@Test("A repo-nested board explains itself — setup is absent, not disabled") @Test("A repo-nested board explains itself — setup is absent, not disabled")
func repoNestedExplainsRatherThanDisables() { func repoNestedExplainsRatherThanDisables() {
let section = BoardGitSection.resolve(tier: .pro, mode: .repoNested, hasGitDirectory: false) let section = BoardGitSection.resolve(mode: .repoNested)
// The design is insistent here: "not a hidden 'add git' but a short explanation the option // The design is insistent here: "not a hidden 'add git' but a short explanation the option
// is absent because it *can't* apply, and the UI should teach that rather than look broken" // is absent because it *can't* apply, and the UI should teach that rather than look broken"
@@ -74,7 +43,7 @@ struct BoardGitSectionTests {
// "Denial is not absence" (06 Rules Detection, ruled 2026-07-31): a denied ancestor check // "Denial is not absence" (06 Rules Detection, ruled 2026-07-31): a denied ancestor check
// is not a found repository, so the two must resolve to different cases even though both are // is not a found repository, so the two must resolve to different cases even though both are
// action-less, prose-only sections. // action-less, prose-only sections.
let section = BoardGitSection.resolve(tier: .pro, mode: .unverifiable, hasGitDirectory: false) let section = BoardGitSection.resolve(mode: .unverifiable)
#expect(section == .unverifiable) #expect(section == .unverifiable)
#expect(section != .repoNested, "a denial is not a nesting") #expect(section != .repoNested, "a denial is not a nesting")
@@ -88,10 +57,10 @@ struct BoardGitSectionTests {
/// The section case is deliberately blind to readability: what changes on such a board is what /// The section case is deliberately blind to readability: what changes on such a board is what
/// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation /// the branch surface inside it says (`BoardGitBranchSurface.resolve`, whose broken presentation
/// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The /// and own sentence `BoardGitBranchSurfaceTests` pins), not which section the popover shows. The
/// posture matrix stays a function of the tier and the mode alone. /// posture matrix stays a function of the mode alone.
@Test("An unreadable repository is still the branch section — never the no-repository posture") @Test("An unreadable repository is still the branch section — never the no-repository posture")
func anUnreadableRepositoryKeepsTheBranchSection() { func anUnreadableRepositoryKeepsTheBranchSection() {
let section = BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) let section = BoardGitSection.resolve(mode: .git)
#expect(section == .branch) #expect(section == .branch)
#expect(section != .noRepository, "the board has a repository; it is unreadable, not absent") #expect(section != .noRepository, "the board has a repository; it is unreadable, not absent")
@@ -99,84 +68,49 @@ struct BoardGitSectionTests {
@Test("Every posture is reachable, and none of them is two postures") @Test("Every posture is reachable, and none of them is two postures")
func theMatrixIsTotal() { func theMatrixIsTotal() {
let resolved = Set( let resolved = Set(BoardGitMode.allCases.map { BoardGitSection.resolve(mode: $0) })
Tier.allCases.flatMap { tier in
BoardGitMode.allCases.flatMap { mode in
[true, false].map { BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: $0) }
}
}
)
// One posture per mode, and every posture spoken for which is also what makes the tab
// strip's membership rule trivially true since the pivot: no mode resolves to nothing, so
// the Git tab is never dropped (03-board-ui.md Board popover, the pivot note).
#expect(resolved == Set(BoardGitSection.allCases)) #expect(resolved == Set(BoardGitSection.allCases))
#expect(resolved.count == BoardGitMode.allCases.count, "no two modes share a posture")
} }
} }
/// **The tab strip's membership** (03-board-ui.md Board popover, the tabbed-popover paragraph /// **The tab strip carries all three tabs, always** (03-board-ui.md Board popover, the tabbed-
/// the Git session's ruling, 2026-08-07): the Git tab joins the strip only where the git section has /// popover paragraph as the 2026-08-07 pivot note leaves it): "the absent posture is unreachable and
/// something true to say, so a free-tier board with no `.git` shows Info | Theme alone. /// every board carries all three tabs. The membership rule stands structurally the strip still asks
/// the posture it just never hears 'absent' anymore."
/// ///
/// `BoardInfoTab.available` is pinned here for the reason `BoardGitSection.resolve` is pinned above: /// So there is no `available()` seam left to pin; what is worth keeping is the strip's *order*,
/// it is the whole decision, pure in its three inputs, and the segmented `Picker` it feeds is SwiftUI /// which the popover's `Picker` takes from `allCases` and which the Git session fixed: Info first
/// and stays untested. What these tests are really about is that membership **is** the posture rather /// it is the board's face and the tab selection resets to it on every open then Theme, then Git.
/// than a second reading of it the last one walks the entire input matrix and asserts the tab's @Suite("Board popover ▸ the tab strip")
/// presence against `resolve` itself, so the strip cannot drift from the surface it labels. struct BoardInfoTabStripTests {
@Suite("Board popover ▸ the tab strip's membership")
struct BoardInfoTabAvailabilityTests {
@Test("A free board with no .git shows Info and Theme alone — never a standing Git tab") @Test("Info, Theme, Git — in that order, on every board")
func theFreeTierDropsTheGitTab() { func theStripIsTheWholeSet() {
// 12-editions.md's "absent, no placeholder" carried up to the tab strip: a Git tab on every #expect(BoardInfoTab.allCases == [.info, .theme, .git])
// free board would be the standing ad for Pro that 12 forbids.
#expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: false) == [.info, .theme])
} }
@Test("A free board carrying an inert .git earns the tab — it has the Pro pointer to say") @Test("The raw values are the segmented control's own labels")
func anInertGitEarnsTheTab() { func labelsAreTheRawValues() {
#expect(BoardInfoTab.available(tier: .free, mode: .none, hasGitDirectory: true) == [.info, .theme, .git]) // The strip needs no separate label function, which is the only reason this enum is
} // `String`-backed at all.
#expect(BoardInfoTab.allCases.map(\.rawValue) == ["Info", "Theme", "Git"])
@Test("Pro shows the Git tab under every mode — each of the four postures is a tab surface")
func proAlwaysShowsTheTab() {
for mode in BoardGitMode.allCases {
for hasGit in [true, false] {
#expect(
BoardInfoTab.available(tier: .pro, mode: mode, hasGitDirectory: hasGit)
== [.info, .theme, .git],
"Pro, mode \(mode): the tab is the posture's home, and Git is always last"
)
}
}
}
@Test("The tab is present exactly where the git section is not absent, across the whole matrix")
func membershipDelegatesToThePosture() {
for tier in Tier.allCases {
for mode in BoardGitMode.allCases {
for hasGit in [true, false] {
let tabs = BoardInfoTab.available(tier: tier, mode: mode, hasGitDirectory: hasGit)
let isAbsent =
BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: hasGit) == .absent
#expect(
tabs.contains(.git) == !isAbsent,
"\(tier)/\(mode)/git=\(hasGit): membership is the posture, not a second reading of it"
)
#expect(
Array(tabs.prefix(2)) == [.info, .theme],
"Info and Theme stand on every board, in that order — Info is the board's face"
)
#expect(tabs.count == Set(tabs).count, "no tab appears twice")
}
}
}
} }
} }
/// **The window-title widget's two strings** (03-board-ui.md Board popover, the card that widened /// **The window-title widget's two strings** (03-board-ui.md Board popover, the card that widened
/// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure /// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure
/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its /// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its mode
/// tier/mode inputs with. No disk I/O the title half of this seam takes a raw `URL`, not a /// input with. No disk I/O the title half of this seam takes a raw `URL`, not a `BoardStore`, so a
/// `BoardStore`, so a plain `/tmp/...` path is enough. /// plain `/tmp/...` path is enough.
///
/// **The tier axis came out at 12-editions.md PIVOT 2026-08-07.** The branch rule read `tier ==
/// .pro && mode == .git` until that day; git is tier-independent now, so a git-mode board shows its
/// branch and nothing else does.
@Suite("Board popover ▸ the widget's strings") @Suite("Board popover ▸ the widget's strings")
struct BoardInfoTitlebarSummaryTests { struct BoardInfoTitlebarSummaryTests {
@@ -185,17 +119,17 @@ struct BoardInfoTitlebarSummaryTests {
@Test("The title is the on-disk title, falling back to the folder name sans extension") @Test("The title is the on-disk title, falling back to the folder name sans extension")
func titleFallsBackToTheFolderName() { func titleFallsBackToTheFolderName() {
let named = BoardInfoTitlebarSummary( let named = BoardInfoTitlebarSummary(
snapshotTitle: "Sprint 12", rootURL: root, tier: .free, mode: .none, branch: nil snapshotTitle: "Sprint 12", rootURL: root, mode: .none, branch: nil
) )
#expect(named.title == "Sprint 12") #expect(named.title == "Sprint 12")
let untitled = BoardInfoTitlebarSummary( let untitled = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .free, mode: .none, branch: nil snapshotTitle: nil, rootURL: root, mode: .none, branch: nil
) )
#expect(untitled.title == "My Board") #expect(untitled.title == "My Board")
let emptyTitled = BoardInfoTitlebarSummary( let emptyTitled = BoardInfoTitlebarSummary(
snapshotTitle: "", rootURL: root, tier: .free, mode: .none, branch: nil snapshotTitle: "", rootURL: root, mode: .none, branch: nil
) )
#expect( #expect(
emptyTitled.title == "My Board", emptyTitled.title == "My Board",
@@ -203,43 +137,31 @@ struct BoardInfoTitlebarSummaryTests {
) )
} }
@Test("The branch shows under Pro, in git mode, once it has been read") @Test("The branch shows in git mode, once it has been read")
func branchShowsForProGitMode() { func branchShowsInGitMode() {
let summary = BoardInfoTitlebarSummary( let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: "main" snapshotTitle: nil, rootURL: root, mode: .git, branch: "main"
) )
#expect(summary.branch == "main") #expect(summary.branch == "main")
} }
@Test("The free tier never shows a branch, whatever the mode or the git state hands it") @Test("No other mode shows a branch, whatever the git state hands it")
func theFreeTierNeverShowsABranch() { func onlyGitModeShowsABranch() {
for mode in BoardGitMode.allCases { for mode in BoardGitMode.allCases where mode != .git {
let summary = BoardInfoTitlebarSummary( let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .free, mode: mode, branch: "main" snapshotTitle: nil, rootURL: root, mode: mode, branch: "main"
)
#expect(
summary.branch == nil,
"mode \(mode): a board the app manages no repository for must never surface a stray branch value"
) )
#expect(summary.branch == nil, "detection never runs under the free tier, so a stray branch value must never surface")
} }
} }
@Test("An inert .git under Pro — mode none, repo-nested, or unverifiable — never shows a branch")
func inertGitNeverShowsABranch() {
#expect(
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .none, branch: "main").branch == nil
)
#expect(
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .repoNested, branch: "main").branch
== nil
)
#expect(
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .unverifiable, branch: "main").branch
== nil
)
}
@Test("A git-mode board whose branch has not been read yet shows none, honestly") @Test("A git-mode board whose branch has not been read yet shows none, honestly")
func unreadBranchShowsNone() { func unreadBranchShowsNone() {
let summary = BoardInfoTitlebarSummary( let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: nil snapshotTitle: nil, rootURL: root, mode: .git, branch: nil
) )
#expect(summary.branch == nil) #expect(summary.branch == nil)
} }
+63 -61
View File
@@ -9,53 +9,54 @@ import Testing
/// They are pinned here for `BoardGitSection.resolve`'s reason one surface over: the *inventory* is /// They are pinned here for `BoardGitSection.resolve`'s reason one surface over: the *inventory* is
/// the decision worth asserting and the SwiftUI that renders it is not. Every case below is a plain /// the decision worth asserting and the SwiftUI that renders it is not. Every case below is a plain
/// value no board on disk, no window, no session. /// value no board on disk, no window, no session.
///
/// **The tier axis came out at 12-editions.md PIVOT 2026-08-07**: this inventory resolved `guard
/// tier == .pro` first and the mode second until git left the paywall. What survives is the mode
/// reading, which was always the part carrying the design's reasoning.
@Suite("Board settings sheet ▸ the sections") @Suite("Board settings sheet ▸ the sections")
struct BoardSettingsSectionTests { struct BoardSettingsSectionTests {
@Test("Pro, mode none: the sheet is add-git and nothing else") @Test("Mode none: the sheet is add-git and nothing else")
func modeNoneHoldsAddGit() { func modeNoneHoldsAddGit() {
// "add-git (mode none; opt-in init 06)" 03's own first entry for this surface, and the // "add-git (mode none; opt-in init 06)" 03's own first entry for this surface, and the
// control that moved here out of the popover with the split. // control that moved here out of the popover with the split. Every tier's since
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .none) == [.git]) // 12-editions.md PIVOT 2026-08-07, and still an offer rather than an auto-init.
#expect(BoardSettingsSection.resolve(mode: .none) == [.git])
} }
@Test("Pro, git mode: branch creation and the commit identity, in that order") @Test("Git mode: branch creation and the commit identity, in that order")
func gitModeHoldsCreationAndIdentity() { func gitModeHoldsCreationAndIdentity() {
// "branch creation (switching stays in the popover)" and "commit identity name/email (06 // "branch creation (switching stays in the popover)" and "commit identity name/email (06
// the visibility-scoped 2 s config re-read rides with the fields)". // the visibility-scoped 2 s config re-read rides with the fields)".
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity]) #expect(BoardSettingsSection.resolve(mode: .git) == [.branch, .commitIdentity])
} }
@Test("Every section is reachable from some posture, and no posture invents one") @Test("Every section is reachable from some posture, and no posture invents one")
func theInventoryIsTotal() { func theInventoryIsTotal() {
let offered = Set( let offered = Set(BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(mode: $0) })
Tier.allCases.flatMap { tier in
BoardGitMode.allCases.flatMap { BoardSettingsSection.resolve(tier: tier, mode: $0) }
}
)
#expect(offered == Set(BoardSettingsSection.allCases)) #expect(offered == Set(BoardSettingsSection.allCases))
} }
@Test("Pro, unverifiable: the sheet has nothing to show — structurally like repo-nested") @Test("Unverifiable: the sheet has nothing to show — structurally like repo-nested")
func unverifiableHoldsNothing() { func unverifiableHoldsNothing() {
// "Denial is not absence" (06 Rules Detection, ruled 2026-07-31): a denied ancestor check // "Denial is not absence" (06 Rules Detection, ruled 2026-07-31): a denied ancestor check
// can never be told apart from a repository actually being there, so add-git stays as // can never be told apart from a repository actually being there, so add-git stays as
// unreachable here as it is on a genuinely nested board. // unreachable here as it is on a genuinely nested board.
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .unverifiable) == []) #expect(BoardSettingsSection.resolve(mode: .unverifiable) == [])
} }
@Test("Pro, git mode with an unreadable repository: nothing setup-shaped applies either") @Test("Git mode with an unreadable repository: nothing setup-shaped applies either")
func anUnreadableRepositoryHoldsNothing() { func anUnreadableRepositoryHoldsNothing() {
// **The corrupt-`.git` loud failure** (06 Rules, ruled 2026-07-31): both sections here are // **The corrupt-`.git` loud failure** (06 Rules, ruled 2026-07-31): both sections here are
// *writes* to a repository a branch created in it, an identity written into its config // *writes* to a repository a branch created in it, an identity written into its config
// and there is no repository the app can open to write either into. Repo-nested's emptiness, // and there is no repository the app can open to write either into. Repo-nested's emptiness,
// reached one step further along. // reached one step further along.
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true) == []) #expect(BoardSettingsSection.resolve(mode: .git, isRepositoryUnreadable: true) == [])
// and the mode is still `git` throughout: this is emptiness *within* git mode, never the // and the mode is still `git` throughout: this is emptiness *within* git mode, never the
// fall to mode none that would let add-git be offered against an existing `.git`. // fall to mode none that would let add-git be offered against an existing `.git`.
#expect(BoardSettingsSection.resolve(tier: .pro, mode: .git) == [.branch, .commitIdentity]) #expect(BoardSettingsSection.resolve(mode: .git) == [.branch, .commitIdentity])
} }
@Test("The sections carry the headers VoiceOver navigates by") @Test("The sections carry the headers VoiceOver navigates by")
@@ -69,41 +70,34 @@ struct BoardSettingsSectionTests {
} }
} }
/// **Where the sheet can be opened from, tier by mode** the answer both doors validate on: Board /// **Where the sheet can be opened from, mode by mode** the answer both doors validate on: Board
/// Board Settings's `disabled` state, and whether the popover's git section renders its Board /// Board Settings's `disabled` state, and whether the popover's git section renders its Board
/// Settings row at all. /// Settings row at all.
///
/// **The Pro clause is gone** (12-editions.md PIVOT 2026-08-07): every board can reach the setup
/// its own mode leaves it, on any tier.
@Suite("Board settings sheet ▸ availability") @Suite("Board settings sheet ▸ availability")
struct BoardSettingsAvailabilityTests { struct BoardSettingsAvailabilityTests {
@Test("The whole matrix: Pro on a none-or-git board, and nowhere else") @Test("The whole matrix: a none-or-git board, and nowhere else")
func theMatrix() { func theMatrix() {
// Pro, and a board whose mode leaves something to set up. // A board whose mode leaves something to set up.
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .none)) #expect(BoardSettingsAvailability.resolve(mode: .none))
#expect(BoardSettingsAvailability.resolve(tier: .pro, mode: .git)) #expect(BoardSettingsAvailability.resolve(mode: .git))
// **Pro, repo-nested**: "nothing setup-shaped can apply" no add-git (06's prose, not a // **Repo-nested**: "nothing setup-shaped can apply" no add-git (06's prose, not a
// disabled button), no branch of ours to create, no repo-local config of ours to write. The // disabled button), no branch of ours to create, no repo-local config of ours to write. The
// popover's explanation stands and no door opens. // popover's explanation stands and no door opens.
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .repoNested)) #expect(!BoardSettingsAvailability.resolve(mode: .repoNested))
// **Pro, unverifiable**: the same unreachability, for the denial-not-absence reason a // **Unverifiable**: the same unreachability, for the denial-not-absence reason a denied
// denied ancestor check is never distinguishable from a repository actually being there. // ancestor check is never distinguishable from a repository actually being there.
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .unverifiable)) #expect(!BoardSettingsAvailability.resolve(mode: .unverifiable))
// **Pro, git mode over a repository that will not open**: no door either, so neither the // **Git mode over a repository that will not open**: no door either, so neither the
// popover's Board Settings row nor the menu command offers a surface with nothing on it // popover's Board Settings row nor the menu command offers a surface with nothing on it
// (06 Rules, the corrupt-`.git` loud failure). // (06 Rules, the corrupt-`.git` loud failure).
#expect(!BoardSettingsAvailability.resolve(tier: .pro, mode: .git, isRepositoryUnreadable: true)) #expect(!BoardSettingsAvailability.resolve(mode: .git, isRepositoryUnreadable: true))
// **The free tier**: no setup exists there at all (12-editions.md The free tier and
// `.git`), whatever mode a stray value claims detection never runs off Pro, so the mode is
// swept for completeness rather than because it can vary.
for mode in BoardGitMode.allCases {
#expect(
!BoardSettingsAvailability.resolve(tier: .free, mode: mode),
"the free tier has no board settings sheet in any mode"
)
}
} }
@Test("Reachable means exactly 'has something to show'") @Test("Reachable means exactly 'has something to show'")
@@ -111,13 +105,11 @@ struct BoardSettingsAvailabilityTests {
// The derivation, not a coincidence: a surface whose whole job is hosting setup controls has // The derivation, not a coincidence: a surface whose whole job is hosting setup controls has
// no honest empty state, so the two answers are one answer. pro-m2's sections join the // no honest empty state, so the two answers are one answer. pro-m2's sections join the
// inventory and this identity keeps holding. // inventory and this identity keeps holding.
for tier in Tier.allCases { for mode in BoardGitMode.allCases {
for mode in BoardGitMode.allCases { #expect(
#expect( BoardSettingsAvailability.resolve(mode: mode)
BoardSettingsAvailability.resolve(tier: tier, mode: mode) == !BoardSettingsSection.resolve(mode: mode).isEmpty
== !BoardSettingsSection.resolve(tier: tier, mode: mode).isEmpty )
)
}
} }
} }
} }
@@ -133,10 +125,11 @@ struct BoardSettingsPresentationTests {
let presentation = BoardSettingsPresentation() let presentation = BoardSettingsPresentation()
#expect(presentation.isPresented == false) #expect(presentation.isPresented == false)
#expect(presentation.tier == .free)
#expect(presentation.git == nil) #expect(presentation.git == nil)
// The free tier's posture is the safe default for a window whose session has not been adopted // No session adopted yet, so no sheet: every control this surface hosts writes *through* the
// yet: an unreachable sheet, rather than a sheet with no sections in it. // git state, and a sheet of bare headers is the empty state the design forbids. (It was the
// free tier's default that kept this shut before 12-editions.md PIVOT 2026-08-07; the
// absence of a session is what keeps it shut now.)
#expect(presentation.isReachable == false) #expect(presentation.isReachable == false)
#expect(presentation.sections.isEmpty) #expect(presentation.sections.isEmpty)
} }
@@ -146,7 +139,7 @@ struct BoardSettingsPresentationTests {
let presentation = BoardSettingsPresentation() let presentation = BoardSettingsPresentation()
presentation.present() presentation.present()
#expect(presentation.isPresented == false, "the free tier has no sheet to open") #expect(presentation.isPresented == false, "an unadopted window has no sheet to open")
} }
@Test("Two windows hold their own flags") @Test("Two windows hold their own flags")
@@ -160,19 +153,20 @@ struct BoardSettingsPresentationTests {
#expect(second.isPresented == false) #expect(second.isPresented == false)
} }
@Test("Adopting a Pro git session makes the sheet reachable, and dismissal is idempotent") @Test("Adopting a session makes the sheet reachable, and dismissal is idempotent")
func adoptingASession() throws { func adoptingASession() throws {
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
// Mode `none` the add-git posture, which is the sheet's whole job on a board with no // Mode `none` the add-git posture, which is the sheet's whole job on a board with no
// repository yet. // repository yet. Composed with no tier in sight: git is tier-independent since
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) // 12-editions.md PIVOT 2026-08-07, so this is every session's git state, not Pro's.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none) #expect(git.mode == .none)
let presentation = BoardSettingsPresentation() let presentation = BoardSettingsPresentation()
presentation.adopt(tier: .pro, git: git) presentation.adopt(git: git)
#expect(presentation.isReachable) #expect(presentation.isReachable)
#expect(presentation.sections == [.git]) #expect(presentation.sections == [.git])
@@ -185,23 +179,31 @@ struct BoardSettingsPresentationTests {
#expect(presentation.isPresented == false) #expect(presentation.isPresented == false)
} }
@Test("The free tier's session leaves both doors shut, .git on the folder or not") @Test("A board carrying a `.git` is a git board — the inert posture is retired")
func aFreeTierSessionIsUnreachable() throws { func anInertGitIsNoLongerInert() throws {
let fixture = try WriterFixture() let fixture = try WriterFixture()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
// `compose` is the tier gate: the free tier gets no git state at all, so there is nothing for // The retired posture, asserted where it used to bite: a `.git` at a board root was **inert**
// a settings sheet to be about even on a board carrying an inert `.git` the popover's // off Pro never read, never written, the popover's one-line Pro pointer that board's whole
// one-line Pro pointer is that board's whole story (12-editions.md). // story. Detection runs at every board open on every tier now (12-editions.md PIVOT
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) // 2026-08-07), so this composes in git mode with no tier asked for.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git)
// And what these three bytes actually are is a repository libgit2 will not open, so the board
// takes **06's corrupt-`.git` posture, not the retired inert one** (06 Rules, ruled
// 2026-07-31): git mode throughout never a fall to mode none that would offer add-git
// against an existing `.git` with both doors shut because every section here is a write
// into something the app cannot open.
#expect(git.isRepositoryUnreadable)
let presentation = BoardSettingsPresentation() let presentation = BoardSettingsPresentation()
presentation.adopt(tier: .free, git: nil) presentation.adopt(git: git)
#expect(presentation.isReachable == false) #expect(presentation.isReachable == false)
presentation.present() #expect(presentation.sections.isEmpty)
#expect(presentation.isPresented == false)
} }
} }
+2 -2
View File
@@ -37,7 +37,7 @@ private func makeBoard() throws -> WriterFixture {
@MainActor @MainActor
private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore) { private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore) {
let fixture = try makeBoard() let fixture = try makeBoard()
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: EchoLedger())) let git = HistoryStore.compose(boardRoot: fixture.root, ledger: EchoLedger())
#expect(await git.addGit()) #expect(await git.addGit())
await git.refreshBranch() await git.refreshBranch()
return (fixture, git) return (fixture, git)
@@ -1034,7 +1034,7 @@ struct BoardGitBranchSurfaceTests {
/// is that git mode now carries controls rather than a read-only line. /// is that git mode now carries controls rather than a read-only line.
@Test("Git mode still resolves to the branch section — now the one with controls in it") @Test("Git mode still resolves to the branch section — now the one with controls in it")
func gitModeResolvesToTheBranchSection() { func gitModeResolvesToTheBranchSection() {
#expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch) #expect(BoardGitSection.resolve(mode: .git) == .branch)
} }
} }
+2 -2
View File
@@ -137,7 +137,7 @@ private func makeWindow() async throws -> Window {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
await store.awaitQuiescence() await store.awaitQuiescence()
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: store.echoes)) let git = HistoryStore.compose(boardRoot: fixture.root, ledger: store.echoes)
#expect(await git.addGit()) #expect(await git.addGit())
let committer = try #require(git.committer) let committer = try #require(git.committer)
@@ -494,7 +494,7 @@ struct CardSessionStagingWiringTests {
try fixture.item("", Item.board) try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) 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(cardPath, Item.rich(order: "1024", title: "Fix login"))
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
return (fixture, try openBoard(model, at: fixture.root)) return (fixture, try openBoard(model, at: fixture.root))
} }
+62 -50
View File
@@ -39,7 +39,7 @@ private func makeBoard() throws -> WriterFixture {
private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) { private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) {
let fixture = try makeBoard() let fixture = try makeBoard()
let ledger = EchoLedger() let ledger = EchoLedger()
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: ledger)) let git = HistoryStore.compose(boardRoot: fixture.root, ledger: ledger)
#expect(await git.addGit()) #expect(await git.addGit())
return (fixture, git, ledger) return (fixture, git, ledger)
} }
@@ -998,32 +998,36 @@ struct GitUndoSessionTests {
// MARK: - The provider binding // MARK: - The provider binding
/// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 12-editions.md /// **The provider follows the board and since PIVOT 2026-08-07, the board alone** (re-ruled
/// The provider seam; 13-native-undo.md's header; 06-history-undo.md Rules), stated as the /// 2026-07-31 12-editions.md The provider seam; 13-native-undo.md's header; 06-history-undo.md
/// matrix it is: **boards without app-managed git repo-nested included bind the native stack in /// Rules; then 12 PIVOT 2026-08-07, git leaves the paywall), stated as the matrix it is: a
/// *every* tier** ("an upgrade never removes undo"), and a Pro git board binds the git provider. /// **git-mode board binds the git provider** and **every other board binds the native stack**
/// There is no third answer any more. /// repo-nested and unverifiable included in *every* tier. There is no third answer and no second
/// axis.
/// ///
/// ### The repo-nested row stopped being an exception /// ### What the pivot changed here, and what it did not
/// ///
/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all /// The 2026-07-31 re-ruling had already taken the tier out of every row but one: it still decided
/// and cannot tell a repo-nested board from a plain one which is not an omission but 12 The free /// whether git was on the table at all, because `HistoryStore.compose` returned `nil` off Pro, so a
/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a /// free session never detected a mode and could not tell a repo-nested board from a plain one. The
/// 1.x board, **a repo-nested board**) works normally files read and write as on any board, /// pivot removes that last row "every tier composes the git stack on git-mode boards exactly as
/// **native undo runs**". Pro used to answer differently on the same board, which made subscribing /// Pro did" so a free-tier session on a git board now binds the *git* provider, which is the
/// *remove* Z from it; the re-ruling of 2026-07-31 retired that case outright "leave strictly /// inversion `freeTierBindsGitOnAGitBoard` below pins.
/// alone concerns *git*, and this stack never touches git memory-only, journal-free, ///
/// session-scoped" (13's header) so the two tiers now agree on every board there is, and the tests /// What survives untouched is the gitless side: a board nobody added git to detects `none` and binds
/// below pin both halves of that agreement. /// the native stack, for good, because git stays **opt-in per board** (06 Rules; 13's header read
/// through the pivot). And the repo-nested row keeps the 2026-07-31 reasoning that retired it as an
/// exception "leave strictly alone concerns *git*, and this stack never touches git memory-only,
/// journal-free, session-scoped" (13's header).
@MainActor @MainActor
@Suite("Git undo ▸ which board gets a provider") @Suite("Git undo ▸ which board gets a provider")
struct GitUndoBindingTests { struct GitUndoBindingTests {
@Test("Free tier binds the native stack everywhere, git or not") @Test("Free tier on a git board binds the git provider — git left the paywall")
func freeTierIsNativeEverywhere() async throws { func freeTierBindsGitOnAGitBoard() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
@@ -1033,16 +1037,20 @@ struct GitUndoBindingTests {
let ref = try openBoard(model, at: fixture.root) let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
#expect(session.git == nil, "the free tier composes no git state at all") // **PIVOT 2026-08-07** (12-editions.md), and the exact inversion of what this test used to
#expect(session.history is NativeHistoryProvider) // pin: `session.git == nil` and the native stack, because the free tier composed no git state
#expect(session.undoManager.canUndo == false, "empty, not absent") // at all and this board's `.git` was inert. It is live now, in every tier.
#expect(session.git != nil, "every session composes a git state")
#expect(session.gitMode == .git)
#expect(session.history is GitHistoryProvider)
#expect(session.tier == .free, "recorded, and consulted by nothing on this path")
} }
@Test("Pro on a git board binds the git provider") @Test("Pro on a git board binds the git provider")
func proOnAGitBoardBindsGit() async throws { func proOnAGitBoardBindsGit() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
@@ -1055,8 +1063,8 @@ struct GitUndoBindingTests {
#expect(session.history is GitHistoryProvider) #expect(session.history is GitHistoryProvider)
} }
@Test("Pro on a mode-none board binds the native stack — an upgrade never removes undo") @Test("A mode-none board binds the native stack — opt-in is what keeps it there")
func proOnAPlainBoardBindsTheNativeStack() throws { func aPlainBoardBindsTheNativeStack() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
@@ -1066,14 +1074,16 @@ struct GitUndoBindingTests {
let ref = try openBoard(model, at: fixture.root) let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
// The board is Pro's it composed a git state and detected a mode and the mode is what // The board composed a git state and detected a mode, and the mode is what chose the
// chose the substrate: "gitless boards bind the native undo stack in every tier" (12). // substrate: "gitless boards bind the native undo stack in every tier" (12). Since PIVOT
#expect(session.git != nil, "Pro composes a git state even where there is no repository") // 2026-08-07 the *only* thing keeping a board on this row is that nobody added git to it
// opt-in per board is exactly what the pivot left standing (06 Rules; 13's header).
#expect(session.git != nil, "a git state is composed even where there is no repository")
#expect(session.gitMode == .none) #expect(session.gitMode == .none)
#expect(session.history is NativeHistoryProvider) #expect(session.history is NativeHistoryProvider)
// And it is a *working* stack, not a placeholder: the same command surface a free-tier // And it is a *working* stack, not a placeholder: the same command surface every other
// session gets, which is what "a user subscribing relearns nothing" means here. // gitless board gets, which is what "a user relearns nothing" means here.
let log = StepLog() let log = StepLog()
#expect(session.undoManager.canUndo == false, "empty, not absent") #expect(session.undoManager.canUndo == false, "empty, not absent")
session.history?.register(log.step("Move Card")) session.history?.register(log.step("Move Card"))
@@ -1083,8 +1093,8 @@ struct GitUndoBindingTests {
#expect(log.crossings == ["undo Move Card"]) #expect(log.crossings == ["undo Move Card"])
} }
@Test("Pro on a repo-nested board binds the native stack — the no-undo case is retired") @Test("A repo-nested board binds the native stack — the no-undo case is retired")
func proOnARepoNestedBoardBindsTheNativeStack() throws { func aRepoNestedBoardBindsTheNativeStack() throws {
let outer = try WriterFixture() let outer = try WriterFixture()
defer { outer.tearDown() } defer { outer.tearDown() }
// A repository at the *parent*, with the board inside it the nested posture. // A repository at the *parent*, with the board inside it the nested posture.
@@ -1101,15 +1111,15 @@ struct GitUndoBindingTests {
let ref = try openBoard(model, at: boardRoot) let ref = try openBoard(model, at: boardRoot)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
// The mode is detected Pro walks the ancestors and it no longer decides Z: "Pro binds it // The mode is detected the ancestor walk runs and it no longer decides Z: "mode-none
// on mode-none **and repo-nested** boards alike what repo-nested denies is app-managed // **and repo-nested** boards alike bind it what repo-nested denies is app-managed history,
// history, never Z" (13's header, re-ruled 2026-07-31). // never Z" (13's header, re-ruled 2026-07-31).
#expect(session.gitMode == .repoNested) #expect(session.gitMode == .repoNested)
#expect(session.history is NativeHistoryProvider) #expect(session.history is NativeHistoryProvider)
#expect(session.undoManager.canUndo == false, "empty, not absent") #expect(session.undoManager.canUndo == false, "empty, not absent")
// A working stack, exactly as the free tier's on this same board which is the whole point // A working stack, exactly as on the same board under any other tier see
// of the re-ruling: the two tiers answer alike here now. // `freeTierOnARepoNestedBoardIsNativeToo`, which now detects the same mode this one does.
let log = StepLog() let log = StepLog()
session.history?.register(log.step("Move Card")) session.history?.register(log.step("Move Card"))
#expect(session.undoManager.canUndo) #expect(session.undoManager.canUndo)
@@ -1142,12 +1152,12 @@ struct GitUndoBindingTests {
defer { tearDown() } defer { tearDown() }
let git = HistoryStore(boardRoot: fixture.root, mode: .unverifiable, ledger: EchoLedger()) let git = HistoryStore(boardRoot: fixture.root, mode: .unverifiable, ledger: EchoLedger())
let provider = model.makeHistoryProvider(store, .pro, git) let provider = model.makeHistoryProvider(store, git)
#expect(provider is NativeHistoryProvider) #expect(provider is NativeHistoryProvider)
} }
@Test("The free tier's repo-nested board still binds the native stack — it never detects one") @Test("The free tier's repo-nested board binds the native stack too — now that it detects one")
func freeTierOnARepoNestedBoardIsNativeToo() throws { func freeTierOnARepoNestedBoardIsNativeToo() throws {
let outer = try WriterFixture() let outer = try WriterFixture()
defer { outer.tearDown() } defer { outer.tearDown() }
@@ -1164,15 +1174,17 @@ struct GitUndoBindingTests {
let ref = try openBoard(model, at: boardRoot) let ref = try openBoard(model, at: boardRoot)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
// 12 The free tier and `.git` names this board by hand: "opening a board that has one // The answer is unchanged and the *reason* is new. Before PIVOT 2026-08-07 the free tier
// a repo-nested board works normally native undo runs". The tier composes no git state // composed no git state, so nothing here could tell this board from a plain one and native
// at all, so there is nothing here that *could* tell this board from a plain one the inert // undo followed by default "opening a board that has one a repo-nested board works
// posture made structural rather than remembered. // normally native undo runs" (12 The free tier and `.git`, now retired). Detection runs
#expect(session.git == nil) // in every tier now, sees the enclosing repository, and binds native anyway, because what
#expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode") // repo-nested denies is app-managed history and never Z (13's header, re-ruled 2026-07-31).
#expect(session.git != nil, "detection runs here now")
#expect(session.gitMode == .repoNested, "and it sees the enclosing repository")
#expect(session.history is NativeHistoryProvider) #expect(session.history is NativeHistoryProvider)
// Pro on this same board now answers identically (above) the two tiers agree, which is what // The same board under `.pro` answers identically (above) one matrix, no tier column.
// the re-ruling of 2026-07-31 bought: an upgrade never removes undo from *any* board. #expect(session.git?.committer == nil, "and the enclosing repository is still left alone")
} }
@Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded") @Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded")
@@ -1265,7 +1277,7 @@ struct GitOperationFailureBannerTests {
func aFailedRestorePostsAFailureRow() async throws { func aFailedRestorePostsAFailureRow() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
@@ -1302,7 +1314,7 @@ struct GitOperationFailureBannerTests {
func theSwitcherReportsFailureAndRecoveryDifferently() async throws { func theSwitcherReportsFailureAndRecoveryDifferently() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
@@ -1336,7 +1348,7 @@ struct GitUndoRoutingTests {
func textEditingWins() async throws { func textEditingWins() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
+17 -12
View File
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
defer { tearDown() } defer { tearDown() }
let bound = FakeHistoryProvider() let bound = FakeHistoryProvider()
model.makeHistoryProvider = { _, _, _ in bound } model.makeHistoryProvider = { _, _ in bound }
let ref = try openBoard(model, at: fixture.root) let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
@@ -597,28 +597,30 @@ struct BoardSessionHistoryTests {
#expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound") #expect(bound.undoCount == 1, "the window's manager reaches whatever the root bound")
} }
@Test("The tier reaches the composition root, and the session records what it composed under") @Test("The session records the tier it composed under — and the provider root never sees it")
func theTierIsAComposedFact() throws { func theTierIsARecordedFact() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
defer { tearDown() } defer { tearDown() }
// 12-editions.md The entitlement: the tier is read at composition, once, and handed to the // 12-editions.md The entitlement: the tier is read at composition, once, and recorded.
// root that binds the provider. What this pins is that the *argument arrives* the matrix it // **PIVOT 2026-08-07**: it is no longer *handed* anywhere. This test used to pin the argument
// feeds is `GitUndoBindingTests`', since after the re-ruling of 2026-07-31 the tier decides // arriving at `makeHistoryProvider` (`{ _, tier, _ in }`); the parameter is gone, so what is
// only whether a git state is composed at all, and the mode decides the substrate. // pinned now is the pair of facts that replaced it the seam takes a store and a git state
var seen: [Tier] = [] // 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`'.
var seen = 0
model.currentTier = { .pro } model.currentTier = { .pro }
model.makeHistoryProvider = { _, tier, _ in model.makeHistoryProvider = { _, _ in
seen.append(tier) seen += 1
return NativeHistoryProvider() return NativeHistoryProvider()
} }
let ref = try openBoard(model, at: fixture.root) let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
#expect(seen == [.pro]) #expect(seen == 1, "called once per board, with no tier to consult")
#expect(session.tier == .pro, "the session carries the fact it composed under") #expect(session.tier == .pro, "the session carries the fact it composed under")
} }
@@ -635,7 +637,10 @@ struct BoardSessionHistoryTests {
// The subscription lapses mid-session the one thing 12 The entitlement says must not // The subscription lapses mid-session the one thing 12 The entitlement says must not
// disturb a board that is already on screen: "an open board finishes with the provider it // disturb a board that is already on screen: "an open board finishes with the provider it
// composed; the next open composes the native stack over inert `.git`". // composed". (Its second clause "the next open composes the native stack over inert
// `.git`" went with PIVOT 2026-08-07: the next open composes the git stack too. What is
// pinned here is the recorded fact's immutability, which is what makes the first clause
// true whatever a future split gates.)
model.currentTier = { .free } model.currentTier = { .free }
#expect(try #require(model.session(for: ref)).tier == .pro) #expect(try #require(model.session(for: ref)).tier == .pro)
+62 -52
View File
@@ -4,8 +4,9 @@ import Testing
@testable import Kanban @testable import Kanban
/// **The board's git state** (06-history-undo.md Rules; 02-architecture.md Components /// **The board's git state** (06-history-undo.md Rules; 02-architecture.md Components
/// HistoryStore) composed under the tier, detected at open, and changed afterwards by exactly /// HistoryStore) composed for every session, detected at open, and changed afterwards by
/// one thing. /// exactly one thing. It was "composed under the tier" until PIVOT 2026-08-07 (12-editions.md git
/// left the paywall); the tier axis is gone from composition and from everything below it.
/// ///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled /// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with /// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with
@@ -40,8 +41,8 @@ private func commitEverything(at boardRoot: URL, message: String) throws {
_ = try repository.commit(message: message) _ = try repository.commit(message: message)
} }
/// Bytes and mtimes of everything under a subtree `InertGitTests`' instrument, in the shape this /// Bytes and mtimes of everything under a subtree `UntouchedGitTests`' instrument, in the shape
/// file needs it: what proves that *reading* a board's mode touched nothing. /// this file needs it: what proves that *reading* a board's mode touched nothing.
private struct SubtreeEntry: Equatable { private struct SubtreeEntry: Equatable {
let path: String let path: String
let data: Data? let data: Data?
@@ -102,33 +103,37 @@ private func plantSHA256Repository(in fixture: WriterFixture) throws {
// MARK: - Composition // MARK: - Composition
@MainActor @MainActor
@Suite("HistoryStore ▸ composition and the tier gate") @Suite("HistoryStore ▸ composition and open-time detection")
struct HistoryStoreCompositionTests { struct HistoryStoreCompositionTests {
@Test("The free tier composes no git state at all, on any board") @Test("Composition takes no tier: a board carrying a repository opens in git mode, full stop")
func theFreeTierComposesNothing() throws { func compositionIsUnconditional() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try plantGitDirectory(in: fixture) try plantGitDirectory(in: fixture)
// Not "mode none on a git board" *nothing*. With no object there is no path by which a // **PIVOT 2026-08-07** (12-editions.md git left the paywall). This was the tier gate's own
// free-tier session could read history, commit, or touch `.git` (12-editions.md The free // test, and it read the other way: `compose(boardRoot:tier: .free)` answered `nil`, so a free
// tier and `.git`, whose byte-level half is `InertGitTests`). // session had no git state to consult and never stat'ed a `.git` (the inert posture, made
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil) // structural). The gate is gone the parameter with it and detection now runs on this
// board for every session there is, which is what the assertion below says by having no tier
// to name.
let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git, "the `.git` at the root is live, not inert")
} }
@Test("Pro on a plain board is mode none — and opening one never creates a repository") @Test("A plain board is mode none — and opening one never creates a repository")
func proOnAPlainBoardIsModeNone() throws { func aPlainBoardIsModeNone() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none) #expect(git.mode == .none)
// "No silent auto-init, ever" (06 Rules) the deliberate pivot from the pathfinder, which // "No silent auto-init, ever" (06 Rules) the deliberate pivot from the pathfinder, which
// initialized a repository under every board it opened. Composing twice is the whole test: // initialized a repository under every board it opened. Composing twice is the whole test:
// two opens, no repository. // two opens, no repository.
_ = HistoryStore.compose(boardRoot: fixture.root, tier: .pro) _ = HistoryStore.compose(boardRoot: fixture.root)
#expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in") #expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in")
} }
@@ -139,14 +144,14 @@ struct HistoryStoreCompositionTests {
// The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own // The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own
// add-git in a previous session), and the repository is simply there. // add-git in a previous session), and the repository is simply there.
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit()) #expect(await first.addGit())
let afterInit = try #require(GitRepository.headCommit(at: fixture.root)) let afterInit = try #require(GitRepository.headCommit(at: fixture.root))
let before = try snapshotGitDirectory(fixture.root) let before = try snapshotGitDirectory(fixture.root)
// The next open. Adoption is not init: no dialog, no confirmation, no second step the mode // The next open. Adoption is not init: no dialog, no confirmation, no second step the mode
// is simply what the filesystem says, and it says git. // is simply what the filesystem says, and it says git.
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let second = HistoryStore.compose(boardRoot: fixture.root)
#expect(second.mode == .git) #expect(second.mode == .git)
// And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes. // And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes.
@@ -165,7 +170,7 @@ struct HistoryStoreCompositionTests {
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(git.mode == .repoNested) #expect(git.mode == .repoNested)
} }
@@ -174,7 +179,7 @@ struct HistoryStoreCompositionTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .none) #expect(git.mode == .none)
// What a `git init` in a terminal under an open board does which is nothing, until the // What a `git init` in a terminal under an open board does which is nothing, until the
@@ -183,7 +188,7 @@ struct HistoryStoreCompositionTests {
try plantGitDirectory(in: fixture) try plantGitDirectory(in: fixture)
#expect(git.mode == .none, "the open session keeps the mode it composed with") #expect(git.mode == .none, "the open session keeps the mode it composed with")
let nextOpen = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let nextOpen = HistoryStore.compose(boardRoot: fixture.root)
#expect(nextOpen.mode == .git, "and the next open reflects what it finds") #expect(nextOpen.mode == .git, "and the next open reflects what it finds")
} }
} }
@@ -207,11 +212,11 @@ struct HistoryStoreUnreadableRepositoryTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit()) #expect(await first.addGit())
// The next open, which is where the probe actually runs. // The next open, which is where the probe actually runs.
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git) #expect(git.mode == .git)
#expect(!git.isRepositoryUnreadable) #expect(!git.isRepositoryUnreadable)
#expect(git.committer?.pause == nil) #expect(git.committer?.pause == nil)
@@ -226,7 +231,7 @@ struct HistoryStoreUnreadableRepositoryTests {
// filesystem one question, and not a repository at all to libgit2. // filesystem one question, and not a repository at all to libgit2.
try plantGitDirectory(in: fixture) try plantGitDirectory(in: fixture)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
// **Never a fall to mode none** "detection is presence-shaped a corrupt or unopenable // **Never a fall to mode none** "detection is presence-shaped a corrupt or unopenable
// repo never falls to mode none", which is what keeps add-git from ever being offered // repo never falls to mode none", which is what keeps add-git from ever being offered
@@ -251,7 +256,7 @@ struct HistoryStoreUnreadableRepositoryTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
try plantDanglingGitPointer(in: fixture) try plantDanglingGitPointer(in: fixture)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence") #expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence")
#expect(git.isRepositoryUnreadable) #expect(git.isRepositoryUnreadable)
} }
@@ -262,7 +267,7 @@ struct HistoryStoreUnreadableRepositoryTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
try plantSHA256Repository(in: fixture) try plantSHA256Repository(in: fixture)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git) #expect(git.mode == .git)
// 06 Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the // 06 Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the
// corrupt-repo loud-failure path never a silent fall to mode-none". // corrupt-repo loud-failure path never a silent fall to mode-none".
@@ -276,7 +281,7 @@ struct HistoryStoreUnreadableRepositoryTests {
try plantGitDirectory(in: fixture) try plantGitDirectory(in: fixture)
let before = try snapshotGitDirectory(fixture.root) let before = try snapshotGitDirectory(fixture.root)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.isRepositoryUnreadable) #expect(git.isRepositoryUnreadable)
// "Lanework leaves the repository untouched" the same bytes and the same mtimes, on the // "Lanework leaves the repository untouched" the same bytes and the same mtimes, on the
@@ -290,7 +295,7 @@ struct HistoryStoreUnreadableRepositoryTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
try plantGitDirectory(in: fixture) try plantGitDirectory(in: fixture)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
await git.writeIdentity(name: "Ada", email: "[email protected]") await git.writeIdentity(name: "Ada", email: "[email protected]")
#expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open") #expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open")
@@ -334,7 +339,7 @@ struct HistoryStoreAddGitTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
#expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install") #expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install")
@@ -358,7 +363,7 @@ struct HistoryStoreAddGitTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// The one file the app ever writes into a board because of git, and the one moment it writes // The one file the app ever writes into a board because of git, and the one moment it writes
@@ -374,7 +379,7 @@ struct HistoryStoreAddGitTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
let derived = GitIdentity.derivedDefault() let derived = GitIdentity.derivedDefault()
@@ -388,7 +393,7 @@ struct HistoryStoreAddGitTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// Deterministic rather than inherited: libgit2's initial branch comes from an // Deterministic rather than inherited: libgit2's initial branch comes from an
@@ -409,7 +414,7 @@ struct HistoryStoreAddGitTests {
// (06 Rules Abnormal repo states: "an unborn HEAD is normal git mode"). // (06 Rules Abnormal repo states: "an unborn HEAD is normal git mode").
_ = try Repository.create(at: fixture.root) _ = try Repository.create(at: fixture.root)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git) #expect(git.mode == .git)
#expect(GitRepository.branchName(at: fixture.root) != nil) #expect(GitRepository.branchName(at: fixture.root) != nil)
#expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine") #expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine")
@@ -420,11 +425,11 @@ struct HistoryStoreAddGitTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let first = HistoryStore.compose(boardRoot: fixture.root)
#expect(await first.addGit()) #expect(await first.addGit())
let head = try #require(GitRepository.headCommit(at: fixture.root)) let head = try #require(GitRepository.headCommit(at: fixture.root))
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let second = HistoryStore.compose(boardRoot: fixture.root)
#expect(await second.addGit() == false, "there is nothing to add") #expect(await second.addGit() == false, "there is nothing to add")
#expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized") #expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized")
} }
@@ -439,7 +444,7 @@ struct HistoryStoreAddGitTests {
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(await git.addGit() == false) #expect(await git.addGit() == false)
#expect(git.mode == .repoNested) #expect(git.mode == .repoNested)
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path)) #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
@@ -475,7 +480,7 @@ struct HistoryStoreAddGitTests {
// Composed while the enclosing folder is still a plain one: the store's mode is `none`, and // Composed while the enclosing folder is still a plain one: the store's mode is `none`, and
// that is the reading that goes stale. // that is the reading that goes stale.
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(git.mode == .none) #expect(git.mode == .none)
// A terminal `git init` one level up, after the detection the store is holding. // A terminal `git init` one level up, after the detection the store is holding.
@@ -530,7 +535,7 @@ struct HistoryStoreAddGitTests {
// Mode is read once, at composition so a store composed before a `.git` appeared still says // Mode is read once, at composition so a store composed before a `.git` appeared still says
// `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the // `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the
// question is where the answer lands. // question is where the answer lands.
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
try plantGitDirectory(in: fixture) try plantGitDirectory(in: fixture)
var banners: [String] = [] var banners: [String] = []
git.reportFailure = { banners.append($0.message) } git.reportFailure = { banners.append($0.message) }
@@ -563,7 +568,7 @@ struct GitPathHistoryTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// A second card, arriving in a later commit the whole point of the rung. // A second card, arriving in a later commit the whole point of the rung.
@@ -587,7 +592,7 @@ struct GitPathHistoryTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let plain = HistoryStore.compose(boardRoot: fixture.root)
#expect(plain.identityHistoryRanker == nil, "mode none injects nothing") #expect(plain.identityHistoryRanker == nil, "mode none injects nothing")
// And the free tier has no `HistoryStore` to ask in the first place pinned in the // And the free tier has no `HistoryStore` to ask in the first place pinned in the
@@ -600,7 +605,7 @@ struct GitPathHistoryTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// The same identity, arriving later in a second lane the copy the duplicate rule is about. // The same identity, arriving later in a second lane the copy the duplicate rule is about.
@@ -648,32 +653,37 @@ private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef
@Suite("Board sessions ▸ the git state they compose") @Suite("Board sessions ▸ the git state they compose")
struct BoardSessionGitTests { struct BoardSessionGitTests {
@Test("A free-tier session carries no git state, even on a board that has a repository") @Test("A free-tier session composes a git state too, and detects the repository it finds")
func freeSessionsCarryNoGitState() throws { func freeSessionsComposeAGitStateAsWell() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try plantGitDirectory(in: fixture) try plantGitDirectory(in: fixture)
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
defer { tearDown() } 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 } model.currentTier = { .free }
let ref = try openBoard(model, at: fixture.root) let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
#expect(session.git == nil) #expect(session.git != nil)
#expect(session.gitMode == .none, "the free tier ships exactly one mode") #expect(session.gitMode == .git, "the mode is the disk's answer, not the tier's")
#expect(session.store.makeIdentityHistoryRanker == nil, "and injects nothing into the loader") #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 Pro session on a git board composes git mode and wires the loader's ranker") @Test("A session on a git board composes git mode and wires the loader's ranker")
func proSessionsCarryTheDetectedMode() async throws { func sessionsCarryTheDetectedMode() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
defer { tearDown() } defer { tearDown() }
// A real repository, so the ranker has something to read. // A real repository, so the ranker has something to read.
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
model.currentTier = { .pro } model.currentTier = { .pro }
@@ -685,13 +695,13 @@ struct BoardSessionGitTests {
let ranker = try #require(provider()) let ranker = try #require(provider())
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil) #expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
// The provider binding arrived with the undo/redo card: a Pro session on a git board binds // The provider binding arrived with the undo/redo card: a session on a git board binds the
// the git substrate over exactly this mode (12-editions.md The provider seam). // git substrate over exactly this mode (12-editions.md The provider seam).
#expect(session.history is GitHistoryProvider) #expect(session.history is GitHistoryProvider)
} }
@Test("A Pro session on a plain board is mode none and injects nothing") @Test("A session on a plain board is mode none and injects nothing")
func proSessionsOnPlainBoardsInjectNothing() throws { func sessionsOnPlainBoardsInjectNothing() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let (model, tearDown) = try makeModel() let (model, tearDown) = try makeModel()
+40 -24
View File
@@ -2,24 +2,37 @@ import Foundation
import Testing import Testing
@testable import Kanban @testable import Kanban
/// **The inert-`.git` posture, stated against real bytes on disk** (12-editions.md The free tier /// **The file layer never touches `.git`, stated against real bytes on disk** every write and every
/// and `.git`): "any `.git` is inert opening a board that has one works normally, but the app /// load the app makes leaves the whole `.git` subtree byte- and mtime-identical.
/// never reads history, never commits, never touches `.git` in any way."
/// ///
/// It is unconditional app behaviour, not a build-time posture: the app that ships is the only app /// ### What this file used to be, and what it is now
/// there is (12 The target, re-ruled 2026-07-30), and it runs this way for everyone without a
/// subscription and for everyone whose subscription has lapsed "unsubscribed and lapsed are one
/// state". So these tests assert what `Kanban` does, full stop; nothing here is conditioned on a
/// tier, and the git provider that will one day touch `.git` composes over its own boards.
/// ///
/// Two halves of that posture are already pinned elsewhere and are referenced, not repeated: /// It was written as the **inert-`.git` posture**'s byte-level proof (12-editions.md The free tier
/// `FolderWatcherTests` ".git filtering" proves the watcher ignores churn under a `.git` at any /// and `.git`, as it then read: "any `.git` is inert the app never reads history, never commits,
/// depth, and `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a /// never touches `.git` in any way"). **PIVOT 2026-08-07** retired that posture outright git left
/// `.git` at the board root loads as an ordinary stray with no warning. Both are *input* claims: /// the paywall, so a `.git` at a board root is *live* in every tier, detection runs at every open,
/// what the app does with events and entries it is handed. /// and the auto-committer writes into exactly the directory this file's fixture plants.
/// ///
/// This file states the **output** claim, which no existing test covers, and which is the one the /// The tests survive the retirement unchanged because none of them ever composed a `HistoryStore` or
/// posture has to be able to demonstrate: a full session of ordinary editing leaves every byte and /// asserted anything about a tier: they drive `BoardWriter` and `BoardLoader` directly, and what they
/// pin is that **those layers are git-agnostic**. That claim is not only still true, it is now
/// load-bearing in two places the retired posture never reached:
///
/// - **A board nobody added git to** (mode `none`) is the one board the app manages no git for, for
/// good git stays opt-in per board (06 Rules; 13-native-undo.md's header read through the
/// pivot). Nothing but this layer runs on such a board, so this layer's indifference *is* the
/// whole promise.
/// - **A `.git` the app does not manage** a repo-nested board's ancestor, a clone dropped inside a
/// card folder (which the fixture below plants deliberately) is left strictly alone by the same
/// indifference, whatever the board's own mode or the user's tier.
///
/// Two neighbouring claims are pinned elsewhere and are referenced, not repeated: `FolderWatcherTests`
/// ".git filtering" proves the watcher ignores churn under a `.git` at any depth, and
/// `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a `.git` at the
/// board root loads as an ordinary stray with no warning. Both are *input* claims: what the app does
/// with events and entries it is handed.
///
/// This file states the **output** claim: a full session of ordinary editing leaves every byte and
/// every mtime under `.git` exactly as it found them. It is asserted the only way /// every mtime under `.git` exactly as it found them. It is asserted the only way
/// that is worth anything by snapshotting the whole `.git` subtree from the filesystem before /// that is worth anything by snapshotting the whole `.git` subtree from the filesystem before
/// the edits and re-reading it afterwards, never through the app's own read path /// the edits and re-reading it afterwards, never through the app's own read path
@@ -109,9 +122,11 @@ private func makeGitDirectory(in fixture: WriterFixture, under parent: String) t
} }
/// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root /// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root
/// **and** a second one nested inside a card folder (12: "any `.git` is inert", not just the /// **and** a second one nested inside a card folder. Both, deliberately: the nested one is a `.git`
/// root's own a repo-nested board or a clone dropped inside a card is the same promise). /// the app never manages under any ruling (a clone dropped inside a card), and the root one is the
private struct InertGitBoard { /// case the pivot changed it is a live repository to the *git* layer now, and still nothing at all
/// to the file layer these tests drive.
private struct UntouchedGitBoard {
let fixture: WriterFixture let fixture: WriterFixture
let laneA: String let laneA: String
let laneB: String let laneB: String
@@ -144,12 +159,13 @@ private struct InertGitBoard {
func tearDown() { fixture.tearDown() } func tearDown() { fixture.tearDown() }
} }
// MARK: - The posture // MARK: - The claim
struct InertGitTests { @Suite("The file layer never touches `.git`")
struct UntouchedGitTests {
@Test("A full session of ordinary edits leaves every byte and mtime under .git untouched") @Test("A full session of ordinary edits leaves every byte and mtime under .git untouched")
func anEditingSessionNeverTouchesGit() throws { func anEditingSessionNeverTouchesGit() throws {
let board = try InertGitBoard() let board = try UntouchedGitBoard()
defer { board.tearDown() } defer { board.tearDown() }
let before = try snapshotSubtree(board.root, ".git") let before = try snapshotSubtree(board.root, ".git")
@@ -205,7 +221,7 @@ struct InertGitTests {
@Test("The board loads and renders normally with a .git at its root, without a warning") @Test("The board loads and renders normally with a .git at its root, without a warning")
func aGitBearingBoardLoadsLikeAnyOther() throws { func aGitBearingBoardLoadsLikeAnyOther() throws {
let board = try InertGitBoard() let board = try UntouchedGitBoard()
defer { board.tearDown() } defer { board.tearDown() }
let before = try snapshotSubtree(board.root, ".git") let before = try snapshotSubtree(board.root, ".git")
@@ -223,7 +239,7 @@ struct InertGitTests {
@Test("A nested .git rides along a card move byte- and mtime-verbatim") @Test("A nested .git rides along a card move byte- and mtime-verbatim")
func aNestedRepositorySurvivesACardMove() throws { func aNestedRepositorySurvivesACardMove() throws {
let board = try InertGitBoard() let board = try UntouchedGitBoard()
defer { board.tearDown() } defer { board.tearDown() }
let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
@@ -250,7 +266,7 @@ struct InertGitTests {
@Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone") @Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone")
func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws { func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws {
let board = try InertGitBoard() let board = try UntouchedGitBoard()
defer { board.tearDown() } defer { board.tearDown() }
let nested = "\(board.laneA)/\(board.cardWithRepo)/.git" let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
+27 -35
View File
@@ -49,7 +49,7 @@ private func churn(_ fixture: WriterFixture, commits: Int) throws {
// MARK: - Filesystem instruments // MARK: - Filesystem instruments
/// One entry under a subtree: path, bytes (nil for directories), and mtime `InertGitTests`' /// One entry under a subtree: path, bytes (nil for directories), and mtime `UntouchedGitTests`'
/// instrument. Bytes alone would pass a rewrite with identical content; the mtime is the assertion /// instrument. Bytes alone would pass a rewrite with identical content; the mtime is the assertion
/// that nothing opened the file for writing at all. /// that nothing opened the file for writing at all.
private struct SubtreeEntry: Equatable, CustomStringConvertible { private struct SubtreeEntry: Equatable, CustomStringConvertible {
@@ -169,7 +169,7 @@ struct GitignoreSeedTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// The file, and the whole of the file the one seed text, shared with board creation and // The file, and the whole of the file the one seed text, shared with board creation and
@@ -192,7 +192,7 @@ struct GitignoreSeedTests {
try fixture.file(".DS_Store", Data([0x00, 0x01, 0x42])) try fixture.file(".DS_Store", Data([0x00, 0x01, 0x42]))
try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x43])) try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x43]))
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// Not "removed from history later" never in it. The app has no history-rewriting operation // Not "removed from history later" never in it. The app has no history-rewriting operation
@@ -211,7 +211,7 @@ struct GitignoreSeedTests {
try fixture.file(".gitignore", mine) try fixture.file(".gitignore", mine)
let before = try snapshot(fixture.root, ".gitignore") let before = try snapshot(fixture.root, ".gitignore")
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// Not merged, not appended to, not reordered and not even opened for writing, which is // Not merged, not appended to, not reordered and not even opened for writing, which is
@@ -226,7 +226,7 @@ struct GitignoreSeedTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// The user edits it including deleting the line the app seeded, which is their business. // The user edits it including deleting the line the app seeded, which is their business.
@@ -251,7 +251,7 @@ struct GitignoreSeedTests {
func theCommitterObeysTheSameFile() async throws { func theCommitterObeysTheSameFile() async throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
// The user fine-tunes their own noise definition, which is exactly what the file is for. // The user fine-tunes their own noise definition, which is exactly what the file is for.
@@ -276,7 +276,7 @@ struct GitignoreSeedTests {
// `git init` run outside the app, exactly the shape a cloned or hand-inited board arrives in. // `git init` run outside the app, exactly the shape a cloned or hand-inited board arrives in.
_ = try Repository.create(at: fixture.root) _ = try Repository.create(at: fixture.root)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(git.mode == .git) #expect(git.mode == .git)
#expect(!fixture.exists(".gitignore"), "composing history is not a write") #expect(!fixture.exists(".gitignore"), "composing history is not a write")
} }
@@ -294,7 +294,7 @@ struct GitignoreSeedTests {
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) let git = HistoryStore.compose(boardRoot: boardRoot)
#expect(await git.addGit() == false) #expect(await git.addGit() == false)
#expect(git.mode == .repoNested) #expect(git.mode == .repoNested)
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".gitignore").path)) #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".gitignore").path))
@@ -485,7 +485,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
try churn(fixture, commits: 4) try churn(fixture, commits: 4)
@@ -532,7 +532,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
try churn(fixture, commits: 3) try churn(fixture, commits: 3)
@@ -554,7 +554,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
try churn(fixture, commits: 3) try churn(fixture, commits: 3)
@@ -574,7 +574,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
let before = try snapshot(fixture.root, ".git") let before = try snapshot(fixture.root, ".git")
@@ -590,7 +590,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
try churn(fixture, commits: 3) try churn(fixture, commits: 3)
@@ -604,7 +604,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
try churn(fixture, commits: 3) try churn(fixture, commits: 3)
@@ -624,7 +624,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
try churn(fixture, commits: 3) try churn(fixture, commits: 3)
@@ -643,7 +643,7 @@ struct GitHousekeepingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
let cardPath = "\(Ident.lane1)/\(Ident.card1)/index.md" let cardPath = "\(Ident.lane1)/\(Ident.card1)/index.md"
@@ -694,27 +694,19 @@ struct GitHousekeeperSchedulingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let plain = HistoryStore.compose(boardRoot: fixture.root)
#expect(plain.housekeeper == nil, "no repository, nothing to maintain") #expect(plain.housekeeper == nil, "no repository, nothing to maintain")
#expect(await plain.addGit()) #expect(await plain.addGit())
#expect(plain.housekeeper != nil, "the mid-session flip maintains itself like any git board") #expect(plain.housekeeper != nil, "the mid-session flip maintains itself like any git board")
let reopened = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let reopened = HistoryStore.compose(boardRoot: fixture.root)
#expect(reopened.housekeeper != nil) #expect(reopened.housekeeper != nil)
}
@Test("The free tier has no housekeeper anywhere, because it has no git state at all") // The mode is the *whole* condition. A companion test used to sit beside this one pinning
func theFreeTierMaintainsNothing() async throws { // that the free tier maintained nothing anywhere structurally, since `compose` answered
let fixture = try makeBoard() // `nil` off Pro and PIVOT 2026-08-07 (12-editions.md) retired both the gate and the claim:
defer { fixture.tearDown() } // there is no tier to compose under, so this suite's one axis is the one above.
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await seed.addGit())
// Structural, not conditional: with no `HistoryStore` there is no housekeeper to disable and
// no code path that could reach one (12-editions.md The free tier and `.git`).
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
} }
@Test("A commit in flight defers the pass entirely — it is never retried") @Test("A commit in flight defers the pass entirely — it is never retried")
@@ -722,7 +714,7 @@ struct GitHousekeeperSchedulingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
try churn(fixture, commits: 3) try churn(fixture, commits: 3)
@@ -749,11 +741,11 @@ struct GitHousekeeperSchedulingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit()) #expect(await seed.addGit())
try churn(fixture, commits: 3) try churn(fixture, commits: 3)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
let housekeeper = try #require(git.housekeeper) let housekeeper = try #require(git.housekeeper)
housekeeper.threshold = 1 housekeeper.threshold = 1
housekeeper.delay = .milliseconds(20) housekeeper.delay = .milliseconds(20)
@@ -768,7 +760,7 @@ struct GitHousekeeperSchedulingTests {
// Teardown cancels an armed one, so a closed board's maintenance cannot fire against a store // Teardown cancels an armed one, so a closed board's maintenance cannot fire against a store
// that has gone. // that has gone.
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let second = HistoryStore.compose(boardRoot: fixture.root)
let secondKeeper = try #require(second.housekeeper) let secondKeeper = try #require(second.housekeeper)
secondKeeper.threshold = 1 secondKeeper.threshold = 1
secondKeeper.delay = .milliseconds(200) secondKeeper.delay = .milliseconds(200)
@@ -783,7 +775,7 @@ struct GitHousekeeperSchedulingTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) let git = HistoryStore.compose(boardRoot: fixture.root)
#expect(await git.addGit()) #expect(await git.addGit())
let housekeeper = try #require(git.housekeeper) let housekeeper = try #require(git.housekeeper)
+25 -29
View File
@@ -11,17 +11,16 @@ import XCTest
/// currently displayed UI** rather than a subtree, so each test's job is entirely navigation: get the /// currently displayed UI** rather than a subtree, so each test's job is entirely navigation: get the
/// surface on screen, then let the audit look at whatever is there. /// surface on screen, then let the audit look at whatever is there.
/// ///
/// ### The one surface this suite cannot reach, and why it is not fixable here /// ### The board settings sheet is reachable since the 2026-08-07 pivot
/// ///
/// The **board settings sheet** is Pro-only (03-board-ui.md Board settings sheet; /// The sheet was Pro-only, and this suite could not reach it: the fixture launch had no tier
/// `BoardSettingsAvailability`), and the fixture launch has no tier control: `AppModel.currentTier` /// control, and a `--ui-test-pro` launch argument was rejected as a subscription bypass anyone could
/// reads `ProEntitlement`, which reads StoreKit, and there is deliberately **no launch argument that /// type into Terminal (`UITestLaunch` ships in the app binary on purpose). Since the pivot
/// grants Pro**. Adding one would be worse than the gap it closed `UITestLaunch` is compiled into /// (12-editions.md PIVOT 2026-08-07 git left the paywall), `BoardSettingsAvailability` asks only
/// the shipping binary on purpose ("the thing being audited must be the app that ships"), so a /// the board's mode, the fixture board is mode `none`, and the sheet opens for the audit like any
/// `--ui-test-pro` flag would be a subscription bypass anyone could type into Terminal. So the sheet's /// other surface the deferral to the manual VoiceOver pass is retired with the gate. The
/// own audit is **deferred to the manual VoiceOver pass** (`AccessibilityVerification.md`), and what /// no-bypass objection stands as precedent for whatever the next split gates. See
/// is automated here instead is the posture that *is* reachable: the free tier's, where the row exists /// `testBoardSettingsSheetOpensFromTheMenuAndAudits`.
/// and is disabled. See `testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture`.
/// ///
/// ### No waiving /// ### No waiving
/// ///
@@ -203,22 +202,12 @@ final class AccessibilityAuditTests: XCTestCase {
// MARK: - The board settings sheet // MARK: - The board settings sheet
/// **The reachable half of the settings sheet's audit** the free tier's, which is the tier the /// **The settings sheet's audit**, reachable since the 2026-08-07 pivot (this file's header):
/// fixture launch runs in (see this file's header for why there is no Pro fixture and why adding /// the fixture board is mode `none`, `BoardSettingsAvailability` asks only the mode, so the menu
/// one would be a subscription bypass). /// row opens the sheet add-git's home (03-board-ui.md Board settings sheet) and the audit
/// /// looks at the sheet itself rather than deferring to the manual VoiceOver pass.
/// What it pins is the design's deliberate asymmetry between the sheet's two doors
/// (`BoardSettingsAvailability`): the **menu row stays visible and disabled** where the sheet
/// cannot exist standard menu validation, and 11-command-nexus.md's "a command absent here
/// doesn't exist" read in the other direction while the popover's row is *absent* there instead.
/// A row that vanished from the menu on the free tier would be the app hiding its own inventory;
/// one that was enabled would present an empty sheet.
///
/// The audit call is the board's, taken with the Board menu closed again: the sheet is what this
/// test cannot open, so auditing the surface that *is* on screen keeps the test honest about what
/// it checked.
@MainActor @MainActor
func testBoardSettingsRowIsPresentAndDisabledOnTheFreeFixture() throws { func testBoardSettingsSheetOpensFromTheMenuAndAudits() throws {
let app = XCUIApplication.launchedWithFixtureBoard() let app = XCUIApplication.launchedWithFixtureBoard()
let bar = app.menuBars.firstMatch let bar = app.menuBars.firstMatch
@@ -232,14 +221,21 @@ final class AccessibilityAuditTests: XCTestCase {
let row = bar.menuItems["Board Settings…"] let row = bar.menuItems["Board Settings…"]
XCTAssertTrue( XCTAssertTrue(
row.waitForExistence(timeout: XCUIApplication.uiTimeout), row.waitForExistence(timeout: XCUIApplication.uiTimeout),
"Board ▸ Board Settings… is missing — the row ships whether or not this board can open it" "Board ▸ Board Settings… is missing — the row ships in every mode that can host the sheet"
) )
XCTAssertFalse( XCTAssertTrue(
row.isEnabled, row.isEnabled,
"the free tier has no board settings sheet, so the row must be disabled rather than open an empty one" "a mode-none board hosts the sheet (add-git's home) — the pivot retired the tier gate that disabled this row"
) )
app.typeKey(.escape, modifierFlags: []) row.click()
let sheet = app.sheets.firstMatch
XCTAssertTrue(
sheet.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the Board Settings sheet did not present from its menu row"
)
try app.performAccessibilityAudit() try app.performAccessibilityAudit()
app.typeKey(.escape, modifierFlags: [])
} }
} }