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
+174 -17
View File
@@ -290,19 +290,30 @@ public final class AppModel {
/// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because
/// nothing renders from it.
///
/// ### The `Tier` argument, and the one consumer it is still short
/// ### The three answers, and the `nil` among them
///
/// **`pro-m1` is the consumer that will switch on it.** The default closure ignores the tier
/// today and binds the native stack for both not because the seam is decorative, but because
/// the git provider it would bind does not exist yet (12 Tier matrix: git init/adoption,
/// git-backed undo and the history surfaces are all Pro-tier work, designed in 06-history-undo.md
/// and unbuilt). The argument is here now so that arriving milestone is one closure body rather
/// than a change to the composition root, and so that the tier a board actually composed under is
/// a recorded fact from today (`BoardSession.tier`) instead of something pro-m1 has to introduce
/// alongside its provider.
/// 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.
///
/// 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.
@ObservationIgnored
public var makeHistoryProvider: (BoardStore, Tier) -> any HistoryProviding = { _, _ in
NativeHistoryProvider()
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)
}
// MARK: Sessions
@@ -323,7 +334,16 @@ 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`.
public let history: any HistoryProviding
///
/// **`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.
///
/// 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
/// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it `tier` has no setter.
public var history: (any HistoryProviding)?
/// **The tier this board composed under** (12-editions.md The entitlement).
///
@@ -724,10 +744,6 @@ public final class AppModel {
// also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing
// re-derives it.
let tier = currentTier()
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior".
let history = makeHistoryProvider(store, tier)
// **Mode detection** (06-history-undo.md Rules Detection: "checked at every board
// open"), on the same line as the tier that gates it. Under `.free` this returns `nil`
// without looking at the disk at all the inert posture is unconditional there and under
@@ -736,7 +752,17 @@ public final class AppModel {
//
// Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The
// running session keeps its mode, and the watcher does not scan for `.git` appearing."
//
// **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends
// on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the
// disk itself would be a second detection able to disagree with this one.
let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier)
// The board's stack is born here, with the session that owns it, and dies in `tearDown`
// below the whole of 13-native-undo.md's session-only persistence: "the stack lives with
// the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards
// it is instead the repository's own trail, which survives everything (06 Rules Undo
// survives relaunch) the seam's whole point.
let history = makeHistoryProvider(store, tier, git)
// **The loader's earlier-occurrence-wins history rung** (01-storage-format.md Fractal
// layout Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker,
// everything else keeps injecting nothing. A *provider* rather than a ranker because each
@@ -763,6 +789,20 @@ public final class AppModel {
store?.banners.clearHistorySuspension()
}
store.commitSeam = .binding(to: committer)
// **The undo stack's ear on the committer** every commit this engine lands, and
// which of it was heal work (06 Rules The stack is HEAD's first-parent ancestry,
// live; Heal commits are transparent to undo). Bound here rather than in
// `wireGitUndo` because add-git builds a *new* committer, and this wiring is what
// `activateAutoCommit` remembers on its behalf.
committer.reportLanded = { [weak self, ref] window in
guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return }
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.
git.didAddGit = { [weak self] in
self?.bindHistoryProvider(for: ref)
}
}
// **The binding 13-native-undo.md Rules' "registration at the Writer boundary" needs**: the
@@ -789,10 +829,127 @@ public final class AppModel {
cardRefs: [],
access: access
)
wireGitUndo(history, store: store, git: git, ref: ref)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
// MARK: - The git provider's wiring
/// Fills a `GitHistoryProvider`'s seams with the session it is the history of and does nothing
/// at all for any other substrate.
///
/// Everything the git provider needs is a fact about *this* board that neither a repository nor a
/// protocol could supply: which committer's debounce to settle first, whether the git surface is
/// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree
/// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason the provider
/// stays a thing that knows about commits, and the model stays the only object that knows what a
/// window is.
private func wireGitUndo(
_ history: (any HistoryProviding)?,
store: BoardStore,
git: HistoryStore?,
ref: BoardWindowRef
) {
guard let provider = history as? GitHistoryProvider, let git else { return }
provider.flushPendingCommit = { [weak git] in
await git?.committer?.flushNow()
}
provider.isHeld = { [weak git] in git?.committer?.pause != nil }
provider.suspendCommitting = { [weak git] in git?.committer?.stop() }
provider.resumeCommitting = { [weak git] in git?.committer?.start() }
// **A restore that failed cleanly** (06 Interaction with external writers: "surfaces as a
// one-shot banner failure naming the operation and the error, the tree left as it was").
//
// Posted as a **loss row**, and the compromise is recorded rather than hidden: the true
// failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed
// `WriteOperation` vocabulary and a git operation is deliberately not one of those
// (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest
// class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that
// can name both halves 06 asks for. A message-carrying failure class is the right fix and is
// a banner-surface change, not this card's.
provider.reportFailure = { [weak store] failure in
store?.banners.postLoss(failure.description)
}
provider.runBracketed = { [weak store] subject, work in
guard let store else { return await work() }
// The completion phrase 10-accessibility.md gives a bracketed operation is the restore's
// own subject the sentence the trail now carries, spoken once when the reload lands.
try? await store.performWholesale(announcing: subject) { await work() }
}
provider.settleSessions = { [weak self, weak provider] paths in
guard let self, let provider else { return .proceed }
return await self.settleGate(for: ref, provider: provider).settle(touching: paths)
}
provider.seed()
}
/// **The save-or-discard step for one board**, built from its open card windows
/// (06-history-undo.md Rules Undo restore vs open Edit sessions; Branch switching).
///
/// Built per ask rather than stored, because its whole content is "which card windows are open
/// right now" a set that changes under any operation slow enough to need the step at all.
func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate {
SessionSettleGate(
sessions: { [weak self] in
guard let self, let session = self.sessions[ref] else { return [] }
return session.cardRefs.compactMap { cardRef in
guard let flushing = self.cardSessions[cardRef],
let settlement = flushing.settlement else { return nil }
return SettleableSession(
id: cardRef.cardID,
cardFolderName: cardRef.cardID,
needsSettling: settlement.needsSettling,
saveAll: settlement.saveAll,
discard: { [weak provider] in
settlement.discard()
// The card's uncommitted on-disk saves are reverted by the restore
// itself, which compares this folder against the working tree rather
// than against HEAD see `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderPath: cardRef.cardID)
}
)
}
},
ask: { await SessionSettleStep.ask() },
focus: { [weak self] id in
guard let self, let session = self.sessions[ref] else { return }
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
// Opening a window that is already open is how SwiftUI's value-addressed groups say
// "bring that one forward" the same call `BoardWindowHost` makes to open a card, and
// the reason reopening a live card focuses its window rather than making a second one.
self.windowOpener?(id: WindowID.card, value: cardRef)
}
)
}
/// **Binds the git provider 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.
///
/// 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.
func bindHistoryProvider(for ref: BoardWindowRef) {
guard var session = sessions[ref], session.history == nil,
let git = session.git, git.mode == .git else { return }
guard let history = makeHistoryProvider(session.store, session.tier, git) else { return }
session.history = history
sessions[ref] = session
session.store.history = history
session.undoManager.history = history
wireGitUndo(history, store: session.store, git: git, ref: ref)
}
/// Registers a card window with its board's session, so the close flush can find it.
///
/// A card window whose board has no session is a card window with no board the ownership rule
@@ -1112,7 +1269,7 @@ public final class AppModel {
// Cleared rather than merely dropped because the steps hold closures over the store
// this line is about to release, and a stack that outlived its board would be a
// retain cycle wearing an undo stack's clothes.
session.history.clear()
session.history?.clear()
storeRegistry.release(session.store)
session.access?.stop()
}