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
+100 -22
View File
@@ -1,6 +1,44 @@
import Foundation
import os
// MARK: - GitLandedWindow
/// **One debounce window's commits, as the undo stack needs to hear about them** see
/// `GitAutoCommitter.reportLanded`.
///
/// The heal halves are separated because the two rules they serve are separate: `healOIDs` is what
/// makes the *pointer* pass over a heal commit, and `healPaths` is what makes a restore's
/// materialized diff **exclude** the paths whose divergence is heal work (06-history-undo.md Rules
/// Heal commits are transparent to undo, in-session both halves, stated in one sentence).
public struct GitLandedWindow: Sendable, Equatable {
/// Every commit the window landed, oldest first.
public let commits: [GitLandedCommit]
/// Board-root-relative paths this window committed as heal work.
public let healPaths: Set<String>
public init(commits: [GitLandedCommit], healPaths: Set<String>) {
self.commits = commits
self.healPaths = healPaths
}
/// The oids of the heal-class commits the ones the undo pointer passes over.
public var healOIDs: Set<String> {
Set(commits.filter { $0.kind == .heal }.map(\.oid))
}
/// Whether *everything* this window landed was heal work.
///
/// The distinction the stack acts on: a window of nothing but heal commits must leave the undo
/// pointer and the redo stack exactly where they were "the fresh heal commit is in-session,
/// transparent, and the undo run continues past it" (06). A window carrying anything else is an
/// ordinary arrival, and arrivals clear redo.
public var isEntirelyHeal: Bool {
!commits.isEmpty && commits.allSatisfy { $0.kind == .heal }
}
}
// MARK: - GitAutoCommitter
/// **Every settled change becomes a commit** (06-history-undo.md Rules Auto-commit), debounced
@@ -110,6 +148,22 @@ public final class GitAutoCommitter {
@ObservationIgnored
public var reportRecovery: (@MainActor () -> Void)?
/// **What a flush landed, and which of it was heal work** the undo stack's in-session ear
/// (06-history-undo.md Rules The stack is HEAD's first-parent ancestry, live; Heal commits
/// are transparent to undo, in-session).
///
/// Two facts travel here and nowhere else can carry them. **Liveness**: "foreign commits
/// push onto the in-session undo stack as ordinary steps as they land", and a commit this engine
/// made is the one kind of arrival the stack could otherwise only discover by polling HEAD.
/// **Heal transparency**: the heal class is known from the Writer's heal-marked receipts, which
/// 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).
@ObservationIgnored
public var reportLanded: (@MainActor (GitLandedWindow) -> Void)?
// MARK: - Observable state
/// **The repository state the engine is holding for**, or `nil` when it is free to commit
@@ -247,7 +301,8 @@ public final class GitAutoCommitter {
// One attempt, no lock backoff: this path cannot suspend, and a held lock here simply means
// the foreign version commits on the next quiet debounce instead which is the same
// "re-debounce" answer contention gets everywhere else.
apply(Self.execute(input))
let result = Self.execute(input)
apply(result.outcome, healPaths: result.healPaths)
}
// MARK: - Edit sessions
@@ -324,12 +379,12 @@ public final class GitAutoCommitter {
// The brief backoff. Off the main actor for the git work, on it for the sleep, so a held
// lock costs a couple of suspended turns rather than a blocked UI.
for attempt in 0...max(0, lockRetryAttempts) {
let outcome = await Task.detached(priority: .utility) { Self.execute(input) }.value
if case .locked = outcome, attempt < max(0, lockRetryAttempts) {
let result = await Task.detached(priority: .utility) { Self.execute(input) }.value
if case .locked = result.outcome, attempt < max(0, lockRetryAttempts) {
try? await Task.sleep(for: lockRetryDelay)
continue
}
apply(outcome)
apply(result.outcome, healPaths: result.healPaths)
return
}
}
@@ -355,35 +410,45 @@ public final class GitAutoCommitter {
)
}
/// What one flush concluded the outcome, plus the paths it committed as heal work.
///
/// The second half exists for `reportLanded`: heal paths are known only inside the split, which
/// runs here, and the stack that needs them lives on the main actor.
private struct FlushOutput: Sendable {
let outcome: GitCommitOutcome
var healPaths: Set<String> = []
}
/// **One whole flush**, off the main actor: read the state, list the tree's changes, stage around
/// the open sessions, split by provenance, compose, commit.
private nonisolated static func execute(_ input: FlushInput) -> GitCommitOutcome {
private nonisolated static func execute(_ input: FlushInput) -> FlushOutput {
let reading = GitCommitOperation.reading(at: input.boardRoot)
if let pause = reading.pause { return .held(pause) }
if reading.isIndexLocked { return .locked }
if let pause = reading.pause { return FlushOutput(outcome: .held(pause)) }
if reading.isIndexLocked { return FlushOutput(outcome: .locked) }
// `nil` is "the survey could not be taken" an unwritable object store, a corrupt index
// and it must not read as a clean tree: that would no-op silently and let history stop
// advancing with nothing on the banner strip (06 Interaction with external writers, the
// genuine-failure clause).
guard let surveyed = GitCommitOperation.surveyChangedPaths(at: input.boardRoot) else {
return .failed(GitOperationFailure(
return FlushOutput(outcome: .failed(GitOperationFailure(
operation: "Recording this board's history",
message: "this board's repository could not be read"
))
)))
}
let changed = surveyed
.filter { !isExcluded($0.path, under: input.boardRoot, by: input.excludedFolders) }
guard !changed.isEmpty else { return .nothingToCommit }
guard !changed.isEmpty else { return FlushOutput(outcome: .nothingToCommit) }
return GitCommitOperation.perform(
at: input.boardRoot,
commits: plan(
changed,
reading: reading,
input: input,
composition: composition(for: changed, input: input)
)
let commits = plan(
changed,
reading: reading,
input: input,
composition: composition(for: changed, input: input)
)
return FlushOutput(
outcome: GitCommitOperation.perform(at: input.boardRoot, commits: commits),
healPaths: Set(commits.filter { $0.kind == .heal }.flatMap(\.paths))
)
}
@@ -475,7 +540,8 @@ public final class GitAutoCommitter {
paths: changed.map(\.path),
message: input.composer.message(for: request(changed, .user, isRootCommit: true)),
author: user,
committer: user
committer: user,
kind: .root
)]
}
@@ -497,11 +563,18 @@ public final class GitAutoCommitter {
}
let author: GitIdentity
if case let .foreign(identity) = authorship { author = identity } else { author = user }
let kind: PlannedCommitKind
switch group.kind {
case .foreign: kind = .foreign
case .heal: kind = .heal
case .user: kind = .user
}
return PlannedCommit(
paths: group.paths.map(\.path),
message: input.composer.message(for: request(group.paths, authorship)),
author: author,
committer: user
committer: user,
kind: kind
)
}
}
@@ -519,13 +592,18 @@ public final class GitAutoCommitter {
// MARK: - Outcomes
private func apply(_ outcome: GitCommitOutcome) {
private func apply(_ outcome: GitCommitOutcome, healPaths: Set<String> = []) {
switch outcome {
case let .committed(oids):
case let .committed(landed):
let oids = landed.map(\.oid)
pause = nil
lastFailure = nil
commitCount += oids.count
lastCommitOIDs = oids
// **The stack hears about every commit this engine lands** (06 Rules The stack is
// HEAD's first-parent ancestry, live), *before* the receipts that describe them are
// cleared below the heal class exists only for as long as they do.
reportLanded?(GitLandedWindow(commits: landed, healPaths: healPaths))
// The window is over: its receipts have said everything they can say, and keeping them
// would let them vouch for the *next* window's changes to the same paths.
harvested.removeAll()