Implement undo and redo as forward commits

GitHistoryProvider is the second HistoryProviding implementation:
its stack IS HEAD's first-parent ancestry, reseeded on load (redo
empty), re-synced to HEAD before every crossing so agents'
self-commits become the top and ⌘Z steps back exactly one commit;
any arriving commit clears redo (a heal-only window deliberately
does not). Restores are forward commits through the ordinary
signature path — GitRestoreOperation materializes only the
current-vs-target diff as working-tree writes and resolves no
reset/checkout symbol at all; heal commits are transparent
in-session (pointer passes over, restores exclude heal-owned paths,
identity carried on landed windows via PlannedCommit.kind →
GitLandedCommit). Subjects "Undo:/Redo: <crossed subject>"; menu
labels never nest in-session; the root commit is not a step
(crossing it would restore the empty tree).

Provider binding flips: makeHistoryProvider(store, tier, git) —
free binds native everywhere, Pro binds the git provider on git
boards and NOTHING on mode-none/repo-nested (the pair disables
through existing validation); add-git mid-session live-binds via
HistoryStore.didAddGit → bindHistoryProvider (the flip only ever
adds).

SessionSettleGate is the reusable Save All / Discard / Cancel step:
restores whose diff touches an open Edit session or raw-source
buffer gate on it (Save All applies with validation — a refused
buffer cancels the whole restore focused on the offender; Discard
reverts via CardBodyEditSession.discardBuffer and reconciles against
the working tree, deliberately skipping the second flush); untouched
sessions ride through undisturbed. Built for the branch-switch card
to reuse. BoardStore gains the async performWholesale sibling.

CardHistorySection fills the m6 EmptyView slot: read-only, newest
first, follows the card across lane moves by folder-component match
(the UUID is the identity — no rename detection), absent off git
mode and off Pro.

2332 tests / 403 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-31 15:54:22 -04:00
parent 563999655f
commit 142c6e75fe
19 changed files with 3126 additions and 82 deletions
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -498,7 +498,7 @@ struct BoardSessionHistoryTests {
let log = StepLog()
#expect(session.undoManager.canUndo == false)
session.history.register(log.step("Move Card"))
session.history?.register(log.step("Move Card"))
// The window hands AppKit the adapter; the adapter is answering from the session's provider.
#expect(session.undoManager.canUndo)
@@ -526,14 +526,14 @@ struct BoardSessionHistoryTests {
let secondSession = try #require(model.session(for: secondRef))
#expect(firstSession.history !== secondSession.history)
firstSession.history.register(log.step("Move Card"))
firstSession.history?.register(log.step("Move Card"))
#expect(firstSession.history.canUndo)
#expect(secondSession.history.canUndo == false)
#expect(firstSession.history?.canUndo == true)
#expect(secondSession.history?.canUndo == false)
secondSession.undoManager.undo()
#expect(log.crossings.isEmpty)
#expect(firstSession.history.canUndo, "the other board's ⌘Z left this one's stack alone")
#expect(firstSession.history?.canUndo == true, "the other board's ⌘Z left this one's stack alone")
}
@Test("Closing a board empties its stack — session-only persistence")
@@ -545,7 +545,7 @@ struct BoardSessionHistoryTests {
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
let history = session.history
let history = try #require(session.history)
let log = StepLog()
history.register(log.step("Move Card"))
#expect(history.canUndo)
@@ -567,12 +567,12 @@ struct BoardSessionHistoryTests {
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
session.history.register(StepLog().step("Move Card"))
session.history?.register(StepLog().step("Move Card"))
#expect(session.undoManager.canUndo)
session.store.enterVanishedRootLock()
#expect(session.undoManager.canUndo == false)
#expect(session.history.canUndo, "the stack itself survives the lock")
#expect(session.history?.canUndo == true, "the stack itself survives the lock")
session.store.handleWatcherEvent(.treeChanged(.appMediated))
await session.store.awaitQuiescence()
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
defer { tearDown() }
let bound = FakeHistoryProvider()
model.makeHistoryProvider = { _, _ in bound }
model.makeHistoryProvider = { _, _, _ in bound }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
@@ -609,7 +609,7 @@ struct BoardSessionHistoryTests {
// pins is that the *argument arrives*, so the milestone that switches on it is a closure body.
var seen: [Tier] = []
model.currentTier = { .pro }
model.makeHistoryProvider = { _, tier in
model.makeHistoryProvider = { _, tier, _ in
seen.append(tier)
return NativeHistoryProvider()
}
@@ -808,7 +808,7 @@ struct UndoCommandSurfaceTests {
let boardRow = menuItem("undo:")
let cardRow = menuItem("undo:")
session.history.register(StepLog().step("Move 3 Cards"))
session.history?.register(StepLog().step("Move 3 Cards"))
#expect(boardWindow.validateMenuItem(boardRow))
#expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window")
+3 -3
View File
@@ -426,9 +426,9 @@ struct BoardSessionGitTests {
let ranker = try #require(provider())
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
// The provider binding is deliberately *not* part of this card: both tiers still bind the
// native stack until the undo/redo card builds the git provider over this mode.
#expect(session.history is NativeHistoryProvider)
// The provider binding arrived with the undo/redo card: a Pro session on a git board binds
// the git substrate over exactly this mode (12-editions.md The provider seam).
#expect(session.history is GitHistoryProvider)
}
@Test("A Pro session on a plain board is mode none and injects nothing")