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) 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 = [] /// Paths committed as heal work in this session, which a restore never materializes. private var healPaths: Set = [] /// 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? /// **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 — except the one thing a dropped step is owed.** 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. /// /// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the /// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is /// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge /// rides the close flush there as before" (13 ▸ Interaction with the trash). A card window's close /// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs /// immediately — at the close flush, exactly where it ran before this milestone — with no call /// site anywhere asking which substrate it is talking to. public func register(_ step: HistoryStep) { step.retirement?.run() } 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 = [] 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 = [] /// **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(cardFolderName: String) { discardedFolders.insert(cardFolderName) } // 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..