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:
+140
-13
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user