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
+51 -5
View File
@@ -110,6 +110,38 @@ public struct GitChangedPath: Sendable, Equatable, Hashable {
// MARK: - A planned commit
/// **Which of 06's classes a planned commit belongs to** carried through the libgit2 work so a
/// landed commit can be recognized by the class that planned it.
///
/// It exists for one consumer: the undo provider's **heal transparency** (06-history-undo.md Rules
/// Heal commits are transparent to undo, in-session: "heal-class commits their paths known by the
/// Writer's heal-marked receipts never become undo steps"). Receipts live on the main actor and are
/// cleared the moment a window commits, so the only way the stack can ever learn *which commit* was
/// the heal is to be told at the moment it lands.
///
/// A tag rather than a re-derivation, deliberately: a plan whose staging produced HEAD's tree is
/// skipped and lands no commit at all, so the oids that come back are not positionally alignable with
/// the plans that were submitted.
public enum PlannedCommitKind: String, Sendable, Equatable, CaseIterable {
/// A repository's first commit "Initial board state", never split (06 Rules Abnormal repo
/// states).
case root
case foreign
case heal
case user
}
/// One commit that actually landed: its oid, and the class of the plan that made it.
public struct GitLandedCommit: Sendable, Equatable {
public let oid: String
public let kind: PlannedCommitKind
public init(oid: String, kind: PlannedCommitKind) {
self.oid = oid
self.kind = kind
}
}
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
///
/// A value rather than a call, because the flush's whole decision the three-way split, the
@@ -138,19 +170,33 @@ public struct PlannedCommit: Sendable, Equatable {
/// itself.
public let committer: GitIdentity
public init(paths: [String], message: String, author: GitIdentity, committer: GitIdentity) {
/// Which of 06's classes planned this carried so the landed commit can be recognized by it.
/// See `PlannedCommitKind`; defaulted so a caller with only one class to make (add-git's root
/// commit, the undo provider's restore) says nothing about a split it is not part of.
public let kind: PlannedCommitKind
public init(
paths: [String],
message: String,
author: GitIdentity,
committer: GitIdentity,
kind: PlannedCommitKind = .user
) {
self.paths = paths
self.message = message
self.author = author
self.committer = committer
self.kind = kind
}
}
/// How a flush ended the four outcomes 06 gives the committer, and no fifth.
public enum GitCommitOutcome: Sendable, Equatable {
/// One commit per planned commit that had anything in it, oldest first.
case committed([String])
/// One commit per planned commit that had anything in it, oldest first each carrying the class
/// of the plan that made it (`PlannedCommitKind`), which is how heal transparency reaches the
/// undo stack.
case committed([GitLandedCommit])
/// **The happy path, not a malfunction** (06 Interaction with external writers): the tree had
/// nothing to commit an agent already committed its own work, or the window held only paths
@@ -452,11 +498,11 @@ enum GitCommitOperation {
// own terms.
resetIndexToHead(index, in: repository)
var landed: [String] = []
var landed: [GitLandedCommit] = []
for plan in commits {
switch commit(plan, in: repository, index: index) {
case let .landed(oid):
landed.append(oid)
landed.append(GitLandedCommit(oid: oid, kind: plan.kind))
case .skipped:
continue
case let .stopped(outcome):