Bind the undo provider to the board, not the tier

The 2026-07-31 re-ruling: gitless boards bind the native stack in every
tier — a Pro upgrade no longer removes undo from mode-none boards — and
Pro git boards bind the git provider; repo-nested stays the no-undo
case under Pro, while the free tier (which never runs detection) binds
native there too, per 12's inert posture. Add-git now swaps a live
native substrate mid-session: the in-flight stack is cleared with the
discarded provider, the git trail seeds from the root commit, and the
same BoardUndoManager instance keeps nil-target menu validation fresh.

2405 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 18:53:38 -04:00
parent 3bd6187b94
commit 2e4dde5655
9 changed files with 286 additions and 104 deletions
+93 -50
View File
@@ -278,11 +278,12 @@ 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 free tier binds the native stack a pair of step stacks over the inverses registered at /// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 12 The
/// the Writer boundary (13-native-undo.md, `NativeHistoryProvider`) and that is the default /// provider seam; 13-native-undo.md's header; 06 Rules): a board's substrate is decided by what
/// here because it is the substrate every tier can always fall back to; pro-m1 installs the git /// the board *is*, and the tier only decides whether git is on the table at all. The rule it
/// provider over this closure when the subscription is active (06-history-undo.md). Nothing in /// replaced bound the native stack free-tier-wide and nothing at all on Pro's gitless boards,
/// this file is tier-conditional; the tier difference is which closure the root installs. /// 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
@@ -290,30 +291,40 @@ 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 three answers, and the `nil` among them /// ### The three answers, and the one `nil` among them
/// ///
/// The closure is now the whole tier matrix, in three lines (12 Tier matrix; 06 Rules): /// - **No `HistoryStore` at all** the free tier, where `HistoryStore.compose` returns `nil`
/// /// without so much as a `stat`: the **native stack, on every board**. "The free tier binds it
/// - **Free** the native stack, on every board. There is no `HistoryStore` at all off Pro, so /// everywhere (any `.git` inert)" (13), and 12 The free tier and `.git` names the boards that
/// the absent git state *is* the tier test; nothing here reads a flag. /// covers by hand "a formerly-subscribed user's board, a 1.x board, a repo-nested board
/// - **Pro, mode `git`** the git provider: undo as forward restore commits over HEAD's /// native undo runs". The absent git state *is* the tier test; nothing here reads a flag.
/// first-parent ancestry. /// - **Mode `git`** (Pro only no other tier composes a git state) the git provider: undo as
/// - **Pro, mode `none` or `repoNested`** **no provider**. "A board without git has no /// forward restore commits over HEAD's first-parent ancestry (06).
/// undo/redo", and a repo-nested board is one the app "leaves strictly alone" so Edit /// - **Mode `none`** the **native stack**, exactly as in the free tier. "Gitless boards bind
/// Undo/Redo and the toolbar pair disable there ("the pair disabled on boards with no undo /// the native undo stack in every tier an upgrade never removes undo" (12).
/// provider in the composed tier under Pro, no-git and repo-nested boards, matching their menu /// - **Mode `repoNested`** **no provider**, the one no-undo case: a board inside somebody
/// items", 03-board-ui.md Toolbar). Not the native stack: on Pro, a mode-none board's edits /// else's repository is one the app "leaves strictly alone so they get **no undo**" (06
/// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second /// Rules), and an in-memory stack there would be the app-managed undo journal that rule
/// substrate the design does not have. /// forbids. Edit Undo/Redo and the toolbar pair disable there and nowhere else but under a
/// lock and on an empty stack (03-board-ui.md Toolbar Catalog).
/// ///
/// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession` /// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession`
/// composes the git state *before* the provider: which substrate a board gets is a question about /// composes the git state *before* the provider: which substrate a board gets is a question about
/// its repository, and a root that had to ask the disk itself would be a second detection. /// its repository, and a root that had to ask the disk itself would be a second detection.
///
/// ### Consumers
///
/// `beginSession` calls it once per board, and `bindHistoryProvider(for:)` calls it again on the
/// one event that changes a board's answer under an open session add-git's commanded flip,
/// which swaps `none`'s native stack for `git`'s trail.
@ObservationIgnored @ObservationIgnored
public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in
guard let git else { return NativeHistoryProvider() } guard let git else { return NativeHistoryProvider() }
guard git.mode == .git else { return nil } switch git.mode {
return GitHistoryProvider(boardRoot: store.rootURL) case .git: return GitHistoryProvider(boardRoot: store.rootURL)
case .none: return NativeHistoryProvider()
case .repoNested: return nil
}
} }
// MARK: Sessions // MARK: Sessions
@@ -335,13 +346,15 @@ public final class AppModel {
/// Which implementation it is, is the tier's answer and nobody else's /// Which implementation it is, is the tier's answer and nobody else's
/// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`. /// (12-editions.md The provider seam) see `AppModel.makeHistoryProvider`.
/// ///
/// **`nil` is a board with no undo at all** under Pro, mode `none` and repo-nested boards /// **`nil` is a board with no undo at all** a repo-nested board under Pro, and nothing else
/// (06-history-undo.md Rules: "A board without git has **no undo/redo**"). The command /// (06-history-undo.md Rules: boards inside an existing repository "get **no undo**").
/// surface disables through `undoManager`, which answers the empty way over an absent /// Gitless boards bind the native stack in every tier (re-ruled 2026-07-31 see
/// substrate. /// `AppModel.makeHistoryProvider`). The command surface disables through `undoManager`, which
/// answers the empty way over an absent substrate.
/// ///
/// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single /// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single
/// sanctioned mid-session mode flip, binds a provider 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
/// (`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.
public var history: (any HistoryProviding)? public var history: (any HistoryProviding)?
@@ -379,11 +392,11 @@ public final class AppModel {
/// is every free-tier session ("The free tier ships exactly one mode: `none`", /// is every free-tier session ("The free tier ships exactly one mode: `none`",
/// 12-editions.md Tier matrix). /// 12-editions.md Tier matrix).
/// ///
/// **The consumer this is waiting for is the git `HistoryProviding` implementation** the /// **Its first consumer is the provider seam** `makeHistoryProvider` reads exactly this to
/// undo/redo card two cards further into pro-m1, which binds over `makeHistoryProvider` and /// know whether the board has a repository to be an undo stack for, and it is the *mode*
/// reads exactly this to know whether it has a repository to be an undo stack for. Until it /// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider,
/// lands both tiers bind the native stack, and this is a recorded fact with one reader: the /// `none` the native stack, `repoNested` nothing. The popover's git section is the other
/// popover's git section. /// reader.
/// ///
/// `@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
@@ -799,8 +812,9 @@ public final class AppModel {
provider.noteLanded(window) provider.noteLanded(window)
} }
} }
// **Add-git binds undo too** (06 Rules Detection the one commanded mid-session mode // **Add-git swaps the undo substrate too** (06 Rules Detection the one commanded
// flip). See `bindHistoryProvider(for:)` for the judgment call this records. // 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 git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref) self?.bindHistoryProvider(for: ref)
} }
@@ -1014,25 +1028,53 @@ public final class AppModel {
) )
} }
/// **Binds the git provider onto an already-open session** add-git's one caller. /// **Swaps the board's undo substrate onto an already-open session** add-git's one caller.
/// ///
/// 06 Rules Detection sanctions exactly one mid-session mode flip, the app's own add-git: /// 06 Rules Detection sanctions exactly one mid-session mode flip, the app's own add-git:
/// "clicking it flips the open board into git mode immediately the popover flows straight into /// "clicking it flips the open board into git mode immediately the popover flows straight into
/// the git controls, the first auto-commit follows". It does not mention undo, so this is a /// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what
/// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting /// that does to undo: "**Add-git swaps the substrate mid-session** the commanded flip discards
/// for the next open. Three reasons point the same way the mode flip already carries the /// the in-session native stack and seeds the git trail from the root commit, the branch-switch
/// *committer* through (`HistoryStore.activateAutoCommit` remembers its wiring for precisely this /// discard-and-reseed precedent applied".
/// 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 session at all; and a board that visibly
/// starts accumulating commits while Z stays greyed out until it is closed and reopened would
/// read as a defect rather than as a policy.
/// ///
/// It only ever adds. A board that already has a provider keeps it, and nothing here can take one /// ### The discard is the whole of it there is no migration
/// away there is no un-add-git. ///
/// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real
/// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are
/// in-memory inverse operations against a board that has just acquired a commit trail, and
/// replaying one after the swap would walk the board back across a change the root commit already
/// records as the baseline. The branch-switch precedent says the same thing about the same
/// question "the undo/redo stack does not survive a switch. It is discarded and reseeded from
/// the new HEAD's first-parent ancestry redo starts empty" (06 Branch switching) so the old
/// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`)
/// walks a trail whose only commit is the root, which is the stack's floor and not a step: Z is
/// correctly empty the instant the flip lands.
///
/// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git
/// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard
/// has none; what the user sees is the Edit rows and the toolbar pair revalidating through
/// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement
/// change on this board rides. Nothing here posts a banner, and nothing should.
///
/// ### Why live rather than at the next open
///
/// A judgment call, recorded when the free-tier matrix still left this board with no provider at
/// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit`
/// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with
/// the provider it composed" is a rule about a **tier** lapsing, which cannot change a running
/// session at all; and a board that visibly starts accumulating commits while Z answers from a
/// stack the repository knows nothing about would read as a defect rather than as a policy.
///
/// Called exactly once per board, structurally: `HistoryStore.addGit` refuses any mode but
/// `none`, and flips to `.git` before it fires `didAddGit`.
func bindHistoryProvider(for ref: BoardWindowRef) { func bindHistoryProvider(for ref: BoardWindowRef) {
guard var session = sessions[ref], session.history == nil, guard var session = sessions[ref], let git = session.git, git.mode == .git else { return }
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, session.tier, git) else { return }
guard history !== session.history else { return }
// Before the reassignment, while `session.history` is still the substrate being replaced: the
// in-flight native steps go with it, and any closure that outlives this line finds an empty
// stack rather than inverses against a pre-repository board.
session.history?.clear()
session.history = history session.history = history
sessions[ref] = session sessions[ref] = session
session.store.history = history session.store.history = history
@@ -1256,10 +1298,11 @@ public final class AppModel {
/// ///
/// ### Declining costs nothing, today least of all /// ### Declining costs nothing, today least of all
/// ///
/// Both tiers bind the native history stack until pro-m1 (`makeHistoryProvider`), so a user who /// A board that says Not Now keeps the substrate it composed with, and since the provider follows
/// says Not Now loses exactly nothing that exists yet. The offer is built now because the /// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either
/// *mechanism* is what the design specifies and because a milestone that shipped the subscription /// way declining costs undo nothing at all, and costs a git board only the trail it would have
/// without it would leave a visible gap the moment the git provider lands. /// gained at its next open. The offer exists because "subscribe takes effect at each board's next
/// open" (12 The entitlement) needs one, not because anything breaks without it.
public func reopenOpenBoards() async { public func reopenOpenBoards() async {
// Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s. // Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s.
let refs = sessions.keys.sorted { $0.path < $1.path } let refs = sessions.keys.sorted { $0.path < $1.path }
+4 -2
View File
@@ -159,8 +159,10 @@ public final class GitAutoCommitter {
/// this engine clears the instant a window commits so the moment of landing is the only moment /// this engine clears the instant a window commits so the moment of landing is the only moment
/// at which "that commit was the heal" is knowable at all. /// at which "that commit was the heal" is knowable at all.
/// ///
/// `nil` on every board with no undo provider bound (the free tier's committer does not exist; /// `nil` wherever no `GitHistoryProvider` is listening which in practice is nowhere a committer
/// a Pro board's does, and a provider is bound beside it). /// exists at all: a committer's existence is exactly mode `git`, and mode `git` is exactly where
/// the composition root binds the git provider (`AppModel.makeHistoryProvider`). Mode-none boards
/// have a native stack and no committer; repo-nested boards have neither.
@ObservationIgnored @ObservationIgnored
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)? public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
+8 -6
View File
@@ -19,10 +19,10 @@ import os
/// ### 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 is not part of it** both tiers still bind /// ranker. **The provider binding reads `mode` and nothing else about a tier** the composition
/// `NativeHistoryProvider` (`AppModel.makeHistoryProvider`), and the consumer of `mode` is the /// root binds the git provider on mode `git`, the native stack on mode `none`, and nothing on a
/// undo/redo card two cards later, which builds the git `HistoryProviding` implementation over /// repo-nested board (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows the
/// exactly this object. Auto-commit, commit messages, branch controls, the identity fields, the /// board). Auto-commit, commit messages, branch controls, the identity fields, the
/// `.gitignore` seed and its periodic housekeeping each arrived as their own card and are composed /// `.gitignore` seed and its periodic housekeeping each arrived as their own card and are composed
/// here now; remotes are pro-m2's and deliberately still absent. /// here now; remotes are pro-m2's and deliberately still absent.
@MainActor @MainActor
@@ -141,9 +141,11 @@ public final class HistoryStore {
/// never on any other path. /// never on any other path.
/// ///
/// It exists because the flip has a second consumer beyond the committer: the board's **undo /// It exists because the flip has a second consumer beyond the committer: the board's **undo
/// substrate**. A session that composed on a mode-none board bound no provider at all /// substrate**. A session that composed on a mode-none board bound the native stack
/// (`AppModel.makeHistoryProvider`), and 06 Rules Detection's one sanctioned commanded flip /// (`AppModel.makeHistoryProvider`), and 06 Rules Detection's one sanctioned commanded flip
/// means the board now has a trail to be an undo stack over. What binding it means is /// means the board now has a trail to be an undo stack over instead "add-git swaps the
/// substrate mid-session discards the in-session native stack and seeds the git trail from the
/// root commit" (13-native-undo.md). What that swap means is
/// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is /// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is
/// keep that decision out of a git state that has no business knowing what a provider is. /// keep that decision out of a git state that has no business knowing what a provider is.
@ObservationIgnored @ObservationIgnored
+21 -19
View File
@@ -13,13 +13,13 @@ import AppKit
/// exactly how the system's Edit Undo row and the toolbar's nil-target pair (`BoardToolbar`) light /// exactly how the system's Edit Undo row and the toolbar's nil-target pair (`BoardToolbar`) light
/// up, disable and retitle with no code of the app's own. /// up, disable and retitle with no code of the app's own.
/// ///
/// The seam, though, must not be an `NSUndoManager`: the free tier's stack is one, Pro's is git /// The seam, though, must not be an `NSUndoManager`: a gitless board's stack is one, a Pro git
/// (12-editions.md The provider seam), and a protocol that vended one could only ever have had a /// board's is git (12-editions.md The provider seam), and a protocol that vended one could only
/// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the /// ever have had a single implementation. So the substrate stays behind `HistoryProviding` and
/// translation one per board session, over whichever provider that session was composed with. Pro /// *this* object is the translation one per board session, over whichever provider that session
/// inherits the whole command surface (enablement, dynamic titles, Z, the toolbar pair) by binding /// was composed with. A git board inherits the whole command surface (enablement, dynamic titles,
/// its provider and changing nothing here, which is what "a user subscribing (or lapsing) relearns /// Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user
/// nothing" (12) has to mean in code. /// subscribing (or lapsing) relearns nothing" (12) has to mean in code.
/// ///
/// ### It deliberately keeps its inherited stack empty /// ### It deliberately keeps its inherited stack empty
/// ///
@@ -51,22 +51,24 @@ public final class BoardUndoManager: UndoManager {
/// ///
/// ### `nil` is a board with **no undo provider**, and it is a real state /// ### `nil` is a board with **no undo provider**, and it is a real state
/// ///
/// Under Pro, a board in mode `none` or `repoNested` gets no provider at all "the pair disabled /// A **repo-nested** board under Pro gets no provider at all, and since the re-ruling of
/// on boards with no undo provider in the composed tier under Pro, no-git and repo-nested /// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards,
/// boards, matching their menu items" (03-board-ui.md Toolbar Catalog; 06-history-undo.md /// under locks, and on empty stacks the provider follows the board, so gitless boards bind
/// Rules). Every question below answers the empty way, so the Edit menu's rows, the toolbar /// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md Toolbar Catalog;
/// pair, and Z itself go quiet together, through the same validation path a lock uses. Modelling /// 06-history-undo.md Rules). Every question below answers the empty way, so the Edit menu's
/// it as an absent substrate rather than as a substrate that always says no is the honest shape: /// rows, the toolbar pair, and Z itself go quiet together, through the same validation path a
/// there is nothing there, and nothing can accidentally accumulate in it. /// lock uses. Modelling it as an absent substrate rather than as a substrate that always says no
/// is the honest shape: there is nothing there, and nothing can accidentally accumulate in it.
/// ///
/// ### Settable, for exactly one event /// ### Settable, for exactly one event
/// ///
/// **Add-git** (06 Rules Detection) is the design's one sanctioned mid-session mode flip: /// **Add-git** (06 Rules Detection) is the design's one sanctioned mid-session mode flip:
/// "clicking it flips the open board into git mode immediately the popover flows straight into /// "clicking it flips the open board into git mode immediately". 13's header says what that means
/// the git controls, the first auto-commit follows". A board that gains a repository mid-session /// here the flip **swaps the substrate**: the mode-none board's native stack is discarded and
/// gains a commit trail, and a trail with a dead Z over it would read as a bug. The composition /// the git trail seeded from the root commit, the branch-switch discard-and-reseed precedent. The
/// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit /// composition root writes the new provider into this property rather than rebuilding this
/// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`. /// object, so AppKit keeps the identical manager it has already been handed by
/// `windowWillReturnUndoManager` and simply revalidates over a different stack.
/// ///
/// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it /// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it
/// composed" is about a subscription lapsing, which cannot change a running session's tier at /// composed" is about a subscription lapsing, which cannot change a running session's tier at
+8 -7
View File
@@ -80,9 +80,9 @@ public enum HistoryStepOutcome: Equatable, Sendable {
/// holds: move move back ...; rename restore title" and both halves of that write are already /// holds: move move back ...; rename restore title" and both halves of that write are already
/// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write /// in the caller's hands: the before-value *is* the inverse, and the after-value is what the write
/// set (which is also what the staleness predicate compares). A step is therefore that pair, in the /// set (which is also what the staleness predicate compares). A step is therefore that pair, in the
/// design's own terms, and deliberately says nothing about how a stack stores it: the free tier's /// design's own terms, and deliberately says nothing about how a stack stores it: a gitless board's
/// stack is `NSUndoManager`-backed and Pro's is git (12-editions.md The provider seam), and neither /// stack is `NSUndoManager`-backed and a Pro git board's is git (12-editions.md The provider
/// substrate appears here. /// seam), and neither substrate appears here.
/// ///
/// ### `name` is the 06 vocabulary, unprefixed /// ### `name` is the 06 vocabulary, unprefixed
/// ///
@@ -143,10 +143,11 @@ public struct HistoryStep {
/// "One stack per board, owned by the board session. Not per-window: every window over a board /// "One stack per board, owned by the board session. Not per-window: every window over a board
/// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md /// (board window, its card windows) shares the store and shares the stack" (13-native-undo.md
/// Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds /// Rules). `AppModel.BoardSession` is where that ownership lives, and the composition root binds
/// which implementation it gets: the free tier binds `NativeHistoryProvider` (two step stacks over /// which implementation it gets **following the board, not the tier alone** (re-ruled 2026-07-31):
/// the inverses registered at the Writer boundary), Pro binds the git provider in pro-m1 (undo as /// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at
/// forward restore commits over HEAD's first-parent ancestry 06-history-undo.md), Teams inherits /// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward
/// Pro's. /// restore commits over HEAD's first-parent ancestry 06-history-undo.md), a repo-nested board
/// binds none at all, and Teams inherits Pro's.
/// ///
/// ### What this protocol deliberately does not say /// ### What this protocol deliberately does not say
/// ///
+4 -3
View File
@@ -29,9 +29,10 @@ import SwiftUI
/// ///
/// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open /// "Subscribe takes effect at each board's next open ... The purchase flow offers to reopen open
/// boards so the upgrade feels immediate" (12 The entitlement). The offer is raised **once**, from /// boards so the upgrade feels immediate" (12 The entitlement). The offer is raised **once**, from
/// here, on the one outcome that means an active subscription just landed and declining costs /// here, on the one outcome that means an active subscription just landed and declining costs a
/// nothing, today least of all: both tiers bind the native history stack until pro-m1 builds the git /// gitless board nothing at all: the provider follows the board, so its undo is the same native
/// provider (`AppModel.makeHistoryProvider`). /// stack before and after (`AppModel.makeHistoryProvider`). What a Not Now defers is the git trail
/// on the boards that have a repository.
struct ProSettingsSection: View { struct ProSettingsSection: View {
@Environment(AppModel.self) private var appModel @Environment(AppModel.self) private var appModel
+5 -2
View File
@@ -23,8 +23,11 @@ public enum Tier: String, Sendable, Equatable, Codable, CaseIterable {
/// existing at all. /// existing at all.
case free case free
/// Lanework Pro an active auto-renewable subscription. Binds the git history provider when /// Lanework Pro an active auto-renewable subscription. It is what puts git on the table; which
/// pro-m1 builds it (06-history-undo.md, 07-sync-collab.md). /// substrate a given board then binds is the *board's* answer, not this case's (re-ruled
/// 2026-07-31 `AppModel.makeHistoryProvider`): the git provider on a git board
/// (06-history-undo.md, 07-sync-collab.md), the same native stack the free tier uses on a gitless
/// one, nothing on a repo-nested one.
case pro case pro
} }
+140 -13
View File
@@ -44,6 +44,29 @@ private func makeGitBoard() async throws -> (fixture: WriterFixture, git: Histor
return (fixture, git, ledger) return (fixture, git, ledger)
} }
/// A synthetic native step that records its own crossing `HistoryProviderTests`' fixture, file-private
/// there and here. What the binding suite needs it for is the *discard*: a step that would announce
/// itself loudly if the swap ever ran it.
@MainActor
private final class StepLog {
private(set) var crossings: [String] = []
func step(_ name: String) -> HistoryStep {
HistoryStep(
name: name,
undo: { [weak self] _ in
self?.crossings.append("undo \(name)")
return .applied
},
redo: { [weak self] _ in
self?.crossings.append("redo \(name)")
return .applied
}
)
}
}
@MainActor @MainActor
private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter { private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter {
let committer = try #require(git.committer) let committer = try #require(git.committer)
@@ -873,6 +896,19 @@ 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 seam; 13-native-undo.md's header; 06-history-undo.md Rules), stated as the
/// matrix it is: gitless boards bind the native stack in *every* tier ("an upgrade never removes
/// undo"), a Pro git board binds the git provider, and a repo-nested board binds nothing.
///
/// ### The free tier's row is one cell wide, structurally
///
/// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all
/// and cannot tell a repo-nested board from a plain one which is not an omission but 12 The free
/// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a
/// 1.x board, **a repo-nested board**) works normally files read and write as on any board,
/// **native undo runs**". 06's no-undo rule for repo-nested boards is a rule of a doc whose own first
/// line reads "Tier scope: Lanework Pro", and the tests below pin both halves.
@MainActor @MainActor
@Suite("Git undo ▸ which board gets a provider") @Suite("Git undo ▸ which board gets a provider")
struct GitUndoBindingTests { struct GitUndoBindingTests {
@@ -913,8 +949,8 @@ struct GitUndoBindingTests {
#expect(session.history is GitHistoryProvider) #expect(session.history is GitHistoryProvider)
} }
@Test("Pro on a mode-none board binds no provider at all — the pair disables") @Test("Pro on a mode-none board binds the native stack — an upgrade never removes undo")
func proOnAPlainBoardBindsNothing() throws { func proOnAPlainBoardBindsTheNativeStack() 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()
@@ -924,17 +960,24 @@ 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
// chose the substrate: "gitless boards bind the native undo stack in every tier" (12).
#expect(session.git != nil, "Pro composes a git state even where there is no repository")
#expect(session.gitMode == .none) #expect(session.gitMode == .none)
#expect(session.history == nil, "a board without git has no undo/redo") #expect(session.history is NativeHistoryProvider)
#expect(session.undoManager.canUndo == false)
#expect(session.undoManager.canRedo == false) // And it is a *working* stack, not a placeholder: the same command surface a free-tier
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name") // session gets, which is what "a user subscribing relearns nothing" means here.
// And a crossing that somehow started still writes nothing. let log = StepLog()
#expect(session.undoManager.canUndo == false, "empty, not absent")
session.history?.register(log.step("Move Card"))
#expect(session.undoManager.canUndo)
#expect(session.undoManager.undoMenuItemTitle == "Undo Move Card")
session.undoManager.undo() session.undoManager.undo()
session.undoManager.redo() #expect(log.crossings == ["undo Move Card"])
} }
@Test("Pro on a repo-nested board binds no provider either") @Test("Pro on a repo-nested board binds no provider the one no-undo case")
func proOnARepoNestedBoardBindsNothing() throws { func proOnARepoNestedBoardBindsNothing() throws {
let outer = try WriterFixture() let outer = try WriterFixture()
defer { outer.tearDown() } defer { outer.tearDown() }
@@ -952,13 +995,46 @@ 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))
// 06 Rules: a board inside somebody else's repository is "left strictly alone so they get
// **no undo**" no app-managed undo journal, which an in-memory stack here would be.
#expect(session.gitMode == .repoNested) #expect(session.gitMode == .repoNested)
#expect(session.history == nil, "the app leaves that repository strictly alone") #expect(session.history == nil, "the app leaves that repository strictly alone")
#expect(session.undoManager.canUndo == false) #expect(session.undoManager.canUndo == false)
#expect(session.undoManager.canRedo == false)
#expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name")
// And a crossing that somehow started still writes nothing.
session.undoManager.undo()
session.undoManager.redo()
} }
@Test("Add-git binds the provider on the open session — the commanded mid-session flip") @Test("The free tier's repo-nested board still binds the native stack — it never detects one")
func addGitBindsUndoLive() async throws { func freeTierOnARepoNestedBoardIsNativeToo() throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
let repoRoot = outer.root
_ = GitRepository.create(at: repoRoot)
let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .free }
let ref = try openBoard(model, at: boardRoot)
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
// a repo-nested board works normally native undo runs". The tier composes no git state
// at all, so there is nothing here that *could* tell this board from a plain one the inert
// posture made structural rather than remembered.
#expect(session.git == nil)
#expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode")
#expect(session.history is NativeHistoryProvider)
}
@Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded")
func addGitSwapsTheSubstrate() 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()
@@ -966,7 +1042,16 @@ struct GitUndoBindingTests {
model.currentTier = { .pro } model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root) let ref = try openBoard(model, at: fixture.root)
#expect(try #require(model.session(for: ref)).history == nil) let native = try #require(model.session(for: ref)?.history as? NativeHistoryProvider)
// The AppKit face AppKit already holds: the swap must not replace *this* object, or every
// window's `windowWillReturnUndoManager` answer would go stale.
let manager = try #require(model.session(for: ref)?.undoManager)
// A real in-session step, mid-flight when the flip arrives.
let log = StepLog()
native.register(log.step("Move Card"))
#expect(manager.canUndo)
#expect(manager.undoMenuItemTitle == "Undo Move Card")
let git = try #require(model.session(for: ref)?.git) let git = try #require(model.session(for: ref)?.git)
#expect(await git.addGit()) #expect(await git.addGit())
@@ -974,7 +1059,49 @@ struct GitUndoBindingTests {
let session = try #require(model.session(for: ref)) let session = try #require(model.session(for: ref))
#expect(session.gitMode == .git) #expect(session.gitMode == .git)
#expect(session.history is GitHistoryProvider, "the flip carries undo through with it") #expect(session.history is GitHistoryProvider, "the flip carries undo through with it")
#expect(session.undoManager.canUndo == false, "on a trail whose only commit is the root") #expect(session.store.history is GitHistoryProvider, "and the Writer boundary registers there")
#expect(session.undoManager === manager, "the same manager, over a different stack")
// **The stack dies with the substrate** (13's header the branch-switch discard-and-reseed
// precedent): no migration, and the discarded stack is cleared rather than merely dropped,
// so nothing holding a reference to it can cross a step against a board that now has a trail.
#expect(native.canUndo == false)
#expect(native.canRedo == false)
#expect(log.crossings.isEmpty, "the in-flight step never ran — it was discarded, not applied")
// **Seeded from the root commit**, which is the stack's floor and not a step (06 Rules), so
// Z is correctly empty the instant the flip lands and the menu row says nothing.
#expect(try trail(at: fixture.root).count == 1)
#expect(manager.canUndo == false, "on a trail whose only commit is the root")
#expect(manager.canRedo == false)
#expect(manager.undoMenuItemTitle == "Undo")
}
@Test("The swapped-in git provider is live — the next landed commit is a step on it")
func theSwappedProviderHearsTheCommitter() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let git = try #require(model.session(for: ref)?.git)
#expect(await git.addGit())
let session = try #require(model.session(for: ref))
let provider = try #require(session.history as? GitHistoryProvider)
// `activateAutoCommit` remembered the session's wiring for exactly this the committer
// add-git built reports landed commits to whatever provider the session now holds, which is
// the one the swap just installed.
let committer = try quickCommitter(git)
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
committer.noteReloadLanded(sawForeignChange: true)
await commitAndSettle(committer, provider)
#expect(try trail(at: fixture.root).count == 2)
#expect(session.undoManager.canUndo, "the trail behind ⌘Z is the repository's, live")
} }
} }
+3 -2
View File
@@ -605,8 +605,9 @@ struct BoardSessionHistoryTests {
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 handed to the
// root that binds the provider. Both tiers bind the native stack until pro-m1 what this // root that binds the provider. What this pins is that the *argument arrives* the matrix it
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body. // feeds is `GitUndoBindingTests`', since after the re-ruling of 2026-07-31 the tier decides
// only whether a git state is composed at all, and the mode decides the substrate.
var seen: [Tier] = [] var seen: [Tier] = []
model.currentTier = { .pro } model.currentTier = { .pro }
model.makeHistoryProvider = { _, tier, _ in model.makeHistoryProvider = { _, tier, _ in