diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 7bc2b23..286e682 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -278,11 +278,12 @@ public final class AppModel { /// **The composition root for `HistoryProviding`** (12-editions.md ▸ The provider seam): what a /// board session's undo stack is built by, called once per board as its session begins. /// - /// The free tier binds the native stack — a pair of step stacks over the inverses registered at - /// the Writer boundary (13-native-undo.md, `NativeHistoryProvider`) — and that is the default - /// here because it is the substrate every tier can always fall back to; pro-m1 installs the git - /// provider over this closure when the subscription is active (06-history-undo.md). Nothing in - /// this file is tier-conditional; the tier difference is which closure the root installs. + /// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12 ▸ The + /// provider seam; 13-native-undo.md's header; 06 ▸ Rules): a board's substrate is decided by what + /// the board *is*, and the tier only decides whether git is on the table at all. The rule it + /// 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 /// 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 /// 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): - /// - /// - **Free** — the native stack, on every board. There is no `HistoryStore` at all off Pro, so - /// the absent git state *is* the tier test; nothing here reads a flag. - /// - **Pro, mode `git`** — the git provider: undo as forward restore commits over HEAD's - /// first-parent ancestry. - /// - **Pro, mode `none` or `repoNested`** — **no provider**. "A board without git has no - /// undo/redo", and a repo-nested board is one the app "leaves strictly alone" — so Edit - /// ▸ Undo/Redo and the toolbar pair disable there ("the pair disabled on boards with no undo - /// provider in the composed tier — under Pro, no-git and repo-nested boards, matching their menu - /// items", 03-board-ui.md ▸ Toolbar). Not the native stack: on Pro, a mode-none board's edits - /// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second - /// substrate the design does not have. + /// - **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 + /// everywhere (any `.git` inert)" (13), and 12 ▸ The free tier and `.git` names the boards that + /// covers by hand — "a formerly-subscribed user's board, a 1.x board, a repo-nested board … + /// native undo runs". The absent git state *is* the tier test; nothing here reads a flag. + /// - **Mode `git`** (Pro only — no other tier composes a git state) — the git provider: undo as + /// forward restore commits over HEAD's first-parent ancestry (06). + /// - **Mode `none`** — the **native stack**, exactly as in the free tier. "Gitless boards bind + /// the native undo stack in every tier — an upgrade never removes undo" (12). + /// - **Mode `repoNested`** — **no provider**, the one no-undo case: a board inside somebody + /// else's repository is one the app "leaves strictly alone … so they get **no undo**" (06 + /// ▸ Rules), and an in-memory stack there would be the app-managed undo journal that rule + /// 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` /// 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. + /// + /// ### 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 public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in guard let git else { return NativeHistoryProvider() } - guard git.mode == .git else { return nil } - return GitHistoryProvider(boardRoot: store.rootURL) + switch git.mode { + case .git: return GitHistoryProvider(boardRoot: store.rootURL) + case .none: return NativeHistoryProvider() + case .repoNested: return nil + } } // MARK: Sessions @@ -335,13 +346,15 @@ public final class AppModel { /// Which implementation it is, is the tier's answer and nobody else's /// (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 - /// (06-history-undo.md ▸ Rules: "A board without git has **no undo/redo**"). The command - /// surface disables through `undoManager`, which answers the empty way over an absent - /// substrate. + /// **`nil` is a board with no undo at all** — a repo-nested board under Pro, and nothing else + /// (06-history-undo.md ▸ Rules: boards inside an existing repository "get **no undo**"). + /// Gitless boards bind the native stack in every tier (re-ruled 2026-07-31 — see + /// `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 - /// 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. 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`", /// 12-editions.md ▸ Tier matrix). /// - /// **The consumer this is waiting for is the git `HistoryProviding` implementation** — the - /// undo/redo card two cards further into pro-m1, which binds over `makeHistoryProvider` and - /// reads exactly this to know whether it has a repository to be an undo stack for. Until it - /// lands both tiers bind the native stack, and this is a recorded fact with one reader: the - /// popover's git section. + /// **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* + /// rather than the tier that decides (re-ruled 2026-07-31): `git` binds the git provider, + /// `none` the native stack, `repoNested` nothing. The popover's git section is the other + /// reader. /// /// `@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 @@ -799,8 +812,9 @@ public final class AppModel { provider.noteLanded(window) } } - // **Add-git binds undo too** (06 ▸ Rules ▸ Detection — the one commanded mid-session mode - // flip). See `bindHistoryProvider(for:)` for the judgment call this records. + // **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) } @@ -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: /// "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 - /// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting - /// for the next open. Three reasons point the same way — 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 stays greyed out until it is closed and reopened would - /// read as a defect rather than as a policy. + /// the git controls, the first auto-commit follows". 13-native-undo.md's header spells out what + /// that does to undo: "**Add-git swaps the substrate mid-session** — the commanded flip discards + /// the in-session native stack and seeds the git trail from the root commit, the branch-switch + /// discard-and-reseed precedent applied". /// - /// It only ever adds. A board that already has a provider keeps it, and nothing here can take one - /// away — there is no un-add-git. + /// ### The discard is the whole of it — there is no migration + /// + /// The board opened mode-none under any tier now carries a live `NativeHistoryProvider` with real + /// steps on it (`makeHistoryProvider`), and those steps **die with the substrate**: they are + /// in-memory inverse operations against a board that has just acquired a commit trail, and + /// replaying one after the swap would walk the board back across a change the root commit already + /// records as the baseline. The branch-switch precedent says the same thing about the same + /// question — "the undo/redo stack does not survive a switch. It is discarded and reseeded from + /// the new HEAD's first-parent ancestry … redo starts empty" (06 ▸ Branch switching) — so the old + /// stack is cleared rather than merely dropped, and the git provider's `seed()` (in `wireGitUndo`) + /// walks a trail whose only commit is the root, which is the stack's floor and not a step: ⌘Z is + /// correctly empty the instant the flip lands. + /// + /// **Nothing is announced.** 06 gives the flip the popover's own flow ("straight into the git + /// controls") and 13 gives the discard no surface at all, exactly as the branch switch's discard + /// has none; what the user sees is the Edit rows and the toolbar pair revalidating through + /// `BoardUndoManager` on AppKit's own cadence, which is the same machinery every other enablement + /// change on this board rides. Nothing here posts a banner, and nothing should. + /// + /// ### Why live rather than at the next open + /// + /// A judgment call, recorded when the free-tier matrix still left this board with no provider at + /// all: the mode flip already carries the *committer* through (`HistoryStore.activateAutoCommit` + /// remembers its wiring for precisely this board); 12-editions.md's "an open board finishes with + /// the provider it composed" 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) { - guard var session = sessions[ref], session.history == nil, - 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 history !== session.history else { return } + // Before the reassignment, while `session.history` is still the substrate being replaced: the + // in-flight native steps go with it, and any closure that outlives this line finds an empty + // stack rather than inverses against a pre-repository board. + session.history?.clear() session.history = history sessions[ref] = session session.store.history = history @@ -1256,10 +1298,11 @@ public final class AppModel { /// /// ### Declining costs nothing, today least of all /// - /// Both tiers bind the native history stack until pro-m1 (`makeHistoryProvider`), so a user who - /// says Not Now loses exactly nothing that exists yet. The offer is built now because the - /// *mechanism* is what the design specifies and because a milestone that shipped the subscription - /// without it would leave a visible gap the moment the git provider lands. + /// A board that says Not Now keeps the substrate it composed with, and since the provider follows + /// the board (`makeHistoryProvider`), a gitless board's undo is the *same* native stack either + /// way — declining costs undo nothing at all, and costs a git board only the trail it would have + /// gained at its next open. The offer exists because "subscribe takes effect at each board's next + /// open" (12 ▸ The entitlement) needs one, not because anything breaks without it. public func reopenOpenBoards() async { // Sorted for `flushAllBoardsForQuit`'s reason: a reproducible order rather than a `Set`'s. let refs = sessions.keys.sorted { $0.path < $1.path } diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index bffc1fd..2cacc33 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -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 /// 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; - /// a Pro board's does, and a provider is bound beside it). + /// `nil` wherever no `GitHistoryProvider` is listening — which in practice is nowhere a committer + /// 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 public var reportLanded: (@MainActor (GitLandedWindow) -> Void)? diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index 2bfeab4..46f4ccf 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -19,10 +19,10 @@ import os /// ### 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 -/// ranker. **The provider binding is not part of it** — both tiers still bind -/// `NativeHistoryProvider` (`AppModel.makeHistoryProvider`), and the consumer of `mode` is the -/// undo/redo card two cards later, which builds the git `HistoryProviding` implementation over -/// exactly this object. Auto-commit, commit messages, branch controls, the identity fields, the +/// ranker. **The provider binding reads `mode` and nothing else about a tier** — the composition +/// root binds the git provider on mode `git`, the native stack on mode `none`, and nothing on a +/// repo-nested board (`AppModel.makeHistoryProvider`, re-ruled 2026-07-31: the provider follows 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 /// here now; remotes are pro-m2's and deliberately still absent. @MainActor @@ -141,9 +141,11 @@ public final class HistoryStore { /// never on any other path. /// /// 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 - /// 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 /// keep that decision out of a git state that has no business knowing what a provider is. @ObservationIgnored diff --git a/Kanban/History/BoardUndoManager.swift b/Kanban/History/BoardUndoManager.swift index db51093..42c3fa2 100644 --- a/Kanban/History/BoardUndoManager.swift +++ b/Kanban/History/BoardUndoManager.swift @@ -13,13 +13,13 @@ import AppKit /// 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. /// -/// The seam, though, must not be an `NSUndoManager`: the free tier's stack is one, Pro's is git -/// (12-editions.md ▸ The provider seam), and a protocol that vended one could only ever have had a -/// single implementation. So the substrate stays behind `HistoryProviding` and *this* object is the -/// translation — one per board session, over whichever provider that session was composed with. Pro -/// inherits the whole command surface (enablement, dynamic titles, ⌘Z, the toolbar pair) by binding -/// its provider and changing nothing here, which is what "a user subscribing (or lapsing) relearns -/// nothing" (12) has to mean in code. +/// The seam, though, must not be an `NSUndoManager`: a gitless board's stack is one, a Pro git +/// board's is git (12-editions.md ▸ The provider seam), and a protocol that vended one could only +/// ever have had a single implementation. So the substrate stays behind `HistoryProviding` and +/// *this* object is the translation — one per board session, over whichever provider that session +/// was composed with. A git board inherits the whole command surface (enablement, dynamic titles, +/// ⌘Z, the toolbar pair) by binding its provider and changing nothing here, which is what "a user +/// subscribing (or lapsing) relearns nothing" (12) has to mean in code. /// /// ### 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 /// - /// Under Pro, a board in mode `none` or `repoNested` gets no provider at all — "the pair disabled - /// on boards with no undo provider in the composed tier — under Pro, no-git and repo-nested - /// boards, matching their menu items" (03-board-ui.md ▸ Toolbar ▸ Catalog; 06-history-undo.md - /// ▸ Rules). Every question below answers the empty way, so the Edit menu's rows, the toolbar - /// pair, and ⌘Z itself go quiet together, through the same validation path a 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. + /// A **repo-nested** board under Pro gets no provider at all, and since the re-ruling of + /// 2026-07-31 it is the only board that does: "the pair disabled only on repo-nested boards, + /// under locks, and on empty stacks — the provider follows the board, so gitless boards bind + /// 13-native-undo.md's native stack in **every** tier" (03-board-ui.md ▸ Toolbar ▸ Catalog; + /// 06-history-undo.md ▸ Rules). Every question below answers the empty way, so the Edit menu's + /// rows, the toolbar pair, and ⌘Z itself go quiet together, through the same validation path a + /// 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 /// /// **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 - /// the git controls, the first auto-commit follows". A board that gains a repository mid-session - /// gains a commit trail, and a trail with a dead ⌘Z over it would read as a bug. The composition - /// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit - /// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`. + /// "clicking it flips the open board into git mode immediately". 13's header says what that means + /// here — the flip **swaps the substrate**: the mode-none board's native stack is discarded and + /// the git trail seeded from the root commit, the branch-switch discard-and-reseed precedent. The + /// composition root writes the new provider into this property rather than rebuilding this + /// 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 /// composed" is about a subscription lapsing, which cannot change a running session's tier at diff --git a/Kanban/History/HistoryProviding.swift b/Kanban/History/HistoryProviding.swift index 5e9d4c0..76ee993 100644 --- a/Kanban/History/HistoryProviding.swift +++ b/Kanban/History/HistoryProviding.swift @@ -80,9 +80,9 @@ public enum HistoryStepOutcome: Equatable, Sendable { /// 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 /// 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 -/// stack is `NSUndoManager`-backed and Pro's is git (12-editions.md ▸ The provider seam), and neither -/// substrate appears here. +/// design's own terms, and deliberately says nothing about how a stack stores it: a gitless board's +/// stack is `NSUndoManager`-backed and a Pro git board's is git (12-editions.md ▸ The provider +/// seam), and neither substrate appears here. /// /// ### `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 /// (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 -/// which implementation it gets: the free tier binds `NativeHistoryProvider` (two step stacks over -/// the inverses registered at the Writer boundary), Pro binds the git provider in pro-m1 (undo as -/// forward restore commits over HEAD's first-parent ancestry — 06-history-undo.md), Teams inherits -/// Pro's. +/// which implementation it gets — **following the board, not the tier alone** (re-ruled 2026-07-31): +/// a gitless board binds `NativeHistoryProvider` (two step stacks over the inverses registered at +/// the Writer boundary) in every tier, a Pro git board binds the git provider (undo as forward +/// 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 /// diff --git a/Kanban/Tier/ProSettingsSection.swift b/Kanban/Tier/ProSettingsSection.swift index eff32e4..17e8ca0 100644 --- a/Kanban/Tier/ProSettingsSection.swift +++ b/Kanban/Tier/ProSettingsSection.swift @@ -29,9 +29,10 @@ import SwiftUI /// /// "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 -/// here, on the one outcome that means an active subscription just landed — and declining costs -/// nothing, today least of all: both tiers bind the native history stack until pro-m1 builds the git -/// provider (`AppModel.makeHistoryProvider`). +/// here, on the one outcome that means an active subscription just landed — and declining costs a +/// gitless board nothing at all: the provider follows the board, so its undo is the same native +/// 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 { @Environment(AppModel.self) private var appModel diff --git a/Kanban/Tier/Tier.swift b/Kanban/Tier/Tier.swift index e50fef3..94691d0 100644 --- a/Kanban/Tier/Tier.swift +++ b/Kanban/Tier/Tier.swift @@ -23,8 +23,11 @@ public enum Tier: String, Sendable, Equatable, Codable, CaseIterable { /// existing at all. case free - /// Lanework Pro — an active auto-renewable subscription. Binds the git history provider when - /// pro-m1 builds it (06-history-undo.md, 07-sync-collab.md). + /// Lanework Pro — an active auto-renewable subscription. It is what puts git on the table; which + /// 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 } diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift index fc7a93b..7392a8d 100644 --- a/KanbanTests/GitUndoTests.swift +++ b/KanbanTests/GitUndoTests.swift @@ -44,6 +44,29 @@ private func makeGitBoard() async throws -> (fixture: WriterFixture, git: Histor 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 private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter { let committer = try #require(git.committer) @@ -873,6 +896,19 @@ struct GitUndoSessionTests { // 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 @Suite("Git undo ▸ which board gets a provider") struct GitUndoBindingTests { @@ -913,8 +949,8 @@ struct GitUndoBindingTests { #expect(session.history is GitHistoryProvider) } - @Test("Pro on a mode-none board binds no provider at all — the pair disables") - func proOnAPlainBoardBindsNothing() throws { + @Test("Pro on a mode-none board binds the native stack — an upgrade never removes undo") + func proOnAPlainBoardBindsTheNativeStack() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() @@ -924,17 +960,24 @@ struct GitUndoBindingTests { let ref = try openBoard(model, at: fixture.root) 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.history == nil, "a board without git has no undo/redo") - #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. + #expect(session.history is NativeHistoryProvider) + + // And it is a *working* stack, not a placeholder: the same command surface a free-tier + // session gets, which is what "a user subscribing relearns nothing" means here. + 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.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 { let outer = try WriterFixture() defer { outer.tearDown() } @@ -952,13 +995,46 @@ struct GitUndoBindingTests { let ref = try openBoard(model, at: boardRoot) 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.history == nil, "the app leaves that repository strictly alone") #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") - func addGitBindsUndoLive() async throws { + @Test("The free tier's repo-nested board still binds the native stack — it never detects one") + 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() defer { fixture.tearDown() } let (model, tearDown) = try makeModel() @@ -966,7 +1042,16 @@ struct GitUndoBindingTests { model.currentTier = { .pro } 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) #expect(await git.addGit()) @@ -974,7 +1059,49 @@ struct GitUndoBindingTests { let session = try #require(model.session(for: ref)) #expect(session.gitMode == .git) #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") } } diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index f15092b..15d7cfa 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -605,8 +605,9 @@ struct BoardSessionHistoryTests { defer { tearDown() } // 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 - // pins is that the *argument arrives*, so the milestone that switches on it is a closure body. + // root that binds the provider. What this pins is that the *argument arrives* — the matrix it + // 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] = [] model.currentTier = { .pro } model.makeHistoryProvider = { _, tier, _ in