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
+140 -13
View File
@@ -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")
}
}
+3 -2
View File
@@ -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