Files
lanework/Kanban/Git/GitHistoryProvider.swift
T
rzen 142c6e75fe 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
2026-07-31 15:54:22 -04:00

478 lines
23 KiB
Swift

import Foundation
import os
// MARK: - GitHistoryProvider
/// **Pro's undo substrate: the commit trail itself** (06-history-undo.md; 12-editions.md ▸ The
/// provider seam) — the second implementation of `HistoryProviding`, and the one the seam was
/// designed around.
///
/// ### The stack is not a stack
///
/// "The stack **is** HEAD's first-parent ancestry, live" (06 ▸ Rules). Nothing here records a step
/// when the board is written to; `register(_:)` is a deliberate no-op, because on a git board an undo
/// step is a *commit* and commits are made by the auto-committer, by an agent, or by a terminal. What
/// this object holds is a **pointer into that ancestry** — which commit ⌘Z would cross next — plus a
/// redo list of the commits already crossed in this session. Both are caches over a repository that
/// remains the only truth, which is what makes "no sidecar state, nothing ever lost" (14 ▸ C8) a
/// property of the shape rather than a discipline.
///
/// ### Four rules, and where each one lives
///
/// - **Forward only.** A crossing writes an older state as a *new commit* — `GitRestoreOperation`,
/// which cannot reset because it never resolves a reset symbol. Old commits stay reachable; refs
/// only move forward.
/// - **Exactly one commit per ⌘Z.** The pre-flight sync (`syncToHEAD`) re-reads HEAD before every
/// crossing, so agents' self-commits landed since the last operation become the new top and ⌘Z
/// steps back over *them* rather than silently reverting twenty minutes of their work.
/// - **Any arrival clears redo.** From the pre-flight sync for commits made outside the app, and from
/// `noteLanded(_:)` for the ones this app's committer made — with the one exception the heal rule
/// requires (below).
/// - **In-session and post-relaunch are one rule.** `reseed()` is the same ancestry walk from
/// scratch, so a relaunch, a branch switch and a foreign arrival all take the same path.
///
/// ### Heal transparency, and its honest limit
///
/// Heal-class commits never become steps: the pointer passes over them, and a restore excludes the
/// paths whose divergence is heal work — so a ⌘Z run never reverts a repair and never re-arms the
/// healer (06 ▸ Rules ▸ Heal commits are transparent to undo, in-session). Both halves are learned
/// from `GitAutoCommitter.reportLanded`, which fires while the Writer's heal-marked receipts still
/// exist. **In-session is the whole of it, deliberately**: the reseed is sidecar-free, so after a
/// relaunch old heal commits reappear as ordinary steps — the accepted one-bounce residual, named in
/// 06 and not worked around here.
///
/// ### Asynchrony
///
/// `HistoryProviding.undo()` is synchronous because a menu item is; a restore is a settle step, a
/// libgit2 diff, a set of writes and a commit. So the protocol methods start a `Task` and return, and
/// `cross(_:)` is the awaitable one a test drives. Enablement never waits on any of it: `canUndo`,
/// `canRedo` and both action names answer from the cached ancestry, so menu validation costs nothing.
@MainActor
@Observable
public final class GitHistoryProvider: HistoryProviding {
// MARK: - Identity
/// The board this is the history of — in git mode, the repository's working-tree root.
public let boardRoot: URL
// MARK: - Seams
/// **The pending auto-commit, flushed before a restore commits** (06 ▸ Rules ▸ Flush-before-
/// overwrite, applied here by the card's own rule: settled tree first, then one more commit).
///
/// Without it a ⌘Z would commit an older state on top of edits that never got a commit of their
/// own — the forward trail would be missing the very version the undo is stepping back from.
@ObservationIgnored
public var flushPendingCommit: (@MainActor () async -> Void)?
/// Whether the git surface is **held** — a detached HEAD or an in-progress merge/rebase
/// (06 ▸ Rules ▸ Abnormal repo states: "Undo/Redo and the branch controls disable"). Reads
/// `GitAutoCommitter.pause`, which is in-memory state, so enablement stays free.
@ObservationIgnored
public var isHeld: (@MainActor () -> Bool)?
/// Stops and restarts the auto-commit debounce around a restore, so its own writes cannot be
/// half-committed by a timer that fires mid-materialization.
@ObservationIgnored
public var suspendCommitting: (@MainActor () -> Void)?
@ObservationIgnored
public var resumeCommitting: (@MainActor () -> Void)?
/// **The save-or-discard step** (06 ▸ Rules ▸ Undo restore vs open Edit sessions). `nil` is a
/// board with no card windows to settle — every storeless test, and a session composed before any
/// window opened.
@ObservationIgnored
public var settleSessions: (@MainActor (Set<String>) async -> SessionSettleOutcome)?
/// Runs the restore inside the store's wholesale bracket — watcher suspended, one full reload at
/// the end, the board locked if that reload fails (02-architecture.md; `BoardStore.performWholesale`).
/// `nil` runs the work bare, which is what a repository-level test wants.
@ObservationIgnored
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
// MARK: - The cached stack
/// HEAD's first-parent ancestry as of the last sync, newest first. The *stack*, cached.
public private(set) var ancestry: [GitCommitRecord] = []
/// The oid of the commit ⌘Z would cross next, or `nil` before the first seed. Not always
/// `ancestry.first`: after an undo the pointer sits below the restore commit the undo just made,
/// which is the whole mechanism behind "the undo-menu labels are the *crossed* commit's subject,
/// so labels never nest" (06 ▸ Commit messages).
public private(set) var pointerOID: String?
/// Commits crossed by ⌘Z in this session, oldest crossed first — ⇧⌘Z restores the state *at* the
/// last of them. Empty on every seed: "redo starts empty" (06 ▸ Rules ▸ Undo survives relaunch).
public private(set) var redoCommits: [GitCommitRecord] = []
/// The HEAD this cache was built against — the pre-flight sync's comparison.
private var knownHead: String?
/// Heal-class commits landed **in this session**, which the pointer passes over.
private var healOIDs: Set<String> = []
/// Paths committed as heal work in this session, which a restore never materializes.
private var healPaths: Set<String> = []
/// Whether a crossing is in flight — a second ⌘Z during a restore must not start a second one.
public private(set) var isCrossing = false
/// How many restores this provider has landed — the trail's own testimony, so a test need not
/// infer a crossing from a commit walk.
public private(set) var restoreCount = 0
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
public init(boardRoot: URL) {
self.boardRoot = boardRoot
}
// MARK: - Seeding
/// **Reseeds the stack from HEAD's first-parent ancestry, with an empty redo** — the API a board
/// open, a relaunch, and a **branch switch** all enter through (06 ▸ Rules ▸ Undo survives
/// relaunch; ▸ Branch switching: "The undo/redo stack does not survive a switch. It is discarded
/// and reseeded from the new HEAD's first-parent ancestry … redo starts empty").
///
/// Synchronous and off the main actor is not an option — the walk is libgit2 — so this is the
/// awaitable seed and `seed()` is the fire-and-forget one an open path can call.
public func reseed() async {
let root = boardRoot
let records = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.ancestry(at: root)
}.value
adopt(records)
}
/// `reseed()` without waiting — what a board open and an add-git flip call.
public func seed() {
Task { await reseed() }
}
/// The reseed's main-actor half, split out so the sync path can reuse it.
private func adopt(_ records: [GitCommitRecord]) {
ancestry = records
knownHead = records.first?.oid
pointerOID = records.first?.oid
redoCommits = []
}
/// **The pre-flight sync** (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live):
/// "The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the
/// app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly
/// **one** commit."
///
/// One reference read when nothing moved, a full reseed when something did. A reseed here is the
/// same reseed a relaunch does, which is the point: "In-session and post-relaunch behavior are
/// thereby one rule."
private func syncToHEAD() async {
let root = boardRoot
let head = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.headOID(at: root)
}.value
guard head != knownHead else { return }
Self.logger.debug("undo stack re-syncing: HEAD moved outside the stack's knowledge")
await reseed()
}
// MARK: - What the committer tells it
/// **A flush landed** — `GitAutoCommitter.reportLanded`.
///
/// Two behaviours, and the split between them is the heal rule:
///
/// - A window of **nothing but heal commits** leaves the pointer and the redo list exactly where
/// they were, and only records what was healed. That is what keeps an undo run from being
/// trapped on an ever-renewing top: "the fresh heal commit is in-session, transparent, and the
/// undo run continues past it" (06).
/// - **Anything else is an arrival**, and "any commit arriving from anywhere clears the redo
/// stack (classic behavior)" — with the new commit becoming the top of the stack, so the next
/// ⌘Z crosses what just happened.
public func noteLanded(_ window: GitLandedWindow) {
healOIDs.formUnion(window.healOIDs)
healPaths.formUnion(window.healPaths)
guard !window.commits.isEmpty else { return }
// The walk is libgit2 work and the committer reports from a synchronous outcome handler, so
// the cache catches up on its own turn. `settled()` is how anything that must not race it
// waits — `cross(_:)` first of all.
refresh = Task { [weak self] in
guard let self else { return }
if window.isEntirelyHeal {
// The ancestry gained a commit the pointer must be able to walk past; the pointer and
// the redo list are untouched.
await self.refreshAncestryKeepingPointer()
} else {
await self.reseed()
}
}
}
/// The cache catch-up started by the last `noteLanded(_:)`, if it is still running.
@ObservationIgnored
private var refresh: Task<Void, Never>?
/// **Waits for the cache to have heard about the last commit** — so "⌘Z now crosses what just
/// landed" is a fact to await rather than a race.
///
/// Every crossing awaits it, which is the production caller; a test awaits it to assert on
/// enablement the instant a flush returns, where a menu would simply be validated a turn later.
public func settled() async {
await refresh?.value
refresh = nil
}
/// Re-reads the ancestry without disturbing the pointer or the redo list — the heal window's
/// path, and the one every successful restore takes.
private func refreshAncestryKeepingPointer() async {
let root = boardRoot
let records = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.ancestry(at: root)
}.value
ancestry = records
knownHead = records.first?.oid
if let pointerOID, !records.contains(where: { $0.oid == pointerOID }) {
// The pointer's commit is no longer in HEAD's first-parent ancestry — a rebase remapped
// it (07-sync-collab.md's pull). The honest answer is the seed's: start again from the
// top, redo empty.
adopt(records)
}
}
// MARK: - HistoryProviding
/// **Deliberately nothing.** On a git board an undo step is a commit, and the Writer boundary's
/// inverse operations are the *free* tier's substrate (13-native-undo.md). `BoardStore` registers
/// against whatever provider the session bound, and this one has a repository to read instead —
/// so the registrations arrive and are dropped, which is exactly what "the commit trail itself is
/// the substrate" (14 ▸ C1) means in code.
public func register(_ step: HistoryStep) {}
public var canUndo: Bool {
guard !isCrossing, isHeld?() != true else { return false }
return crossableIndex() != nil
}
public var canRedo: Bool {
guard !isCrossing, isHeld?() != true else { return false }
return !redoCommits.isEmpty
}
/// **The crossed commit's own subject** (06 ▸ Commit messages: "the undo-menu labels are the
/// *crossed* commit's subject, so labels never nest") — so the Edit menu reads "Undo Move card
/// 'Fix login' to Doing", never "Undo Undo: …" for a restore this session made.
public var undoActionName: String? {
guard canUndo, let index = crossableIndex() else { return nil }
return ancestry[index].subject
}
public var redoActionName: String? {
guard canRedo else { return nil }
return redoCommits.last?.subject
}
public func undo() {
Task { await cross(.undo) }
}
public func redo() {
Task { await cross(.redo) }
}
/// Drops the cache. The session's teardown, and nothing else — the *repository* is untouched, so
/// a board reopened a second later has exactly the same trail.
public func clear() {
ancestry = []
pointerOID = nil
redoCommits = []
knownHead = nil
healOIDs = []
healPaths = []
}
// MARK: - The crossing
/// One ⌘Z or ⇧⌘Z, awaitable — the whole restore, in the order the rules fix it.
public func cross(_ direction: HistoryDirection) async {
guard !isCrossing, isHeld?() != true else { return }
isCrossing = true
defer { isCrossing = false }
// **The settled tree first** (06 ▸ Rules ▸ Flush-before-overwrite, and this card's own rule):
// whatever the debounce is still holding becomes a commit of its own before a restore lands
// on top of it, so both states exist in the trail.
await flushPendingCommit?()
await settled()
await syncToHEAD()
switch direction {
case .undo:
guard let index = crossableIndex() else { return }
let crossed = ancestry[index]
guard let target = crossed.parentOID else { return }
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
guard landed else { return }
redoCommits.append(crossed)
pointerOID = target
await refreshAncestryKeepingPointer()
case .redo:
guard let target = redoCommits.last else { return }
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
guard landed else { return }
redoCommits.removeLast()
// The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance,
// with the pointer where the undo found it.
pointerOID = target.oid
await refreshAncestryKeepingPointer()
}
}
/// Materializes one target state as a new commit. Answers whether the crossing may advance.
///
/// `message` is both the commit's subject and the bracket's completion announcement
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
/// completion") — one sentence, so the trail and the speech cannot disagree about what happened.
private func restore(to target: String, message: String) async -> Bool {
let root = boardRoot
let excluded = healPaths
// The **preliminary** plan: what the restore would write, which is the only thing that can
// say whether any open session is in its way.
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
}).value else {
report("this board's repository could not be read")
return false
}
var reconciling: Set<String> = []
if !preliminary.isEmpty, let settleSessions {
switch await settleSessions(Set(preliminary.paths)) {
case .cancelled, .failed:
// "Cancel keeps everything" — and a raw buffer that would not validate cancels the
// whole restore, focused on the offender (06 ▸ Branch switching).
return false
case .proceed:
reconciling = discardedFolders
discardedFolders = []
}
if reconciling.isEmpty {
// **Save All ended sessions, which commits them**: the tree moved, so the plan is
// recomputed below against the HEAD that now exists rather than the one it was
// drafted against.
//
// **Discard deliberately does not flush.** Ending a session un-stages-around its
// folder, so a flush here would commit exactly the uncommitted saves the user just
// asked to lose — a Discard that wrote them into history forever. Nothing is left
// behind by skipping it: everything else pending was already flushed at the top of
// the crossing, and the discarded folder is reconciled against the working tree by
// the plan itself.
await flushPendingCommit?()
await settled()
}
}
// Bound before the closure that crosses actors reads it — the settle step is over, and what
// it decided is a value from here on.
let folders = reconciling
var landed = false
let work: @MainActor () async -> Void = { [weak self] in
guard let self else { return }
self.suspendCommitting?()
defer { self.resumeCommitting?() }
let outcome = await Task.detached(priority: .userInitiated, operation: {
guard let plan = GitRestoreOperation.plan(
at: root,
target: target,
excluding: excluded,
reconciling: folders
) else {
return GitCommitOutcome.failed(GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: "this board's repository could not be read"
))
}
return GitRestoreOperation.apply(plan, at: root, message: message)
}).value
switch outcome {
case .committed:
self.restoreCount += 1
landed = true
case .nothingToCommit:
// The step was crossed and needed no bytes — every path its diff would have written
// was heal work, or the two states are byte-identical. The pointer still advances:
// a step that changed nothing is still a step the user asked to walk past.
landed = true
case .locked:
// "Contention outlasting the brief retry surfaces as a *waiting* state" (06); the
// in-progress banner row is the branch card's surface. Here the honest answer is to
// leave the stack where it is so ⌘Z can simply be pressed again.
Self.logger.debug("restore found index.lock held — the stack is unchanged")
case let .held(pause):
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
case let .failed(failure):
self.reportFailure?(failure)
}
}
if let runBracketed {
await runBracketed(message, work)
} else {
await work()
}
return landed
}
/// Folders the settle step's Discard branch left for the plan to reconcile against the working
/// tree. Filled by the gate's wiring through `noteDiscarded(_:)`.
private var discardedFolders: Set<String> = []
/// **A settle step discarded this card's session** — its folder is compared against the working
/// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
/// reverted by the restore itself rather than by a second pass that could disagree with it
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
public func noteDiscarded(cardFolderPath: String) {
discardedFolders.insert(cardFolderPath)
}
// MARK: - The pointer
/// The index in `ancestry` of the commit ⌘Z would cross, or `nil` when there is none.
///
/// Two commits are never steps:
///
/// - **Heal commits**, which the pointer passes over (06 ▸ Rules ▸ Heal commits are transparent).
/// - **The root commit** — a judgment call, recorded. It has no parent, so "the state before it"
/// is the empty tree: crossing it would delete every file the board has ever had, in one
/// keystroke, on a board whose entire history is that one commit. 06 says an unborn repository's
/// "undo trail simply starts empty"; a repository with exactly one commit is that repository one
/// commit later, and the honest reading is that the board's existence is not a step. (Nothing is
/// lost either way: the commit stays reachable in any git client.)
private func crossableIndex() -> Int? {
guard !ancestry.isEmpty else { return nil }
let start = pointerOID.flatMap { oid in ancestry.firstIndex { $0.oid == oid } } ?? 0
for index in start..<ancestry.count {
let commit = ancestry[index]
guard !healOIDs.contains(commit.oid) else { continue }
guard commit.parentOID != nil else { return nil }
return index
}
return nil
}
private func report(_ message: String) {
reportFailure?(GitOperationFailure(
operation: GitRestoreOperation.operationName,
message: message
))
}
}