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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user