From 142c6e75fe131576c91b46fecc38fdb189edef8e Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 31 Jul 2026 15:54:22 -0400 Subject: [PATCH] Implement undo and redo as forward commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: "; 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 --- Kanban/App/AppModel.swift | 191 +++- Kanban/App/CardWindowHost.swift | 87 ++ Kanban/App/CloseFlushCoordinator.swift | 12 + Kanban/App/SessionSettleGate.swift | 253 +++++ Kanban/Git/GitAutoCommitter.swift | 122 ++- Kanban/Git/GitCommitOperation.swift | 56 +- Kanban/Git/GitHistoryProvider.swift | 477 ++++++++++ Kanban/Git/GitHistoryWalk.swift | 213 +++++ Kanban/Git/GitRestoreOperation.swift | 337 +++++++ Kanban/Git/HistoryStore.swift | 14 + Kanban/History/BoardUndoManager.swift | 43 +- Kanban/LiveStore/BoardStore.swift | 33 + Kanban/UI/Card/CardBodyEditSession.swift | 22 + Kanban/UI/Card/CardHistorySection.swift | 185 ++++ Kanban/UI/Card/CardWindowView.swift | 27 +- KanbanTests/GitUndoTests.swift | 1100 ++++++++++++++++++++++ KanbanTests/HistoryProviderTests.swift | 22 +- KanbanTests/HistoryStoreTests.swift | 6 +- README.md | 8 +- 19 files changed, 3126 insertions(+), 82 deletions(-) create mode 100644 Kanban/App/SessionSettleGate.swift create mode 100644 Kanban/Git/GitHistoryProvider.swift create mode 100644 Kanban/Git/GitHistoryWalk.swift create mode 100644 Kanban/Git/GitRestoreOperation.swift create mode 100644 Kanban/UI/Card/CardHistorySection.swift create mode 100644 KanbanTests/GitUndoTests.swift diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index b61fd4f..dd91722 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -290,19 +290,30 @@ public final class AppModel { /// can bind a fake without a second `AppModel` initializer, `@ObservationIgnored` because /// nothing renders from it. /// - /// ### The `Tier` argument, and the one consumer it is still short + /// ### The three answers, and the `nil` among them /// - /// **`pro-m1` is the consumer that will switch on it.** The default closure ignores the tier - /// today and binds the native stack for both — not because the seam is decorative, but because - /// the git provider it would bind does not exist yet (12 ▸ Tier matrix: git init/adoption, - /// git-backed undo and the history surfaces are all Pro-tier work, designed in 06-history-undo.md - /// and unbuilt). The argument is here now so that arriving milestone is one closure body rather - /// than a change to the composition root, and so that the tier a board actually composed under is - /// a recorded fact from today (`BoardSession.tier`) instead of something pro-m1 has to introduce - /// alongside its provider. + /// The closure is now the whole tier matrix, in three lines (12 ▸ Tier matrix; 06 ▸ Rules): + /// + /// - **Free** — the native stack, on every board. There is no `HistoryStore` at all off Pro, so + /// the absent git state *is* the tier test; nothing here reads a flag. + /// - **Pro, mode `git`** — the git provider: undo as forward restore commits over HEAD's + /// first-parent ancestry. + /// - **Pro, mode `none` or `repoNested`** — **no provider**. "A board without git has no + /// undo/redo", and a repo-nested board is one the app "leaves strictly alone" — so Edit + /// ▸ Undo/Redo and the toolbar pair disable there ("the pair disabled on boards with no undo + /// provider in the composed tier — under Pro, no-git and repo-nested boards, matching their menu + /// items", 03-board-ui.md ▸ Toolbar). Not the native stack: on Pro, a mode-none board's edits + /// are deliberately unhistoried, and half-undoing them from an in-memory stack would be a second + /// substrate the design does not have. + /// + /// The `HistoryStore` argument is what makes that decidable here, and it is why `beginSession` + /// composes the git state *before* the provider: which substrate a board gets is a question about + /// its repository, and a root that had to ask the disk itself would be a second detection. @ObservationIgnored - public var makeHistoryProvider: (BoardStore, Tier) -> any HistoryProviding = { _, _ in - NativeHistoryProvider() + public var makeHistoryProvider: (BoardStore, Tier, HistoryStore?) -> (any HistoryProviding)? = { store, _, git in + guard let git else { return NativeHistoryProvider() } + guard git.mode == .git else { return nil } + return GitHistoryProvider(boardRoot: store.rootURL) } // MARK: Sessions @@ -323,7 +334,16 @@ public final class AppModel { /// /// Which implementation it is, is the tier's answer and nobody else's /// (12-editions.md ▸ The provider seam) — see `AppModel.makeHistoryProvider`. - public let history: any HistoryProviding + /// + /// **`nil` is a board with no undo at all** — under Pro, mode `none` and repo-nested boards + /// (06-history-undo.md ▸ Rules: "A board without git has **no undo/redo**"). The command + /// surface disables through `undoManager`, which answers the empty way over an absent + /// substrate. + /// + /// A `var`, unlike `tier` beside it, and for one event only: **add-git**, the design's single + /// sanctioned mid-session mode flip, binds a provider here on the board it flips + /// (`bindHistoryProvider(for:)`). A tier lapse still cannot touch it — `tier` has no setter. + public var history: (any HistoryProviding)? /// **The tier this board composed under** (12-editions.md ▸ The entitlement). /// @@ -724,10 +744,6 @@ public final class AppModel { // also the *only* time this board asks: the answer becomes `BoardSession.tier` and nothing // re-derives it. let tier = currentTier() - // The board's stack is born here, with the session that owns it, and dies in `tearDown` - // below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with - // the board session and dies at close/quit ... standard macOS behavior". - let history = makeHistoryProvider(store, tier) // **Mode detection** (06-history-undo.md ▸ Rules ▸ Detection: "checked at every board // open"), on the same line as the tier that gates it. Under `.free` this returns `nil` // without looking at the disk at all — the inert posture is unconditional there — and under @@ -736,7 +752,17 @@ public final class AppModel { // // Deliberately *not* re-run anywhere: no reload path, no watcher event, nothing. "The // running session keeps its mode, and the watcher does not scan for `.git` appearing." + // + // **Before the provider**, which is new in pro-m1: which substrate a board's undo is depends + // on the mode this line detects (`makeHistoryProvider`), and a root that had to look at the + // disk itself would be a second detection able to disagree with this one. let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier) + // The board's stack is born here, with the session that owns it, and dies in `tearDown` + // below — the whole of 13-native-undo.md's session-only persistence: "the stack lives with + // the board session and dies at close/quit ... standard macOS behavior". On Pro's git boards + // it is instead the repository's own trail, which survives everything (06 ▸ Rules ▸ Undo + // survives relaunch) — the seam's whole point. + let history = makeHistoryProvider(store, tier, git) // **The loader's earlier-occurrence-wins history rung** (01-storage-format.md ▸ Fractal // layout ▸ Rules; `BoardLoader.IdentityHistoryRanker`): git-mode boards get a ranker, // everything else keeps injecting nothing. A *provider* rather than a ranker because each @@ -763,6 +789,20 @@ public final class AppModel { store?.banners.clearHistorySuspension() } store.commitSeam = .binding(to: committer) + // **The undo stack's ear on the committer** — every commit this engine lands, and + // which of it was heal work (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, + // live; ▸ Heal commits are transparent to undo). Bound here rather than in + // `wireGitUndo` because add-git builds a *new* committer, and this wiring is what + // `activateAutoCommit` remembers on its behalf. + committer.reportLanded = { [weak self, ref] window in + guard let provider = self?.sessions[ref]?.history as? GitHistoryProvider else { return } + provider.noteLanded(window) + } + } + // **Add-git binds undo too** (06 ▸ Rules ▸ Detection — the one commanded mid-session mode + // flip). See `bindHistoryProvider(for:)` for the judgment call this records. + git.didAddGit = { [weak self] in + self?.bindHistoryProvider(for: ref) } } // **The binding 13-native-undo.md ▸ Rules' "registration at the Writer boundary" needs**: the @@ -789,10 +829,127 @@ public final class AppModel { cardRefs: [], access: access ) + wireGitUndo(history, store: store, git: git, ref: ref) clearLaunchFailures(naming: [ref.path, store.rootURL.path]) refreshRecents() } + // MARK: - The git provider's wiring + + /// Fills a `GitHistoryProvider`'s seams with the session it is the history of — and does nothing + /// at all for any other substrate. + /// + /// Everything the git provider needs is a fact about *this* board that neither a repository nor a + /// protocol could supply: which committer's debounce to settle first, whether the git surface is + /// held, which card windows a restore's diff would disturb, and the bracket a wholesale tree + /// change runs inside. Each arrives as a closure for `HistoryCommitSeam`'s reason — the provider + /// stays a thing that knows about commits, and the model stays the only object that knows what a + /// window is. + private func wireGitUndo( + _ history: (any HistoryProviding)?, + store: BoardStore, + git: HistoryStore?, + ref: BoardWindowRef + ) { + guard let provider = history as? GitHistoryProvider, let git else { return } + + provider.flushPendingCommit = { [weak git] in + await git?.committer?.flushNow() + } + provider.isHeld = { [weak git] in git?.committer?.pause != nil } + provider.suspendCommitting = { [weak git] in git?.committer?.stop() } + provider.resumeCommitting = { [weak git] in git?.committer?.start() } + // **A restore that failed cleanly** (06 ▸ Interaction with external writers: "surfaces as a + // one-shot banner failure naming the operation and the error, the tree left as it was"). + // + // Posted as a **loss row**, and the compromise is recorded rather than hidden: the true + // failure class (`OneShotBanner`) carries a `BoardWriteError`, whose `operation` is the closed + // `WriteOperation` vocabulary — and a git operation is deliberately not one of those + // (`BoardStore.performWholesale`'s own note says so). The loss row is the nearest honest + // class: warning tone, one-shot lifecycle, never auto-expires, and a free-form message that + // can name both halves 06 asks for. A message-carrying failure class is the right fix and is + // a banner-surface change, not this card's. + provider.reportFailure = { [weak store] failure in + store?.banners.postLoss(failure.description) + } + provider.runBracketed = { [weak store] subject, work in + guard let store else { return await work() } + // The completion phrase 10-accessibility.md gives a bracketed operation is the restore's + // own subject — the sentence the trail now carries, spoken once when the reload lands. + try? await store.performWholesale(announcing: subject) { await work() } + } + provider.settleSessions = { [weak self, weak provider] paths in + guard let self, let provider else { return .proceed } + return await self.settleGate(for: ref, provider: provider).settle(touching: paths) + } + provider.seed() + } + + /// **The save-or-discard step for one board**, built from its open card windows + /// (06-history-undo.md ▸ Rules ▸ Undo restore vs open Edit sessions; ▸ Branch switching). + /// + /// Built per ask rather than stored, because its whole content is "which card windows are open + /// right now" — a set that changes under any operation slow enough to need the step at all. + func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate { + SessionSettleGate( + sessions: { [weak self] in + guard let self, let session = self.sessions[ref] else { return [] } + return session.cardRefs.compactMap { cardRef in + guard let flushing = self.cardSessions[cardRef], + let settlement = flushing.settlement else { return nil } + return SettleableSession( + id: cardRef.cardID, + cardFolderName: cardRef.cardID, + needsSettling: settlement.needsSettling, + saveAll: settlement.saveAll, + discard: { [weak provider] in + settlement.discard() + // The card's uncommitted on-disk saves are reverted by the restore + // itself, which compares this folder against the working tree rather + // than against HEAD — see `GitRestoreOperation.plan`. + provider?.noteDiscarded(cardFolderPath: cardRef.cardID) + } + ) + } + }, + ask: { await SessionSettleStep.ask() }, + focus: { [weak self] id in + guard let self, let session = self.sessions[ref] else { return } + guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return } + // Opening a window that is already open is how SwiftUI's value-addressed groups say + // "bring that one forward" — the same call `BoardWindowHost` makes to open a card, and + // the reason reopening a live card focuses its window rather than making a second one. + self.windowOpener?(id: WindowID.card, value: cardRef) + } + ) + } + + /// **Binds the git provider onto an already-open session** — add-git's one caller. + /// + /// 06 ▸ Rules ▸ Detection sanctions exactly one mid-session mode flip, the app's own add-git: + /// "clicking it flips the open board into git mode immediately — the popover flows straight into + /// the git controls, the first auto-commit follows". It does not mention undo, so this is a + /// judgment call and it is recorded here: **the flip binds undo too**, live, rather than waiting + /// for the next open. Three reasons point the same way — the mode flip already carries the + /// *committer* through (`HistoryStore.activateAutoCommit` remembers its wiring for precisely this + /// board); 12-editions.md's "an open board finishes with the provider it composed" is a rule about + /// a **tier** lapsing, which cannot change a running session at all; and a board that visibly + /// starts accumulating commits while ⌘Z stays greyed out until it is closed and reopened would + /// read as a defect rather than as a policy. + /// + /// It only ever adds. A board that already has a provider keeps it, and nothing here can take one + /// away — there is no un-add-git. + func bindHistoryProvider(for ref: BoardWindowRef) { + guard var session = sessions[ref], session.history == nil, + let git = session.git, git.mode == .git else { return } + guard let history = makeHistoryProvider(session.store, session.tier, git) else { return } + session.history = history + sessions[ref] = session + session.store.history = history + session.undoManager.history = history + wireGitUndo(history, store: session.store, git: git, ref: ref) + } + /// Registers a card window with its board's session, so the close flush can find it. /// /// A card window whose board has no session is a card window with no board — the ownership rule @@ -1112,7 +1269,7 @@ public final class AppModel { // Cleared rather than merely dropped because the steps hold closures over the store // this line is about to release, and a stack that outlived its board would be a // retain cycle wearing an undo stack's clothes. - session.history.clear() + session.history?.clear() storeRegistry.release(session.store) session.access?.stop() } diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 7a8e0a4..cd38a4b 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -77,6 +77,14 @@ final class CardWindowSession: CardSessionFlushing { /// window that has not joined its board — holds nothing, which is true. var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)? + /// The window's raw-source outlet, as the save-or-discard step's two writes: **Apply** (which + /// validates, and answers `false` when it refuses) and **Cancel**. Wired by the host beside + /// `rawSourceHoldsUnsavedText`, and for its reason — the outlet is window state living beside + /// this object rather than inside it. + var rawSourceApply: (@MainActor () -> Bool)? + var rawSourceCancel: (@MainActor () -> Void)? + var rawSourceIsActive: (@MainActor () -> Bool)? + /// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session /// holding keystrokes its file has not got — see `CardSessionFlushing`. /// @@ -89,6 +97,44 @@ final class CardWindowSession: CardSessionFlushing { body.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent } + /// **What a restore or a branch switch asks this window to settle** (06-history-undo.md ▸ Rules + /// ▸ Undo restore vs open Edit sessions). + /// + /// ### The predicate is *open*, not *dirty* + /// + /// An **open** Edit session is what needs settling even with a clean buffer, because its ~700 ms + /// saves are on disk and deliberately uncommitted — the stage-around rule's whole point — so a + /// restore landing over them would either bury text no commit protects or leave the session's + /// next debounced save to write pre-restore bytes back over the restored card, "a ⌘Z that visibly + /// doesn't happen". Same reading `CardBodyEditSession.isEditing` records for staging, applied to + /// the same fact. + /// + /// An **open raw-source outlet** counts whether or not it has been typed in, and 06 says why: its + /// Apply "would write the *entire* pre-switch `index.md` byte-for-byte onto the new branch's + /// card". A buffer read from before the restore is the hazard; typing is not required for it. + var settlement: CardSessionSettlement? { + CardSessionSettlement( + needsSettling: { [self] in + body.isEditing || body.isDirty || rawSourceIsActive?() == true + }, + saveAll: { [self] in + // The Edit session ends with its normal commit — "each card's Edit→Preview flip". + body.endEditSession() + // Apply validates; a refusal is the whole operation's cancellation, and the alert it + // raised is already on the offending window. + guard rawSourceIsActive?() == true else { return true } + return rawSourceApply?() ?? true + }, + discard: { [self] in + // The buffer goes back to what disk says; the *disk* goes back to the target state as + // part of the restore itself, which reconciles this card's folder against the working + // tree rather than against HEAD (`GitRestoreOperation.plan`). + body.discardBuffer() + rawSourceCancel?() + } + ) + } + private var hasEnded = false /// Both `let`s, wired to each other through a local — the guard's two closures need the buffer, @@ -185,6 +231,9 @@ struct CardWindowHost: View { /// snapshot the store applies — a cache that died with the view would regenerate every thumbnail /// on every reload (`AttachmentThumbnailCache`). @State private var thumbnails = AttachmentThumbnailCache() + /// This card's commit trail (05-card-window.md ▸ History). Held here for `thumbnails`' reason — + /// it must survive every snapshot — and surfaced to the view only in git mode (`cardHistory`). + @State private var history = CardHistory() /// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not /// flush; cleared by the resolution that lets the close resume. @State private var isClosePending = false @@ -311,6 +360,29 @@ struct CardWindowHost: View { .onDisappear { finish() } } + /// **This card's commit trail, or nothing at all** (05-card-window.md ▸ History). + /// + /// `nil` is the section's absence rule, read from the board's own git state rather than from a + /// flag: no `HistoryStore` means the free tier (12-editions.md — where the section never exists), + /// and a mode other than `git` means a board the app manages no history for. The object is held + /// by this host so it survives every snapshot, `thumbnails`' reason exactly. + private var cardHistory: CardHistory? { + guard appModel.session(for: ref.board)?.gitMode == .git else { return nil } + return history + } + + /// What a trail re-read depends on: this card, and the number of commits the board has landed. + /// + /// The count is the committer's own (`GitAutoCommitter.commitCount`), which advances for every + /// commit the app makes — the debounced ones, the launch catch-up, and a restore's. A foreign + /// commit an agent made *itself* moves HEAD without touching it; the trail then refreshes at the + /// next commit or the next open, which is the same freshness bound the popover's branch line has + /// and a great deal cheaper than polling HEAD from a sidebar. + private func historyReloadKey(store: BoardStore) -> String { + let commits = appModel.session(for: ref.board)?.git?.committer?.commitCount ?? 0 + return "\(ref.cardID)#\(commits)" + } + /// **The minimum grows only while the comments pane is beside the body** (05-card-window.md ▸ /// Composition) — which is the whole reason the stacked mount exists, so a narrow display keeps /// the minimum it always had. @@ -343,11 +415,20 @@ struct CardWindowHost: View { attachments: attachments, comments: session.comments, thumbnails: thumbnails, + history: cardHistory, fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id), onToggleTask: { offset, checked in store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked) } ) + // **The trail, re-read when a commit lands** (05 ▸ History). The id is the pair of facts + // the answer depends on: which card this is, and how many commits this board has made — + // so the section refreshes after the app's own commits, after an agent's that the watcher + // committed, and after a ⌘Z's restore, with nothing here knowing what a committer is. + .task(id: historyReloadKey(store: store)) { + guard let cardHistory else { return } + await cardHistory.load(boardRoot: store.rootURL, cardFolderName: ref.cardID) + } // **The listing is the snapshot's, republished** — `Card.attachments`, which the loader // fills from `attachments/`'s top-level files in Finder order. Every write in the // section is bracketed, so the reload that refreshes this arrives by itself and the @@ -544,6 +625,12 @@ struct CardWindowHost: View { // The other half of the outlet's wiring: the session answers for this window's unsaved // content, and the outlet is the half that does not live inside it (`CardWindowSession`). session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText } + // The save-or-discard step's half of the same wiring (06-history-undo.md ▸ Branch switching): + // Save All *applies* an open outlet — validation included, so a refusal cancels the whole + // operation — and Discard leaves it without writing. + session.rawSourceIsActive = { [rawSource] in rawSource.isActive } + session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() } + session.rawSourceCancel = { [rawSource] in rawSource.cancel() } Self.configureAttachments(attachments, store: store, cardID: cardID) Self.configureComments(session.comments, store: store, cardID: cardID) } diff --git a/Kanban/App/CloseFlushCoordinator.swift b/Kanban/App/CloseFlushCoordinator.swift index bf942d4..ebd68a0 100644 --- a/Kanban/App/CloseFlushCoordinator.swift +++ b/Kanban/App/CloseFlushCoordinator.swift @@ -42,11 +42,23 @@ public protocol CardSessionFlushing: AnyObject { /// template that silently missed those keystrokes would break 09's never-misses-keystrokes /// guarantee, which outranks the item's availability. var holdsUnsavedContent: Bool { get } + + /// **This window's answers to the save-or-discard step** (06-history-undo.md ▸ Rules ▸ Undo + /// restore vs open Edit sessions; ▸ Branch switching), or `nil` for a window with nothing a + /// wholesale tree operation could disturb. + /// + /// Beside `holdsUnsavedContent` and deliberately not folded into it: that property answers "would + /// a *template copy* miss keystrokes", which is a read; this one carries the two writes — Save + /// All and Discard — that a restore or a branch switch needs the window to perform before it can + /// run on a settled tree. Same window, two different questions, and collapsing them would give + /// the settle step the draft-composer exemption `holdsUnsavedContent` deliberately makes. + var settlement: CardSessionSettlement? { get } } public extension CardSessionFlushing { func endSession() async {} var holdsUnsavedContent: Bool { false } + var settlement: CardSessionSettlement? { nil } } // MARK: - CloseFlushCoordinator diff --git a/Kanban/App/SessionSettleGate.swift b/Kanban/App/SessionSettleGate.swift new file mode 100644 index 0000000..f0a76c3 --- /dev/null +++ b/Kanban/App/SessionSettleGate.swift @@ -0,0 +1,253 @@ +import AppKit +import Foundation + +// MARK: - Vocabulary + +/// What the user chose at the save-or-discard step (06-history-undo.md ▸ Branch switching: "**Save +/// All** ends every session with its normal commit …, **Discard** reverts buffers and uncommitted +/// saves to HEAD, **Cancel** keeps the current branch and the sessions"). +public enum SessionSettleChoice: Sendable, Equatable { + case saveAll + case discard + case cancel +} + +/// What the gate concluded — the only thing the operation behind it branches on. +public enum SessionSettleOutcome: Sendable, Equatable { + + /// Nothing needed settling, or everything did and did. The tree is settled; run. + case proceed + + /// The user chose Cancel. "Cancel keeps everything" — nothing was written, nothing reverted. + case cancelled + + /// **Save All met a raw-source buffer that would not validate.** "Since Apply validates, a buffer + /// that fails validation cancels the whole switch with focus on the offending window, nothing + /// half-switched" (06 ▸ Branch switching). The payload is that window's session id, already + /// focused by the gate. + case failed(String) +} + +// MARK: - What one card window offers the step + +/// **A card window's three answers to the save-or-discard step**, handed over as closures. +/// +/// A type of its own rather than three members on `CardSessionFlushing` because it is optional as a +/// unit: a window with nothing settleable in it has no settlement, and the gate should not have to +/// ask three questions to find that out. `nil` is also every window in a build with no card session +/// at all, which is what the protocol's default supplies. +@MainActor +public struct CardSessionSettlement { + + /// Whether this window is holding state a wholesale tree operation would disturb. + public let needsSettling: @MainActor () -> Bool + + /// Ends the Edit session with its normal commit and *applies* the raw buffer. `false` means the + /// raw buffer failed validation. + public let saveAll: @MainActor () -> Bool + + /// Reverts the Edit buffer and leaves raw source without writing. + public let discard: @MainActor () -> Void + + public init( + needsSettling: @escaping @MainActor () -> Bool, + saveAll: @escaping @MainActor () -> Bool, + discard: @escaping @MainActor () -> Void + ) { + self.needsSettling = needsSettling + self.saveAll = saveAll + self.discard = discard + } +} + +// MARK: - One settleable session + +/// **A card window, as the save-or-discard step sees it** — three closures and the card it is over. +/// +/// A value of closures rather than a protocol over `CardWindowSession`, for `CloseFlushCoordinator`'s +/// reason exactly: what this gate is *about* is a decision procedure, and a procedure written against +/// a live window is verifiable only by running the app. The production values come from the card +/// windows; a test builds them from a counter. +@MainActor +public struct SettleableSession { + + /// The window's identity — `CardWindowRef.cardID` is what production passes. Opaque to the gate, + /// and only ever handed back to `focus`. + public let id: String + + /// The **card's folder name** — its id, which is its folder on disk (01-storage-format.md). + /// + /// Matched component-wise against the paths a wholesale operation would write, which is what + /// makes the match survive a lane move: a card's own folder component never changes, only the + /// lane above it (`GitHistoryWalk.path(_:isInsideFolderNamed:)`, the same trick, same reason). + public let cardFolderName: String + + /// Whether this session is holding state a wholesale tree operation would disturb: unsaved + /// keystrokes, an **open** Edit session whose ~700 ms saves are deliberately uncommitted, or a + /// raw-source outlet that is open at all. + public let needsSettling: @MainActor () -> Bool + + /// **Save All** for this one session: end the Edit session with its normal commit, and *apply* + /// the raw buffer. `false` means the raw buffer failed validation — the whole operation is off. + public let saveAll: @MainActor () -> Bool + + /// **Discard** for this one session: revert the buffer and leave raw source without writing. The + /// on-disk uncommitted saves are reverted by the operation itself, which is comparing this card's + /// folder against the working tree rather than against HEAD for exactly that reason + /// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`). + public let discard: @MainActor () -> Void + + public init( + id: String, + cardFolderName: String, + needsSettling: @escaping @MainActor () -> Bool, + saveAll: @escaping @MainActor () -> Bool, + discard: @escaping @MainActor () -> Void + ) { + self.id = id + self.cardFolderName = cardFolderName + self.needsSettling = needsSettling + self.saveAll = saveAll + self.discard = discard + } +} + +// MARK: - SessionSettleGate + +/// **The save-or-discard step**, as one reusable decision procedure (06-history-undo.md ▸ Rules +/// ▸ Undo restore vs open Edit sessions; ▸ Branch switching). +/// +/// ### One machinery, two callers, by design +/// +/// 06 does not describe two gates. It describes the branch-switch step and then hands undo the same +/// one by name: "When the diff *does* touch a session card, the restore **gates on the branch-switch +/// save-or-discard step** (Branch switching below — Save All / Discard / Cancel, same machinery, same +/// rationale)." So this object is written for both from the start; the undo provider is its first +/// caller and the branch controls will be its second, passing the paths a checkout would write +/// instead of the paths a restore would. +/// +/// ### The diff decides whether it appears at all +/// +/// "A restore materializes only the diff between the current tree and the target state, so a card +/// whose open Edit session the diff doesn't touch is simply unaffected — its uncommitted ~700 ms +/// saves and the stage-around rule continue undisturbed, and most undos never meet an editor at all." +/// That is `settle(touching:)`'s first line, and it is why the gate takes paths rather than a +/// yes/no: a modal that appeared on every ⌘Z because *some* window somewhere was in Edit would be a +/// different, much worse feature. +/// +/// ### Why the ask is a closure +/// +/// Presenting three buttons is AppKit's job and cannot be asserted without a display. The rule this +/// file exists to hold — which sessions are asked about, what each answer does to them, and that a +/// failing raw buffer stops everything with focus on the offender — is decidable from values, so the +/// presentation is a seam and the decision is testable. +@MainActor +public struct SessionSettleGate { + + /// Every open card session on this board, read live: a window can open or close between the + /// moment an operation starts and the moment it asks. + public var sessions: () -> [SettleableSession] + + /// Presents the three-button step and answers what the user chose. + public var ask: () async -> SessionSettleChoice + + /// Brings one session's window forward — the "focus on the offending window" half of the + /// validation-failure rule. + public var focus: (String) -> Void + + public init( + sessions: @escaping () -> [SettleableSession], + ask: @escaping () async -> SessionSettleChoice, + focus: @escaping (String) -> Void = { _ in } + ) { + self.sessions = sessions + self.ask = ask + self.focus = focus + } + + // MARK: The decision + + /// Settles whatever the operation's paths reach, and answers whether it may run. + /// + /// - Parameter paths: board-root-relative paths the operation would write. + public func settle(touching paths: Set) async -> SessionSettleOutcome { + let candidates = Self.reached(by: paths, among: sessions()).filter { $0.needsSettling() } + guard !candidates.isEmpty else { return .proceed } + + switch await ask() { + case .cancel: + return .cancelled + + case .discard: + for session in candidates { session.discard() } + return .proceed + + case .saveAll: + for session in candidates { + guard session.saveAll() else { + // "Nothing half-switched": the sessions saved before this one are saved, which is + // an ordinary Save and loses nothing, but the operation itself does not run. + focus(session.id) + return .failed(session.id) + } + } + return .proceed + } + } + + /// **Which sessions a set of paths reaches** — pure, and the whole of "the diff touches a session + /// card". + /// + /// Component-exact folder matching, so a card whose id happens to be a prefix of another's cannot + /// drag that other card's window into the step. + public static func reached( + by paths: Set, + among sessions: [SettleableSession] + ) -> [SettleableSession] { + guard !paths.isEmpty else { return [] } + return sessions.filter { session in + paths.contains { GitHistoryWalk.path($0, isInsideFolderNamed: session.cardFolderName) } + } + } +} + +// MARK: - The presented step + +/// **The three buttons**, as an `NSAlert` — the production `SessionSettleGate.ask`. +/// +/// One of 02-architecture.md's sanctioned modal moments, and it is modal for `DirtyBufferGuard`'s +/// reason exactly: the operation behind it cannot proceed until the user has decided what happens to +/// text no commit protects, and there is no non-modal shape for a question whose three answers are +/// mutually exclusive and immediate. +/// +/// The wording is 06's own vocabulary. The default is **Cancel**, deliberately: a Return pressed +/// reflexively at a dialog nobody read must be the answer that changes nothing, and both other +/// answers write. +public enum SessionSettleStep { + + public static let title = "Unsaved card edits" + + public static let message = """ + Restoring an earlier state would change cards you are editing. \ + Save them, discard the changes, or cancel. + """ + + @MainActor + public static func ask() async -> SessionSettleChoice { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = title + alert.informativeText = message + // Order matters for the key equivalents AppKit assigns: the first button takes Return, so + // Cancel leads and the two writing answers follow. Escape reaches Cancel either way. + alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: "Save All") + alert.addButton(withTitle: "Discard") + + switch alert.runModal() { + case .alertSecondButtonReturn: return .saveAll + case .alertThirdButtonReturn: return .discard + default: return .cancel + } + } +} diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index c014b60..7859c97 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -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 + + public init(commits: [GitLandedCommit], healPaths: Set) { + self.commits = commits + self.healPaths = healPaths + } + + /// The oids of the heal-class commits — the ones the undo pointer passes over. + public var healOIDs: Set { + 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 = [] + } + /// **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 = []) { 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() diff --git a/Kanban/Git/GitCommitOperation.swift b/Kanban/Git/GitCommitOperation.swift index dc76637..b6dd112 100644 --- a/Kanban/Git/GitCommitOperation.swift +++ b/Kanban/Git/GitCommitOperation.swift @@ -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): diff --git a/Kanban/Git/GitHistoryProvider.swift b/Kanban/Git/GitHistoryProvider.swift new file mode 100644 index 0000000..c3200bf --- /dev/null +++ b/Kanban/Git/GitHistoryProvider.swift @@ -0,0 +1,477 @@ +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.** 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 = [] + 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(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.. String? { + guard BoardGitMode.hasGitEntry(at: boardRoot), + let repository = try? Repository.open(at: boardRoot), + !repository.isHEADUnborn, + let head = try? repository.HEAD, + let commit = head.target as? Commit else { return nil } + return commit.id.hex + } + + /// HEAD's first-parent ancestry, **newest first** — index 0 is HEAD. + /// + /// An unborn HEAD answers `[]`, which is exactly "the undo trail simply starts empty" (06 ▸ Rules + /// ▸ Abnormal repo states) with no case of its own. + nonisolated static func ancestry(at boardRoot: URL, limit: Int = defaultLimit) -> [GitCommitRecord] { + guard BoardGitMode.hasGitEntry(at: boardRoot), + let repository = try? Repository.open(at: boardRoot), + !repository.isHEADUnborn, + let head = try? repository.HEAD, + let tip = head.target as? Commit else { return [] } + + var records: [GitCommitRecord] = [] + var current: Commit? = tip + while let commit = current, records.count < max(0, limit) { + let parent = (try? commit.parents)?.first + records.append(record(commit, parent: parent)) + current = parent + } + return records + } + + /// **Every commit that touched one card's folder, newest first** — the card window's History + /// section (05-card-window.md ▸ History). + /// + /// ### Following the card is matching its own folder name + /// + /// "The listing **follows the card across lane moves** (path changes; the UUID folder is the + /// identity to track)." A card's folder *is* its identity: `‹lane-uuid›/‹card-uuid›/index.md`, so + /// a lane move rewrites the first component and never the second. Matching on the card's own + /// folder component therefore follows it across every move it can make — into another lane, into + /// `.trash/`, back out again — with no rename detection to be defeated by a large diff, and no + /// `--follow` heuristic to disagree with git's own answer. (`GitRepository.pathFirstAppearanceRanks` + /// records the opposite trade for its own question: it does *not* follow renames, and says so.) + /// + /// The walk is HEAD's first-parent ancestry, so the trail a card shows is the trail its board's + /// current branch has — which is what makes a branch switch change it for free. + nonisolated static func commitsTouching( + folderNamed name: String, + at boardRoot: URL, + limit: Int = defaultLimit + ) -> [GitCommitRecord] { + guard !name.isEmpty, + BoardGitMode.hasGitEntry(at: boardRoot), + let repository = try? Repository.open(at: boardRoot), + !repository.isHEADUnborn, + let head = try? repository.HEAD, + let tip = head.target as? Commit else { return [] } + + var records: [GitCommitRecord] = [] + var current: Commit? = tip + var walked = 0 + while let commit = current, walked < max(0, limit) { + walked += 1 + let parent = (try? commit.parents)?.first + if touches(commit, folderNamed: name, parent: parent, in: repository) { + records.append(record(commit, parent: parent)) + } + current = parent + } + return records + } + + /// Whether `path` lies inside a folder named `name` — component-exact, so a card whose id is a + /// prefix of another's cannot borrow its history. + nonisolated static func path(_ path: String, isInsideFolderNamed name: String) -> Bool { + path.split(separator: "/").dropLast().contains { $0 == name } + } + + // MARK: - Private + + private static func record(_ commit: Commit, parent: Commit?) -> GitCommitRecord { + GitCommitRecord( + oid: commit.id.hex, + subject: commit.summary, + authorName: commit.author.name, + date: commit.author.date, + parentOID: parent?.id.hex + ) + } + + /// Whether one commit's diff against its first parent mentions the folder. + /// + /// **A root commit is diffed against nothing**, so its whole tree counts as touched — the same + /// reading `pathFirstAppearanceRanks` gives a walk's base, and the honest one: every file in a + /// root commit arrived in it. + private static func touches( + _ commit: Commit, + folderNamed name: String, + parent: Commit?, + in repository: Repository + ) -> Bool { + guard parent != nil else { + return treePaths(of: commit, in: repository).contains { path($0, isInsideFolderNamed: name) } + } + guard let diff = try? repository.diff(commit: commit) else { return false } + return diff.changes.contains { delta in + path(delta.newFile.path, isInsideFolderNamed: name) + || path(delta.oldFile.path, isInsideFolderNamed: name) + } + } + + /// Every blob path under a commit's tree — `GitRepository.filePaths`' twin, kept here rather than + /// shared because that one is `private` to a file with a different job. + private static func treePaths(of commit: Commit, in repository: Repository) -> [String] { + guard let tree = try? commit.tree else { return [] } + var paths: [String] = [] + + func walk(_ tree: Tree, prefix: String, depth: Int) { + guard depth < 8 else { return } + for entry in tree.entries { + let path = prefix.isEmpty ? entry.name : prefix + "/" + entry.name + if entry.type == .tree { + guard let subtree: Tree = try? repository.show(id: entry.id) else { continue } + walk(subtree, prefix: path, depth: depth + 1) + } else { + paths.append(path) + } + } + } + + walk(tree, prefix: "", depth: 0) + return paths + } +} diff --git a/Kanban/Git/GitRestoreOperation.swift b/Kanban/Git/GitRestoreOperation.swift new file mode 100644 index 0000000..beedb65 --- /dev/null +++ b/Kanban/Git/GitRestoreOperation.swift @@ -0,0 +1,337 @@ +import Foundation +import libgit2 +import os + +// MARK: - The plan + +/// **One restore, as the writes it will make** — computed before anything touches the working tree, +/// so the whole of what a ⌘Z is about to do is a value a caller can inspect, gate on, and test. +public struct GitRestorePlan: Sendable, Equatable { + + /// One file the restore will write or remove. + public struct Change: Sendable, Equatable { + /// Board-root-relative, in git's own spelling. + public let path: String + /// The bytes to write, or `nil` to remove the file. + public let contents: Data? + + public init(path: String, contents: Data?) { + self.path = path + self.contents = contents + } + } + + public let changes: [Change] + + public init(changes: [Change]) { + self.changes = changes + } + + public var paths: [String] { changes.map(\.path) } + + public var isEmpty: Bool { changes.isEmpty } +} + +// MARK: - GitRestoreOperation + +/// **Undo and redo, as forward commits** (14-git-operations.md ▸ The forward-restore model; the +/// load-bearing extraction): "Every restorative operation moves history forward. Nothing the app does +/// ever rewrites a published commit: no reset, no force-push, no revert-by-rewrite." +/// +/// ### What this file is allowed to call, and what it is not +/// +/// It materializes an older state as **ordinary working-tree writes** and then commits them through +/// the same signature-capable path every auto-commit takes (`GitCommitOperation.perform`). It never +/// calls `git_reset`, never moves a reference by hand, never writes `refs/`, and never touches the +/// reflog: the only ref movement in the whole restore is `git_commit_create`'s own advance of HEAD, +/// which is what a commit *is*. That is the property "verifiable by trail inspection in any git +/// client" reduces to, and it is checkable here by reading the imports: nothing below resolves a +/// reset or a checkout symbol at all. +/// +/// ### Only the diff, never the tree +/// +/// "A restore materializes only the diff between the current tree and the target state, so a card +/// whose open Edit session the diff doesn't touch is simply unaffected" (06-history-undo.md ▸ Rules +/// ▸ Undo restore vs open Edit sessions). So the plan is HEAD's tree against the target's, file by +/// file — never a checkout of the whole target, which would sweep every unrelated file on the board +/// through a write it did not need. +/// +/// Two deliberate narrowings ride on that: +/// +/// - **`excluding`** — the heal-transparency rule's second half (06 ▸ Rules ▸ Heal commits are +/// transparent to undo): "a restore materializing an older target **excludes paths whose divergence +/// is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler." +/// - **`reconciling`** — the folders of card sessions the user chose to **Discard** at the +/// save-or-discard step (06 ▸ Branch switching: "Discard reverts buffers and uncommitted saves to +/// HEAD"). Those folders are compared against the **working tree** rather than against HEAD, +/// because their uncommitted on-disk saves are precisely the state HEAD does not have — one pass +/// that both drops the discarded saves and applies the restore, instead of a revert followed by a +/// restore that would have to agree with it. +/// +/// ### Isolation +/// +/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in +/// the same synchronous scope, and no handle crosses an `await`. Called from a detached task. +enum GitRestoreOperation { + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") + + /// The operation name a failure carries into the banner (06 ▸ Interaction with external writers: + /// "surfaces as a one-shot banner failure naming the operation and the error"). + static let operationName = "Restoring an earlier state" + + /// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason. + private static let startUp: Bool = { + git_libgit2_init() >= 0 + }() + + // MARK: - Planning + + /// **The writes that would turn the working tree into `target`'s state**, or `nil` when the + /// repository could not be read. + /// + /// `nil` is emphatically not "nothing to do": a restore that silently did nothing because a tree + /// would not load is the one failure mode a forward-only undo could not explain afterwards. + /// + /// - Parameters: + /// - target: the oid of the commit whose state is being restored. + /// - excluding: board-root-relative paths whose divergence is heal work — never materialized. + /// - reconciling: board-root-relative folders compared against the working tree rather than + /// against HEAD (the Discard branch of the save-or-discard step). + nonisolated static func plan( + at boardRoot: URL, + target: String, + excluding: Set = [], + reconciling: Set = [] + ) -> GitRestorePlan? { + _ = startUp + guard let repository = open(boardRoot) else { return nil } + defer { git_repository_free(repository) } + + guard let targetTree = tree(of: target, in: repository) else { return nil } + defer { git_tree_free(targetTree) } + var wanted: [String: git_oid] = [:] + fileMap(of: targetTree, in: repository, prefix: "", depth: 0, into: &wanted) + + var current: [String: git_oid] = [:] + if let headTree = headTree(of: repository) { + defer { git_tree_free(headTree) } + fileMap(of: headTree, in: repository, prefix: "", depth: 0, into: ¤t) + } + + // The reconciled folders answer from disk instead: their committed state is beside the point, + // because what is being discarded is exactly what is *not* committed. + if !reconciling.isEmpty { + for folder in reconciling { + current = current.filter { !isInside($0.key, folder: folder) } + } + for path in workingTreeFiles(under: reconciling, at: boardRoot) { + // A sentinel oid nothing can equal: the comparison below only ever asks "same or + // different", and a working-tree file's bytes are not addressed by the object store. + current[path] = git_oid() + } + } + + var changes: [GitRestorePlan.Change] = [] + for (path, oid) in wanted.sorted(by: { $0.key < $1.key }) { + guard !excluding.contains(path) else { continue } + if let held = current[path], equal(held, oid), !isInside(path, folders: reconciling) { continue } + guard let data = blob(oid, in: repository) else { continue } + changes.append(GitRestorePlan.Change(path: path, contents: data)) + } + for path in current.keys.sorted() where wanted[path] == nil { + guard !excluding.contains(path) else { continue } + changes.append(GitRestorePlan.Change(path: path, contents: nil)) + } + return GitRestorePlan(changes: changes.sorted { $0.path < $1.path }) + } + + // MARK: - Applying + + /// **Writes the plan and commits it** — one new commit on the current branch, nothing rewound. + /// + /// The commit goes through `GitCommitOperation.perform` unchanged, so it takes the ordinary + /// signature path (06 ▸ Interaction with external writers) and is authored by the user: a restore + /// is the user acting through the app, whatever the origin of the commit it crosses. + /// + /// A plan that turns out to write nothing new commits nothing — `perform`'s own empty-tree skip — + /// and answers `.nothingToCommit`, which the caller reads as "the step was crossed and needed no + /// bytes", not as a failure. + nonisolated static func apply( + _ plan: GitRestorePlan, + at boardRoot: URL, + message: String + ) -> GitCommitOutcome { + _ = startUp + guard !plan.isEmpty else { return .nothingToCommit } + + let manager = FileManager.default + for change in plan.changes { + let url = boardRoot.appendingPathComponent(change.path) + guard let contents = change.contents else { + try? manager.removeItem(at: url) + pruneEmptyFolders(above: url, upTo: boardRoot) + continue + } + let folder = url.deletingLastPathComponent() + do { + try manager.createDirectory(at: folder, withIntermediateDirectories: true) + try contents.write(to: url, options: .atomic) + } catch { + logger.error("restore could not write \(change.path, privacy: .public)") + return .failed(GitOperationFailure( + operation: operationName, + message: (error as NSError).localizedDescription + )) + } + } + + let identity = GitCommitOperation.userIdentity(at: boardRoot) + return GitCommitOperation.perform( + at: boardRoot, + commits: [PlannedCommit( + paths: plan.paths, + message: message, + author: identity, + committer: identity, + kind: .user + )], + allowRootCommit: false + ) + } + + // MARK: - Private plumbing + + private static func open(_ boardRoot: URL) -> OpaquePointer? { + guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil } + var repository: OpaquePointer? + guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil } + return repository + } + + private static func tree(of oid: String, in repository: OpaquePointer) -> OpaquePointer? { + var id = git_oid() + guard git_oid_fromstr(&id, oid) == 0 else { return nil } + var commit: OpaquePointer? + guard git_commit_lookup(&commit, repository, &id) == 0, let commit else { return nil } + defer { git_commit_free(commit) } + var tree: OpaquePointer? + guard git_commit_tree(&tree, commit) == 0 else { return nil } + return tree + } + + private static func headTree(of repository: OpaquePointer) -> OpaquePointer? { + guard git_repository_head_unborn(repository) != 1 else { return nil } + var reference: OpaquePointer? + guard git_repository_head(&reference, repository) == 0, let reference else { return nil } + defer { git_reference_free(reference) } + var object: OpaquePointer? + guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0 else { return nil } + return object + } + + /// Every blob under a tree, board-root-relative, with its object id. + /// + /// The depth cap is `GitHeadSnapshot.materialize`'s, for its reason: a guard against a + /// pathological repository, not a statement about boards. + private static func fileMap( + of tree: OpaquePointer, + in repository: OpaquePointer, + prefix: String, + depth: Int, + into map: inout [String: git_oid] + ) { + guard depth < 8 else { return } + for position in 0.. Data? { + var id = oid + var blob: OpaquePointer? + guard git_blob_lookup(&blob, repository, &id) == 0, let blob else { return nil } + defer { git_blob_free(blob) } + let size = Int(git_blob_rawsize(blob)) + guard size > 0, let bytes = git_blob_rawcontent(blob) else { return Data() } + return Data(bytes: bytes, count: size) + } + + /// Every file on disk under one of `folders`, board-root-relative. `.git` is never walked — it is + /// not part of any board's tree and nothing here may write into it. + private static func workingTreeFiles(under folders: Set, at boardRoot: URL) -> [String] { + var found: [String] = [] + for folder in folders { + let root = boardRoot.appendingPathComponent(folder) + guard let walker = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { continue } + for case let url as URL in walker { + guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + else { continue } + guard let relative = relativePath(of: url, under: boardRoot) else { continue } + found.append(relative) + } + } + return found + } + + private static func relativePath(of url: URL, under boardRoot: URL) -> String? { + let root = boardRoot.standardizedFileURL.path + let path = url.standardizedFileURL.path + guard path.hasPrefix(root + "/") else { return nil } + return String(path.dropFirst(root.count + 1)) + } + + private static func isInside(_ path: String, folder: String) -> Bool { + path == folder || path.hasPrefix(folder + "/") + } + + private static func isInside(_ path: String, folders: Set) -> Bool { + folders.contains { isInside(path, folder: $0) } + } + + /// Removes folders emptied by a deletion, up to (never including) the board root — the same + /// tidiness a card's own delete leaves behind, so a restore does not litter a board with empty + /// UUID folders that the loader would then have to ignore. + private static func pruneEmptyFolders(above file: URL, upTo boardRoot: URL) { + let manager = FileManager.default + let root = boardRoot.standardizedFileURL.path + var folder = file.deletingLastPathComponent().standardizedFileURL + while folder.path != root, folder.path.hasPrefix(root + "/") { + let contents = (try? manager.contentsOfDirectory(atPath: folder.path)) ?? [] + guard contents.isEmpty || contents == [".DS_Store"] else { return } + try? manager.removeItem(at: folder) + folder = folder.deletingLastPathComponent().standardizedFileURL + } + } + + private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool { + var left = lhs + var right = rhs + return git_oid_cmp(&left, &right) == 0 + } +} diff --git a/Kanban/Git/HistoryStore.swift b/Kanban/Git/HistoryStore.swift index ada40d2..ae809bb 100644 --- a/Kanban/Git/HistoryStore.swift +++ b/Kanban/Git/HistoryStore.swift @@ -88,6 +88,18 @@ public final class HistoryStore { @ObservationIgnored private var autoCommitWiring: ((GitAutoCommitter) -> Void)? + /// **The mid-session mode flip, announced** — called once, after a successful `addGit()`, and + /// never on any other path. + /// + /// It exists because the flip has a second consumer beyond the committer: the board's **undo + /// substrate**. A session that composed on a mode-none board bound no provider at all + /// (`AppModel.makeHistoryProvider`), and 06 ▸ Rules ▸ Detection's one sanctioned commanded flip + /// means the board now has a trail to be an undo stack over. What binding it means is + /// `AppModel.bindHistoryProvider(for:)`'s to decide and to justify; what this property does is + /// keep that decision out of a git state that has no business knowing what a provider is. + @ObservationIgnored + public var didAddGit: (@MainActor () -> Void)? + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") init(boardRoot: URL, mode: BoardGitMode, ledger: EchoLedger) { @@ -189,6 +201,8 @@ public final class HistoryStore { self.committer = committer autoCommitWiring?(committer) committer.start() + // Last, after the mode and the committer: the undo binding reads both. + didAddGit?() Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)") return true case .failure(let failure): diff --git a/Kanban/History/BoardUndoManager.swift b/Kanban/History/BoardUndoManager.swift index 66f4f46..db51093 100644 --- a/Kanban/History/BoardUndoManager.swift +++ b/Kanban/History/BoardUndoManager.swift @@ -48,7 +48,31 @@ public final class BoardUndoManager: UndoManager { /// The substrate this manager is a face for. Strong: the session owns both, and the manager is /// only ever reachable while the session that made it is alive. - private let history: any HistoryProviding + /// + /// ### `nil` is a board with **no undo provider**, and it is a real state + /// + /// Under Pro, a board in mode `none` or `repoNested` gets no provider at all — "the pair disabled + /// on boards with no undo provider in the composed tier — under Pro, no-git and repo-nested + /// boards, matching their menu items" (03-board-ui.md ▸ Toolbar ▸ Catalog; 06-history-undo.md + /// ▸ Rules). Every question below answers the empty way, so the Edit menu's rows, the toolbar + /// pair, and ⌘Z itself go quiet together, through the same validation path a lock uses. Modelling + /// it as an absent substrate rather than as a substrate that always says no is the honest shape: + /// there is nothing there, and nothing can accidentally accumulate in it. + /// + /// ### Settable, for exactly one event + /// + /// **Add-git** (06 ▸ Rules ▸ Detection) is the design's one sanctioned mid-session mode flip: + /// "clicking it flips the open board into git mode immediately — the popover flows straight into + /// the git controls, the first auto-commit follows". A board that gains a repository mid-session + /// gains a commit trail, and a trail with a dead ⌘Z over it would read as a bug. The composition + /// root binds the git provider here on that flip, rather than rebuilding this object, so AppKit + /// keeps the identical manager it has already been handed by `windowWillReturnUndoManager`. + /// + /// (This is *not* a tier flip. 12-editions.md's "an open board finishes with the provider it + /// composed" is about a subscription lapsing, which cannot change a running session's tier at + /// all — `BoardSession.tier` is a `let` with no setter. Mode can change, by explicit command, + /// and only in this one direction.) + var history: (any HistoryProviding)? /// Whether the board is refusing writes — `BoardStore.isReadOnly`, read through a closure rather /// than by holding the store. The adapter is deliberately store-free (it is a face for a *seam*, @@ -58,7 +82,7 @@ public final class BoardUndoManager: UndoManager { /// the adapter's own grammar — should have. private let isReadOnly: @MainActor () -> Bool - public init(history: any HistoryProviding, isReadOnly: @escaping @MainActor () -> Bool = { false }) { + public init(history: (any HistoryProviding)?, isReadOnly: @escaping @MainActor () -> Bool = { false }) { self.history = history self.isReadOnly = isReadOnly super.init() @@ -68,10 +92,11 @@ public final class BoardUndoManager: UndoManager { /// **False under the lock, whatever the stack holds.** The steps are still there — this is an /// enablement answer, not a clearing — so the first ⌘Z after the lock clears crosses the step it - /// would have crossed before it landed. - public override var canUndo: Bool { !isReadOnly() && history.canUndo } + /// would have crossed before it landed. False with no substrate at all, for the reason + /// `history` records. + public override var canUndo: Bool { !isReadOnly() && history?.canUndo == true } - public override var canRedo: Bool { !isReadOnly() && history.canRedo } + public override var canRedo: Bool { !isReadOnly() && history?.canRedo == true } // MARK: Crossing @@ -80,17 +105,17 @@ public final class BoardUndoManager: UndoManager { /// started anyway is refused one layer down by `performWrite` — which leaves the step on the /// stack (`HistoryStepOutcome.failed`), the same place this enablement rule keeps it. A second /// guard here would be a second answer to one question. - public override func undo() { history.undo() } + public override func undo() { history?.undo() } - public override func redo() { history.redo() } + public override func redo() { history?.redo() } // MARK: Titles /// `NSUndoManager`'s own vocabulary for "the phrase, without the verb" — `""` when there is /// nothing to cross, which is what its menu-title composition expects. - public override var undoActionName: String { history.undoActionName ?? "" } + public override var undoActionName: String { history?.undoActionName ?? "" } - public override var redoActionName: String { history.redoActionName ?? "" } + public override var redoActionName: String { history?.redoActionName ?? "" } /// "Undo Move 3 Cards" — composed and localized by the platform (`undoMenuTitle(forUndoActionName:)` /// reads the `undo.strings` pattern), so the step vocabulary stays the bare phrase and this app diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 9635a32..82b93d2 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1248,6 +1248,39 @@ public final class BoardStore: HealHost { } } + /// The same bracket over work that **awaits** — the undo restore (06-history-undo.md) and, next, + /// the branch switch. + /// + /// A sibling rather than a replacement, and the reason is a hard fact about the two callers: the + /// synchronous version above exists because `performWrite`-shaped work is synchronous, while a + /// git operation is a detached libgit2 task the main actor must not block on + /// (`GitRepository`'s isolation rule). Both keep the bracket, the reload floor and the completion + /// phrase in one place; the distinct argument label is what keeps overload resolution from having + /// to guess which one a trailing closure meant. + /// + /// The refusal, the ordering and the arming are the synchronous version's, unchanged — see its + /// doc comment for all three. + public func performWholesale( + announcing completion: String? = nil, + awaiting operation: () async throws -> Void + ) async throws { + if let readOnlyLock { + throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock) + } + watcherBrackets?.begin() + defer { + wholesaleReloadFloor = reloadGeneration + 1 + wholesaleCompletion = completion + watcherBrackets?.end() + } + do { + try await operation() + } catch let error as BoardWriteError { + banners.post(error) + throw error + } + } + // MARK: - Lane width /// Writes a lane's width — the one commit point both width mechanisms share (03-board-ui.md § diff --git a/Kanban/UI/Card/CardBodyEditSession.swift b/Kanban/UI/Card/CardBodyEditSession.swift index f874e8d..9b1d6c3 100644 --- a/Kanban/UI/Card/CardBodyEditSession.swift +++ b/Kanban/UI/Card/CardBodyEditSession.swift @@ -226,6 +226,28 @@ public final class CardBodyEditSession { return outcome } + /// **Throws the buffer away and takes disk's word for it** — the Discard branch of the + /// save-or-discard step (06-history-undo.md ▸ Branch switching: "Discard reverts buffers and + /// uncommitted saves to HEAD"). + /// + /// It reverts the *buffer* and ends the session; the uncommitted on-disk saves are the operation + /// behind the step's to undo, because only that operation knows which state it is restoring to + /// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)` reconciles the card's folder + /// against the working tree for exactly this reason). Splitting it that way is what keeps the two + /// halves from being two answers able to disagree: one pass writes the card's files, once. + /// + /// The pending debounce is cancelled first, which is the load-bearing half — a surviving timer + /// would write the discarded text back over the restored card a moment later. + public func discardBuffer() { + cancelPending() + text = disk + sessionOriginBody = nil + if isEditing { + isEditing = false + editSessionDidChange?(false) + } + } + /// `DirtyBufferGuard`'s `attemptSave`: the same flush, with a real failure raised instead of /// reported. /// diff --git a/Kanban/UI/Card/CardHistorySection.swift b/Kanban/UI/Card/CardHistorySection.swift new file mode 100644 index 0000000..39e3425 --- /dev/null +++ b/Kanban/UI/Card/CardHistorySection.swift @@ -0,0 +1,185 @@ +import Observation +import SwiftUI + +// MARK: - One row + +/// One **History** row: a commit that touched this card's folder (05-card-window.md ▸ History). +/// +/// A value rather than the `GitCommitRecord` itself, so the view renders strings a test has already +/// checked and never formats a date in a `body`. +struct CardHistoryRow: Identifiable, Equatable, Sendable { + + /// The commit's oid — the identity, and nothing the row shows. + let id: String + + /// The commit's subject, exactly as the message engine wrote it. + let subject: String + + /// "2 days ago · Claude" — the row's second line. + let attribution: String +} + +// MARK: - The seam + +/// What the History section shows, as a pure function of commits and a clock +/// (05-card-window.md ▸ History: "newest first — semantic subject, relative date, author"). +enum CardHistoryRows { + + /// The rows for one card's commits, newest first — which is the order the walk already answers + /// in, so nothing here re-sorts and nothing can disagree with git about what "newest" means. + nonisolated static func rows( + for commits: [GitCommitRecord], + now: Date = Date(), + locale: Locale = .autoupdatingCurrent + ) -> [CardHistoryRow] { + commits.map { commit in + CardHistoryRow( + id: commit.oid, + subject: commit.subject, + attribution: attribution(of: commit, now: now, locale: locale) + ) + } + } + + /// "⟨relative date⟩ · ⟨author⟩", with the author dropped when there is none to name. + /// + /// The author is the commit's, which is where origin lives (06-history-undo.md ▸ Interaction with + /// external writers) — so a foreign commit reads `Lanework External` and a `modified-by` agent + /// reads its own name, with no rendering rule of this section's own. That is 05's claim that the + /// trail "reads as a story, agent and hand edits included" arriving for free. + nonisolated static func attribution( + of commit: GitCommitRecord, + now: Date = Date(), + locale: Locale = .autoupdatingCurrent + ) -> String { + let when = relativeDate(commit.date, now: now, locale: locale) + let author = commit.authorName.trimmingCharacters(in: .whitespacesAndNewlines) + return author.isEmpty ? when : "\(when) · \(author)" + } + + /// A relative date in the system's own words ("2 days ago"), with **"just now"** for anything + /// inside a minute. + /// + /// The floor is a judgment call, recorded: `RelativeFormatStyle` renders a five-second-old commit + /// as "in 0 seconds" whenever the clock rounds the wrong way, and a trail whose newest row reads + /// as the future is worse than one that rounds down. Everything past a minute is the platform's + /// answer verbatim, localized and abbreviated to suit a 26-character-wide sidebar. + nonisolated static func relativeDate( + _ date: Date, + now: Date = Date(), + locale: Locale = .autoupdatingCurrent + ) -> String { + guard now.timeIntervalSince(date) >= 60 else { return "just now" } + var style = Date.RelativeFormatStyle(presentation: .named, unitsStyle: .wide) + style.locale = locale + return date.formatted(style.locale(locale)) + } +} + +// MARK: - The loader + +/// **One card window's commit trail** — the object the sidebar renders and the host refreshes. +/// +/// ### Its existence is the section's visibility rule +/// +/// "The section is **absent** on boards without app-managed git (mode none, repo-nested) — same +/// honesty rule as the popover's git section" (05 ▸ History), and the free tier has no git state at +/// all (12-editions.md). So the host builds one of these only in mode `git`, and `nil` is the whole +/// of the absence — no placeholder, no empty header, nothing to explain. +/// +/// ### It re-reads rather than subscribes +/// +/// The trail changes when a commit lands, which is exactly what `GitAutoCommitter.commitCount` +/// counts. The host re-asks on that number and on the card's own folder, so a trail refreshes after +/// every commit — the app's own, an agent's the watcher committed, and a restore's — without this +/// object learning what a committer is. +@MainActor +@Observable +final class CardHistory { + + /// The rows, newest first. Empty until the first load answers, which is also the honest answer for + /// a card whose folder no commit has touched yet. + private(set) var rows: [CardHistoryRow] = [] + + /// Whether a load is in flight — what keeps the section from flashing "no history yet" during the + /// first walk of a large repository. + private(set) var isLoading = false + + init() {} + + /// Reads the commits that touched `cardFolderName` under `boardRoot`, off the main actor. + func load(boardRoot: URL, cardFolderName: String) async { + isLoading = true + defer { isLoading = false } + let commits = await Task.detached(priority: .utility) { + GitHistoryWalk.commitsTouching(folderNamed: cardFolderName, at: boardRoot) + }.value + rows = CardHistoryRows.rows(for: commits) + } +} + +// MARK: - The section + +/// The sidebar's **History** section: the card's commit trail, read-only, newest first +/// (05-card-window.md ▸ History). +/// +/// ### No actions, deliberately +/// +/// "Rows are focusable (arrows), but carry **no actions in v1** — restoring an old version stays a +/// git-client task for now; a per-row forward-restore and lane history are wishlist items, +/// deliberately." So the rows are text: selectable, copyable, and nothing else. The one restore this +/// milestone ships is board-level ⌘Z, which is a different gesture with a different target. +/// +/// ### Empty says so, rather than disappearing +/// +/// Contrast Details beside it, which vanishes when a card has no unknown keys. The distinction is +/// what an empty state would *imply*: an absent Details section implies nothing (most cards have no +/// unknown keys), while an absent History section on a git board would imply the board has no +/// history — the exact claim 05 reserves for boards that genuinely have none. A card whose folder is +/// newer than its last commit is a real and temporary state, and one quiet line is the honest way to +/// say so. +struct CardHistorySection: View { + + let history: CardHistory + + private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } + + var body: some View { + VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { + CardSidebarSectionHeader(title: "History") + + if history.rows.isEmpty { + Text(history.isLoading ? "Reading history…" : "No commits yet") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + ForEach(history.rows) { row in + self.row(row) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Subject over attribution — `CardDetailsSection.row`'s shape, inverted: there the quiet line is + /// the key and the loud one the value; here the *subject* is what a reader scans for and the date + /// and author are the qualifier. Same two fonts, same wrap-rather-than-truncate rule, so the two + /// sections read as one column at any text size. + private func row(_ row: CardHistoryRow) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(row.subject) + .font(.callout) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + Text(row.attribution) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.subject), \(row.attribution)") + } +} diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index ce8975c..9212718 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -91,6 +91,10 @@ struct CardWindowView: View { let comments: CardComments /// This window's thumbnail memory, held by the host so it outlives a snapshot. let thumbnails: AttachmentThumbnailCache + /// **This card's commit trail** (05 ▸ History), or `nil` on every board with no app-managed git — + /// the free tier, mode none, and repo-nested boards. The `nil` *is* the section's absence rule; + /// see `historySlot`. + let history: CardHistory? /// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole /// window**"). `nil` only where a caller has no store to import through. let fileDrop: CardWindowDropDelegate? @@ -325,22 +329,21 @@ struct CardWindowView: View { } } - /// **The History section's reserved place in the stack** — between Details and Actions, 05's - /// order (05 ▸ History: "the card's commit trail, read-only … newest first — semantic subject, - /// relative date, author"). + /// **The History section** — between Details and Actions, 05's order (05 ▸ History: "the card's + /// commit trail, read-only … newest first — semantic subject, relative date, author"). /// - /// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not - /// exist here, so a header over empty space would claim a commit trail on every board — and on - /// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one - /// that can never arrive. What the slot reserves is the **position**, so filling it in moves - /// nothing above or below it. + /// **Absence is the `nil`, and it is the whole rule.** "The section is absent on boards without + /// app-managed git (mode none, repo-nested) — same honesty rule as the popover's git section", + /// and the free tier has no git state at all (12-editions.md ▸ The free tier and `.git`). The host + /// builds a `CardHistory` only in mode `git`, so there is no placeholder here to decide about: + /// what the slot reserves is the **position**, and on every other board that position is empty. /// - // m7-git: the trail itself, plus the two rules that come with it — absence on boards without - // app-managed git (the same honesty rule as the board popover's git section, 06-history-undo.md) - // and View ▸ History, which focuses this section (11-command-nexus.md). + // A later card: View ▸ History, which focuses this section (11-command-nexus.md). @ViewBuilder private var historySlot: some View { - EmptyView() + if let history { + CardHistorySection(history: history) + } } } diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift new file mode 100644 index 0000000..c8bbbe3 --- /dev/null +++ b/KanbanTests/GitUndoTests.swift @@ -0,0 +1,1100 @@ +import Foundation +import SwiftGitX +import Testing +@testable import Kanban + +/// **Undo and redo as forward commits** (06-history-undo.md; 14-git-operations.md ▸ The forward-restore +/// model) — the stack that *is* HEAD's first-parent ancestry, the restores that only ever move history +/// forward, heal transparency, the save-or-discard gate, the provider binding, and the card window's +/// read-only trail. +/// +/// Every repository here is a **real** one, built through the app's own add-git over bundled libgit2, +/// and every claim about the trail is read back through libgit2 rather than through the code that made +/// it. Nothing shells out to `git` (`AutoCommitTests`' rule, kept). + +// MARK: - Fixtures + +/// A card's `index.md` — `AutoCommitTests`' helper, file-private there and here. +private func plain(order: String, title: String, body: String = "Body.") -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + --- + \(body) + """ +} + +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, plain(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First")) + return fixture +} + +/// A board with a repository and a root commit — the state a board is in a moment after add-git. +@MainActor +private func makeGitBoard() async throws -> (fixture: WriterFixture, git: HistoryStore, ledger: EchoLedger) { + let fixture = try makeBoard() + let ledger = EchoLedger() + let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: ledger)) + #expect(await git.addGit()) + return (fixture, git, ledger) +} + +@MainActor +private func quickCommitter(_ git: HistoryStore) throws -> GitAutoCommitter { + let committer = try #require(git.committer) + committer.debounceInterval = .milliseconds(20) + committer.lockRetryDelay = .milliseconds(5) + committer.holdRecheckInterval = .milliseconds(20) + return committer +} + +/// A provider wired to a real committer the way `AppModel.wireGitUndo` wires one, minus the seams +/// that need windows (the settle gate) and a store (the bracket) — those get their own suites. +@MainActor +private func makeProvider( + _ fixture: WriterFixture, + _ git: HistoryStore, + committer: GitAutoCommitter +) async -> GitHistoryProvider { + let provider = GitHistoryProvider(boardRoot: fixture.root) + provider.flushPendingCommit = { [weak committer] in await committer?.flushNow() } + provider.isHeld = { [weak committer] in committer?.pause != nil } + provider.suspendCommitting = { [weak committer] in committer?.stop() } + provider.resumeCommitting = { [weak committer] in committer?.start() } + committer.reportLanded = { [weak provider] window in provider?.noteLanded(window) } + await provider.reseed() + return provider +} + +/// Commits the pending window and waits for the stack to have heard about it — production validates +/// a menu a turn later; a test asserts the instant the flush returns. +@MainActor +private func commitAndSettle(_ committer: GitAutoCommitter, _ provider: GitHistoryProvider) async { + await committer.flushNow() + await provider.settled() +} + +// MARK: Reading the trail back + +private struct TrailCommit: Equatable { + let oid: String + let subject: String + let parents: Int +} + +/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the provider. +private func trail(at boardRoot: URL, limit: Int = 64) throws -> [TrailCommit] { + let repository = try Repository.open(at: boardRoot) + guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] } + + var records: [TrailCommit] = [] + var current: Commit? = tip + while let commit = current, records.count < limit { + records.append(TrailCommit( + oid: commit.id.hex, + subject: commit.summary, + parents: (try? commit.parents)?.count ?? 0 + )) + current = (try? commit.parents)?.first + } + return records +} + +private func subjects(at boardRoot: URL) throws -> [String] { + try trail(at: boardRoot).map(\.subject) +} + +/// Every line of `.git/logs/HEAD`, as (old oid, new oid, message) — the trail's own record of how +/// the reference moved, which is where a reset or a force would be visible if one had happened. +private func reflog(at boardRoot: URL) throws -> [(old: String, new: String, message: String)] { + let url = boardRoot.appendingPathComponent(".git/logs/HEAD") + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] } + return text.split(separator: "\n").compactMap { line in + let fields = line.split(separator: "\t", maxSplits: 1, omittingEmptySubsequences: false) + let head = fields[0].split(separator: " ") + guard head.count >= 2 else { return nil } + return (String(head[0]), String(head[1]), fields.count > 1 ? String(fields[1]) : "") + } +} + +/// An `AppModel` over its own scratch registry — `HistoryStoreTests`' helper, file-private there. +@MainActor +private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent("GitUndoTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let model = AppModel( + registryStorageURL: folder.appendingPathComponent("board-registry.json"), + clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true) + ) + return (model, { try? FileManager.default.removeItem(at: folder) }) +} + +@MainActor +@discardableResult +private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef { + let ref = BoardWindowRef(url: url) + let recordID = model.boardRegistry.recordOpen(of: url) + let store = try model.storeRegistry.acquire(url) + model.boardRegistry.setOpenNow(id: recordID) + model.beginSession(ref: ref, store: store, recordID: recordID, access: nil) + return ref +} + +private func title(ofCard folder: String, at boardRoot: URL) -> String? { + let url = boardRoot.appendingPathComponent(folder).appendingPathComponent("index.md") + guard let text = try? String(contentsOf: url, encoding: .utf8), + let document = try? FrontmatterDocument.parse(text) else { return nil } + return document.rawValue(for: "title")?.trimmingCharacters(in: .whitespacesAndNewlines) +} + +// MARK: - The stack is HEAD's ancestry + +@MainActor +@Suite("Git undo ▸ the stack is HEAD's first-parent ancestry") +struct GitUndoStackTests { + + @Test("A fresh board seeds from HEAD, with an empty redo") + func seedingReadsTheAncestry() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + // One commit only — the root — which is deliberately not a step. + #expect(provider.ancestry.count == 1) + #expect(provider.canUndo == false, "the board's existence is not an undo step") + #expect(provider.canRedo == false, "redo starts empty") + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + #expect(provider.ancestry.count == 2) + #expect(provider.canUndo, "the commit that just landed is the step") + #expect(provider.undoActionName == "Add card 'Second'") + } + + @Test("The menu label is the crossed commit's own subject, so labels never nest") + func theLabelIsTheCrossedSubject() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + #expect(provider.undoActionName == "Add card 'Second'") + await provider.cross(.undo) + + // HEAD is now "Undo: Add card 'Second'" — but the label is the *next* crossable commit's, + // which is the root, and the root is not a step. Nothing nested (06 ▸ Commit messages). + #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") + #expect(provider.undoActionName == nil) + #expect(provider.redoActionName == "Add card 'Second'") + } + + @Test("A commit landing from anywhere clears the redo stack") + func anArrivalClearsRedo() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + await provider.cross(.undo) + #expect(provider.canRedo) + + // Something else commits — an agent's work arriving through the watcher. + try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + #expect(provider.canRedo == false, "classic behavior: an arrival clears redo") + #expect(provider.undoActionName == "Add card 'Third'", "and becomes the new top step") + } + + @Test("A self-commit made outside the app is learned by the pre-flight sync — ⌘Z steps back one") + func thePreflightSyncLearnsForeignCommits() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + // An agent commits its own work: the tree moves and HEAD moves, with no signal to the app. + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + let identity = GitIdentity(name: "Agent", email: "agent@agents.lanework.invalid") + let landed = GitCommitOperation.perform( + at: fixture.root, + commits: [PlannedCommit( + paths: GitCommitOperation.changedPaths(at: fixture.root).map(\.path), + message: "Add card 'Second'", + author: identity, + committer: identity + )] + ) + guard case .committed = landed else { + Issue.record("the agent's own commit did not land") + return + } + #expect(provider.canUndo == false, "the cached stack has not heard about it yet") + + await provider.cross(.undo) + + // Exactly one step back: the agent's commit, not the whole session. + #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") + #expect(try trail(at: fixture.root).count == 3, "root, the agent's commit, the restore") + } + + @Test("clear() drops the cache and leaves the repository untouched") + func clearingIsACacheDrop() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + #expect(provider.canUndo) + + provider.clear() + #expect(provider.canUndo == false) + #expect(try trail(at: fixture.root).count == 2, "the trail is where it was") + + await provider.reseed() + #expect(provider.canUndo, "and reseeding finds it again") + } +} + +// MARK: - Forward commits, never a rewrite + +@MainActor +@Suite("Git undo ▸ restores are forward commits") +struct GitUndoForwardTests { + + @Test("Undo restores an earlier state as a new commit — refs only move forward") + func undoIsAForwardCommit() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + let before = try trail(at: fixture.root) + #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed") + + await provider.cross(.undo) + + let after = try trail(at: fixture.root) + // **The trail only grew.** Every commit that existed before the undo is still in HEAD's + // ancestry, in the same order, with the restore on top — which is precisely what a reset or + // a force could not produce. + #expect(after.count == before.count + 1) + #expect(Array(after.dropFirst()) == before) + #expect(after.first?.subject == "Undo: Rename card 'First' → 'Renamed'") + #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First") + } + + @Test("Redo restores forward again, as another new commit") + func redoIsAForwardCommitToo() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + await provider.cross(.undo) + #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First") + + await provider.cross(.redo) + #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed") + + let subjects = try subjects(at: fixture.root) + #expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'") + #expect(subjects.count == 4, "root, rename, undo, redo — four commits, none rewritten") + #expect(provider.canRedo == false, "the redo list is spent") + #expect(provider.undoActionName == "Rename card 'First' → 'Renamed'", "and ⌘Z crosses it again") + } + + @Test("The reflog only ever appends — no reset, no force") + func theReflogOnlyAppends() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + await provider.cross(.undo) + await provider.cross(.redo) + + // **Trail inspection, as any git client would do it.** libgit2 writes one reflog entry per + // reference movement, naming the tip before and the tip after. Three facts together are the + // whole of "never reset, never force": + let entries = try reflog(at: fixture.root) + #expect(entries.count == 4, "root, rename, undo, redo — one entry per commit, nothing else") + + // 1. Every movement is a *commit*. A reset writes "reset: …", a checkout "checkout: …", a + // force-fetch "update by push" — none of which the app can produce, and none of which is + // here. + #expect(entries.allSatisfy { $0.message.hasPrefix("commit") }) + + // 2. The chain is unbroken: each entry's `old` is the previous entry's `new`, so the branch + // never jumped sideways or backwards. + for (index, entry) in entries.enumerated() where index > 0 { + #expect(entry.old == entries[index - 1].new) + } + + // 3. Every tip the reference ever held is still in HEAD's first-parent ancestry — old commits + // stay reachable, which is precisely what a rewrite would destroy. + let reachable = Set(try trail(at: fixture.root).map(\.oid)) + #expect(entries.dropFirst().allSatisfy { reachable.contains($0.old) }) + #expect(entries.allSatisfy { reachable.contains($0.new) }) + + let repository = try Repository.open(at: fixture.root) + #expect(try repository.HEAD.name == GitRepository.initialBranchName, "and the branch is the same one") + } + + @Test("A restore materializes only the diff — an untouched card's file is not rewritten") + func onlyTheDiffIsMaterialized() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + // A third card, uncommitted and untouched by the restore's diff. + try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Bystander")) + let bystander = fixture.root + .appendingPathComponent("\(Ident.lane1)/\(Ident.card3)/index.md") + let before = try Data(contentsOf: bystander) + + let root = try #require(provider.ancestry.last?.oid) + let plan = try #require(GitRestoreOperation.plan(at: fixture.root, target: root)) + #expect(plan.paths.allSatisfy { $0.contains(Ident.card2) }, + "the plan names only what differs between the two commits") + #expect(try Data(contentsOf: bystander) == before, "and the bystander's bytes are its own") + } + + @Test("A card the undo removes takes its emptied folder with it") + func aRemovedCardLeavesNoEmptyFolder() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + await provider.cross(.undo) + + let folder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card2)") + #expect(FileManager.default.fileExists(atPath: folder.path) == false) + #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, + "and the tree is clean — the restore committed everything it wrote") + } + + @Test("A pending auto-commit flushes before the restore, so both states are commits") + func theRestoreSettlesTheTreeFirst() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + // A change that has *not* been committed when ⌘Z arrives. + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + + await provider.cross(.undo) + + let subjects = try subjects(at: fixture.root) + // The pending window became its own commit first — so the state ⌘Z stepped back *from* + // exists in the trail — and the restore sits on top of it. + #expect(subjects.first == "Undo: Add card 'Second'") + #expect(subjects.dropFirst().first == "Add card 'Second'") + } +} + +// MARK: - Heal transparency + +@MainActor +@Suite("Git undo ▸ heal commits are transparent") +struct GitUndoHealTransparencyTests { + + @Test("The pointer passes over a heal commit and crosses the step beneath it") + func thePointerPassesOverAHeal() async throws { + let (fixture, git, ledger) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + // An ordinary step. + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + // A heal: the app's own repair, vouched for by a heal-marked receipt. + let healed = fixture.root.appendingPathComponent(AgentGuide.filename) + try "\nrepaired\n".write(to: healed, atomically: true, encoding: .utf8) + ledger.recordWrite(at: healed, data: try Data(contentsOf: healed)) + ledger.markHeal(at: healed) + committer.noteWriteBracketClosed() + await commitAndSettle(committer, provider) + + #expect(try trail(at: fixture.root).count == 3, "root, the add, the heal") + // The heal is on top of the trail, and the label names the *add* beneath it. + #expect(provider.undoActionName == "Add card 'Second'", "the heal is not a step") + + await provider.cross(.undo) + #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") + } + + @Test("A restore excludes the paths a heal repaired — the repair survives the undo") + func aRestoreExcludesHealedPaths() async throws { + let (fixture, git, ledger) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + let guide = fixture.root.appendingPathComponent(AgentGuide.filename) + try "\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8) + ledger.recordWrite(at: guide, data: try Data(contentsOf: guide)) + ledger.markHeal(at: guide) + committer.noteWriteBracketClosed() + await commitAndSettle(committer, provider) + + // ⌘Z crosses the add — whose parent is the root commit, which predates the guide entirely. + // Without the exclusion the restore would delete the healer's file and re-arm it. + await provider.cross(.undo) + + #expect(FileManager.default.fileExists(atPath: guide.path), + "the repair is never reverted (06 ▸ Rules ▸ Heal commits are transparent)") + let text = try String(contentsOf: guide, encoding: .utf8) + #expect(text.contains("repaired")) + } + + @Test("A heal landing mid-run leaves the pointer and the redo list alone") + func aHealDoesNotTrapAnUndoRun() async throws { + let (fixture, git, ledger) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + await provider.cross(.undo) + #expect(provider.canRedo, "a step is on the redo list") + + // The healer runs. An ordinary arrival would clear redo and trap the run on a renewing top. + let guide = fixture.root.appendingPathComponent(AgentGuide.filename) + try "\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8) + ledger.recordWrite(at: guide, data: try Data(contentsOf: guide)) + ledger.markHeal(at: guide) + committer.noteWriteBracketClosed() + await commitAndSettle(committer, provider) + + #expect(provider.canRedo, "transparency dissolves the trap rather than suppressing the healer") + #expect(provider.redoActionName == "Add card 'Second'") + } + + @Test("A window's landed commits are reported with their classes") + func theCommitterReportsClasses() async throws { + let (fixture, git, ledger) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + var seen: [GitLandedWindow] = [] + committer.reportLanded = { seen.append($0) } + + // One window carrying a heal and a foreign change: two commits, two classes. + let guide = fixture.root.appendingPathComponent(AgentGuide.filename) + try "\nrepaired\n".write(to: guide, atomically: true, encoding: .utf8) + ledger.recordWrite(at: guide, data: try Data(contentsOf: guide)) + ledger.markHeal(at: guide) + committer.noteWriteBracketClosed() + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + + let window = try #require(seen.first) + #expect(window.commits.count == 2) + #expect(window.healOIDs.count == 1) + #expect(window.healPaths.contains(AgentGuide.filename)) + #expect(window.isEntirelyHeal == false) + } +} + +// MARK: - The abnormal-state pause + +@MainActor +@Suite("Git undo ▸ the held repository disables the pair") +struct GitUndoHoldTests { + + @Test("A held repository disables Undo and Redo") + func aHeldRepositoryDisablesThePair() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + #expect(provider.canUndo) + + // A merge left by outside-the-app git — 06 ▸ Rules ▸ Abnormal repo states: the *whole* git + // surface pauses, "Undo/Redo and the branch controls disable". + let marker = fixture.root.appendingPathComponent(".git/MERGE_HEAD") + try "0000000000000000000000000000000000000000\n".write(to: marker, atomically: true, encoding: .utf8) + try fixture.item("\(Ident.lane1)/\(Ident.card3)", plain(order: "3072", title: "Third")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + #expect(committer.pause == .merge) + + #expect(provider.canUndo == false) + #expect(provider.canRedo == false) + + let before = try trail(at: fixture.root) + await provider.cross(.undo) + #expect(try trail(at: fixture.root) == before, "and a crossing that started anyway writes nothing") + } +} + +// MARK: - The save-or-discard gate + +@MainActor +@Suite("Git undo ▸ the save-or-discard step") +struct SessionSettleGateTests { + + /// A settleable session a test drives by hand. + @MainActor + private final class FakeSession { + var isSettled = false + var saveSucceeds = true + var saves = 0 + var discards = 0 + + func descriptor(id: String, folder: String) -> SettleableSession { + SettleableSession( + id: id, + cardFolderName: folder, + needsSettling: { [self] in !isSettled }, + saveAll: { [self] in + saves += 1 + guard saveSucceeds else { return false } + isSettled = true + return true + }, + discard: { [self] in + discards += 1 + isSettled = true + } + ) + } + } + + @Test("A diff that touches no session card never asks") + func anUntouchedSessionIsNeverAsked() async throws { + let session = FakeSession() + var asked = 0 + let gate = SessionSettleGate( + sessions: { [session.descriptor(id: "card-a", folder: "card-a")] }, + ask: { asked += 1; return .cancel } + ) + + let outcome = await gate.settle(touching: ["lane-1/card-b/index.md"]) + + #expect(outcome == .proceed) + #expect(asked == 0, "most undos never meet an editor at all") + #expect(session.saves == 0) + } + + @Test("A clean session in the diff's path is not asked about either") + func aSettledSessionIsNotAsked() async throws { + let session = FakeSession() + session.isSettled = true + var asked = 0 + let gate = SessionSettleGate( + sessions: { [session.descriptor(id: "card-a", folder: "card-a")] }, + ask: { asked += 1; return .cancel } + ) + + #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed) + #expect(asked == 0) + } + + @Test("Save All ends every touched session; Cancel keeps everything") + func theThreeAnswers() async throws { + let first = FakeSession() + let second = FakeSession() + let descriptors = { + [ + first.descriptor(id: "card-a", folder: "card-a"), + second.descriptor(id: "card-b", folder: "card-b") + ] + } + + var choice = SessionSettleChoice.cancel + let gate = SessionSettleGate(sessions: descriptors, ask: { choice }) + + #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .cancelled) + #expect(first.saves == 0) + #expect(first.discards == 0, "Cancel keeps everything") + + choice = .saveAll + #expect(await gate.settle(touching: ["lane-1/card-a/index.md"]) == .proceed) + #expect(first.saves == 1) + #expect(second.saves == 0, "only the sessions the diff reaches") + + choice = .discard + #expect(await gate.settle(touching: ["lane-2/card-b/index.md"]) == .proceed) + #expect(second.discards == 1) + } + + @Test("A raw buffer that will not validate cancels the whole operation, focused on the offender") + func aFailingBufferCancelsEverything() async throws { + let good = FakeSession() + let bad = FakeSession() + bad.saveSucceeds = false + + var focused: [String] = [] + let gate = SessionSettleGate( + sessions: { + [ + good.descriptor(id: "card-a", folder: "card-a"), + bad.descriptor(id: "card-b", folder: "card-b") + ] + }, + ask: { .saveAll }, + focus: { focused.append($0) } + ) + + let outcome = await gate.settle(touching: [ + "lane-1/card-a/index.md", + "lane-1/card-b/index.md" + ]) + + #expect(outcome == .failed("card-b")) + #expect(focused == ["card-b"], "focus on the offending window") + #expect(bad.isSettled == false) + } + + @Test("Folder matching is component-exact — a prefix does not borrow another card's session") + func matchingIsComponentExact() throws { + let session = FakeSession() + let descriptors = [session.descriptor(id: "card-1", folder: "card-1")] + + #expect(SessionSettleGate.reached(by: ["lane/card-1/index.md"], among: descriptors).count == 1) + #expect(SessionSettleGate.reached(by: ["lane/card-10/index.md"], among: descriptors).isEmpty) + #expect(SessionSettleGate.reached(by: ["card-1"], among: descriptors).isEmpty, + "the folder itself is not a file inside it") + } +} + +// MARK: - The restore meeting a session + +@MainActor +@Suite("Git undo ▸ restores meet open sessions") +struct GitUndoSessionTests { + + @Test("A restore whose diff misses every session runs untouched") + func anUnrelatedSessionIsUndisturbed() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Second")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + var asked = 0 + provider.settleSessions = { paths in + let gate = SessionSettleGate( + sessions: { + [SettleableSession( + id: Ident.card1, + cardFolderName: Ident.card1, + needsSettling: { true }, + saveAll: { true }, + discard: {} + )] + }, + ask: { asked += 1; return .cancel } + ) + return await gate.settle(touching: paths) + } + + await provider.cross(.undo) + + #expect(asked == 0, "the diff names card 2 only") + #expect(try subjects(at: fixture.root).first == "Undo: Add card 'Second'") + } + + @Test("Cancel at the step leaves the tree and the stack exactly as they were") + func cancelStopsTheRestore() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + let before = try trail(at: fixture.root) + + provider.settleSessions = { _ in .cancelled } + await provider.cross(.undo) + + #expect(try trail(at: fixture.root) == before, "nothing was written") + #expect(provider.canRedo == false, "and the stack did not move") + #expect(provider.undoActionName == "Rename card 'First' → 'Renamed'") + } + + @Test("Discard reconciles the session card against the working tree, not against HEAD") + func discardRevertsUncommittedSaves() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + let provider = await makeProvider(fixture, git, committer: committer) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) + committer.noteReloadLanded(sawForeignChange: true) + await commitAndSettle(committer, provider) + + // **An open Edit session**, whose folder the committer stages around: its ~700 ms save lands + // on disk and is deliberately never committed. That is the exact state 06 says a restore + // would otherwise bury — and the reason the step exists at all. + let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)") + let token = UUID() + committer.beginEditSession(token) { cardFolder } + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed")) + committer.noteReloadLanded(sawForeignChange: false) + await commitAndSettle(committer, provider) + #expect(try subjects(at: fixture.root).first == "Rename card 'First' → 'Renamed'", + "the session's saves committed nothing while it stood") + + provider.settleSessions = { _ in + // What the gate's Discard branch does: the window reverts its buffer and ends its + // session, and the folder is handed to the plan to reconcile against the working tree. + committer.endEditSession(token) + provider.noteDiscarded(cardFolderPath: "\(Ident.lane1)/\(Ident.card1)") + return .proceed + } + await provider.cross(.undo) + + #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First", + "the discarded save is gone and the restore landed, in one pass") + #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "on a settled tree") + } +} + +// MARK: - The provider binding + +@MainActor +@Suite("Git undo ▸ which board gets a provider") +struct GitUndoBindingTests { + + @Test("Free tier binds the native stack everywhere, git or not") + func freeTierIsNativeEverywhere() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(await seed.addGit()) + + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .free } + + let ref = try openBoard(model, at: fixture.root) + let session = try #require(model.session(for: ref)) + + #expect(session.git == nil, "the free tier composes no git state at all") + #expect(session.history is NativeHistoryProvider) + #expect(session.undoManager.canUndo == false, "empty, not absent") + } + + @Test("Pro on a git board binds the git provider") + func proOnAGitBoardBindsGit() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(await seed.addGit()) + + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + + let ref = try openBoard(model, at: fixture.root) + let session = try #require(model.session(for: ref)) + + #expect(session.history is GitHistoryProvider) + } + + @Test("Pro on a mode-none board binds no provider at all — the pair disables") + func proOnAPlainBoardBindsNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + + let ref = try openBoard(model, at: fixture.root) + let session = try #require(model.session(for: ref)) + + #expect(session.gitMode == .none) + #expect(session.history == nil, "a board without git has no undo/redo") + #expect(session.undoManager.canUndo == false) + #expect(session.undoManager.canRedo == false) + #expect(session.undoManager.undoMenuItemTitle == "Undo", "a bare row, with nothing to name") + // And a crossing that somehow started still writes nothing. + session.undoManager.undo() + session.undoManager.redo() + } + + @Test("Pro on a repo-nested board binds no provider either") + func proOnARepoNestedBoardBindsNothing() throws { + let outer = try WriterFixture() + defer { outer.tearDown() } + // A repository at the *parent*, with the board inside it — the nested posture. + let repoRoot = outer.root + _ = GitRepository.create(at: repoRoot) + let boardRoot = repoRoot.appendingPathComponent("board", isDirectory: true) + try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) + try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) + + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + + let ref = try openBoard(model, at: boardRoot) + let session = try #require(model.session(for: ref)) + + #expect(session.gitMode == .repoNested) + #expect(session.history == nil, "the app leaves that repository strictly alone") + #expect(session.undoManager.canUndo == false) + } + + @Test("Add-git binds the provider on the open session — the commanded mid-session flip") + func addGitBindsUndoLive() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + + let ref = try openBoard(model, at: fixture.root) + #expect(try #require(model.session(for: ref)).history == nil) + + let git = try #require(model.session(for: ref)?.git) + #expect(await git.addGit()) + + let session = try #require(model.session(for: ref)) + #expect(session.gitMode == .git) + #expect(session.history is GitHistoryProvider, "the flip carries undo through with it") + #expect(session.undoManager.canUndo == false, "on a trail whose only commit is the root") + } +} + +// MARK: - Routing + +@MainActor +@Suite("Git undo ▸ focus routing, with the git provider behind it") +struct GitUndoRoutingTests { + + @Test("A focused text surface takes ⌘Z, whatever the board's substrate is") + func textEditingWins() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) + #expect(await seed.addGit()) + + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + let ref = try openBoard(model, at: fixture.root) + let session = try #require(model.session(for: ref)) + #expect(session.history is GitHistoryProvider) + + let text = UndoManager() + // A field editor holds the keyboard: the *text* manager answers, so a reflexive ⌘Z over a + // typo can never become a tree checkout (06 ▸ Undo routing). + #expect(BoardUndoRouting.undoManager( + isTextEditing: true, + board: session.undoManager, + textFallback: text + ) === text) + // Focus outside every text surface: the board's own substrate answers. + #expect(BoardUndoRouting.undoManager( + isTextEditing: false, + board: session.undoManager, + textFallback: text + ) === session.undoManager) + } + + @Test("A board with no provider still routes to its own manager — it just cannot cross") + func noProviderIsStillTheBoardsManager() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let (model, tearDown) = try makeModel() + defer { tearDown() } + model.currentTier = { .pro } + let ref = try openBoard(model, at: fixture.root) + let session = try #require(model.session(for: ref)) + + let text = UndoManager() + let answered = BoardUndoRouting.undoManager( + isTextEditing: false, + board: session.undoManager, + textFallback: text + ) + // **No fall-through in either direction**: the board's manager answers even with nothing + // behind it, so an exhausted editor's ⌘Z never reaches a *different* stack — it reaches this + // one, which is disabled, and the system beeps. + #expect(answered === session.undoManager) + #expect(answered.canUndo == false) + } +} + +// MARK: - The card window's History section + +@MainActor +@Suite("Git undo ▸ the card's History trail") +struct CardHistorySectionTests { + + @Test("The trail lists the commits that touched this card, newest first") + func theTrailIsNewestFirst() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed again")) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + + let commits = GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root) + #expect(commits.map(\.subject) == [ + "Rename card 'Renamed' → 'Renamed again'", + "Rename card 'First' → 'Renamed'", + GitRepository.initialCommitSubject + ]) + } + + @Test("The trail follows the card across a lane move") + func theTrailFollowsALaneMove() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + let committer = try quickCommitter(git) + + try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing")) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + + // The card moves lane — its folder name (its identity) is unchanged, its path is not. + try FileManager.default.moveItem( + at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)"), + to: fixture.root.appendingPathComponent("\(Ident.lane2)/\(Ident.card1)") + ) + committer.noteReloadLanded(sawForeignChange: true) + await committer.flushNow() + + let commits = GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root) + #expect(commits.count == 2, "the move, and the root commit it was born in") + #expect(commits.first?.subject == "Move card 'First' to Doing") + } + + @Test("A card no commit has touched has an empty trail") + func anUntouchedCardHasNoTrail() async throws { + let (fixture, git, _) = try await makeGitBoard() + defer { fixture.tearDown() } + _ = try quickCommitter(git) + + let commits = GitHistoryWalk.commitsTouching(folderNamed: "not-a-card", at: fixture.root) + #expect(commits.isEmpty) + } + + @Test("A board with no git answers nothing at all") + func aPlainBoardHasNoTrail() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + + #expect(GitHistoryWalk.commitsTouching(folderNamed: Ident.card1, at: fixture.root).isEmpty) + #expect(GitHistoryWalk.ancestry(at: fixture.root).isEmpty) + #expect(GitHistoryWalk.headOID(at: fixture.root) == nil) + } + + @Test("A row reads subject over relative date and author") + func rowsRenderSubjectDateAuthor() { + let now = Date() + let commit = GitCommitRecord( + oid: "abc123", + subject: "Move card 'Fix login' to Doing", + authorName: "Claude", + date: now.addingTimeInterval(-60 * 60 * 48), + parentOID: "def456" + ) + + let rows = CardHistoryRows.rows(for: [commit], now: now, locale: Locale(identifier: "en_US")) + #expect(rows.count == 1) + #expect(rows[0].subject == "Move card 'Fix login' to Doing") + #expect(rows[0].attribution == "2 days ago · Claude") + #expect(rows[0].id == "abc123") + } + + @Test("A commit from moments ago reads 'just now' rather than a rounded future") + func aFreshCommitReadsJustNow() { + let now = Date() + #expect(CardHistoryRows.relativeDate(now.addingTimeInterval(-2), now: now) == "just now") + #expect(CardHistoryRows.relativeDate(now, now: now) == "just now") + } + + @Test("An unauthored commit drops the author segment rather than trailing a separator") + func anUnauthoredCommitDropsTheSegment() { + let now = Date() + let commit = GitCommitRecord( + oid: "abc", + subject: "Update board", + authorName: " ", + date: now, + parentOID: nil + ) + #expect(CardHistoryRows.attribution(of: commit, now: now) == "just now") + } + + @Test("Path matching is component-exact") + func pathMatchingIsExact() { + #expect(GitHistoryWalk.path("lane/card-1/index.md", isInsideFolderNamed: "card-1")) + #expect(GitHistoryWalk.path("lane/card-1/attachments/a.png", isInsideFolderNamed: "card-1")) + #expect(GitHistoryWalk.path(".trash/card-1/index.md", isInsideFolderNamed: "card-1")) + #expect(GitHistoryWalk.path("lane/card-10/index.md", isInsideFolderNamed: "card-1") == false) + #expect(GitHistoryWalk.path("card-1", isInsideFolderNamed: "card-1") == false) + } +} diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index 143c0c6..f15092b 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -498,7 +498,7 @@ struct BoardSessionHistoryTests { let log = StepLog() #expect(session.undoManager.canUndo == false) - session.history.register(log.step("Move Card")) + session.history?.register(log.step("Move Card")) // The window hands AppKit the adapter; the adapter is answering from the session's provider. #expect(session.undoManager.canUndo) @@ -526,14 +526,14 @@ struct BoardSessionHistoryTests { let secondSession = try #require(model.session(for: secondRef)) #expect(firstSession.history !== secondSession.history) - firstSession.history.register(log.step("Move Card")) + firstSession.history?.register(log.step("Move Card")) - #expect(firstSession.history.canUndo) - #expect(secondSession.history.canUndo == false) + #expect(firstSession.history?.canUndo == true) + #expect(secondSession.history?.canUndo == false) secondSession.undoManager.undo() #expect(log.crossings.isEmpty) - #expect(firstSession.history.canUndo, "the other board's ⌘Z left this one's stack alone") + #expect(firstSession.history?.canUndo == true, "the other board's ⌘Z left this one's stack alone") } @Test("Closing a board empties its stack — session-only persistence") @@ -545,7 +545,7 @@ struct BoardSessionHistoryTests { let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) - let history = session.history + let history = try #require(session.history) let log = StepLog() history.register(log.step("Move Card")) #expect(history.canUndo) @@ -567,12 +567,12 @@ struct BoardSessionHistoryTests { let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) - session.history.register(StepLog().step("Move Card")) + session.history?.register(StepLog().step("Move Card")) #expect(session.undoManager.canUndo) session.store.enterVanishedRootLock() #expect(session.undoManager.canUndo == false) - #expect(session.history.canUndo, "the stack itself survives the lock") + #expect(session.history?.canUndo == true, "the stack itself survives the lock") session.store.handleWatcherEvent(.treeChanged(.appMediated)) await session.store.awaitQuiescence() @@ -587,7 +587,7 @@ struct BoardSessionHistoryTests { defer { tearDown() } let bound = FakeHistoryProvider() - model.makeHistoryProvider = { _, _ in bound } + model.makeHistoryProvider = { _, _, _ in bound } let ref = try openBoard(model, at: fixture.root) let session = try #require(model.session(for: ref)) @@ -609,7 +609,7 @@ struct BoardSessionHistoryTests { // pins is that the *argument arrives*, so the milestone that switches on it is a closure body. var seen: [Tier] = [] model.currentTier = { .pro } - model.makeHistoryProvider = { _, tier in + model.makeHistoryProvider = { _, tier, _ in seen.append(tier) return NativeHistoryProvider() } @@ -808,7 +808,7 @@ struct UndoCommandSurfaceTests { let boardRow = menuItem("undo:") let cardRow = menuItem("undo:") - session.history.register(StepLog().step("Move 3 Cards")) + session.history?.register(StepLog().step("Move 3 Cards")) #expect(boardWindow.validateMenuItem(boardRow)) #expect(cardWindow.validateMenuItem(cardRow), "one stack per board, not per window") diff --git a/KanbanTests/HistoryStoreTests.swift b/KanbanTests/HistoryStoreTests.swift index 14a5747..5058e32 100644 --- a/KanbanTests/HistoryStoreTests.swift +++ b/KanbanTests/HistoryStoreTests.swift @@ -426,9 +426,9 @@ struct BoardSessionGitTests { let ranker = try #require(provider()) #expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil) - // The provider binding is deliberately *not* part of this card: both tiers still bind the - // native stack until the undo/redo card builds the git provider over this mode. - #expect(session.history is NativeHistoryProvider) + // The provider binding arrived with the undo/redo card: a Pro session on a git board binds + // the git substrate over exactly this mode (12-editions.md ▸ The provider seam). + #expect(session.history is GitHistoryProvider) } @Test("A Pro session on a plain board is mode none and injects nothing") diff --git a/README.md b/README.md index 542ef82..224bbef 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Lanework is in early development. This list tracks what has actually shipped and - **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode. -- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. Every tier ships it: the free tier runs the native stack, and a Lanework Pro subscription binds git behind the same seam without changing a keystroke. +- **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. The free tier runs this stack on every board; under Lanework Pro a git board binds git behind the same seam instead, without changing a keystroke — see "Undo as forward commits" below. - **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption. @@ -59,11 +59,13 @@ Lanework is in early development. This list tracks what has actually shipped and - **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12). -- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **The first of that is built** — see "Git integration" below — and the rest is pro-m1 and pro-m2's remaining work; until it ships both tiers run the same native undo stack, the free one over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else. +- **Tiers and the Lanework Pro subscription** — one app, one download, one on-disk format. The free tier is the complete board experience and everything above is in it; **Lanework Pro is an auto-renewable subscription inside the app**, bought and managed in a Pro section of Settings (⌘,) — price, Subscribe, Manage Subscription, Restore Purchases, and one quiet line when the App Store can't be reached. What the subscription unlocks is git: opt-in init and adoption, git-backed undo and history surfaces, branches, remotes and push/pull (DESIGN/06, DESIGN/07). **Most of that is built** — see "Git integration", "Auto-commit" and "Undo as forward commits" below — and branches, remotes and push/pull are pro-m1 and pro-m2's remaining work; the free tier keeps the native undo stack over the same inert-`.git` posture. What exists today is the infrastructure it lands on: the entitlement, the seam, and the storefront. The entitlement is a **local read, never a network call** — StoreKit's own signed on-device transaction store, reduced to two cached facts and resolved by a pure function at board-session composition, so opening a board never waits on the App Store and offline with an active subscription is indistinguishable from online. Offline grace resolves toward the paying user: an expiry passing while the device hasn't heard from the App Store, with the last known state renewing, holds the subscription until StoreKit actually answers, while a cancellation lapses at its expiry either way. A fresh install that has never been online reads free and corrects itself on the first refresh; unsubscribed and lapsed are *one* state, with nothing anywhere distinguishing them. The tier binds **per board session at composition** and is recorded on the session, so a lapse never interrupts an open board — and because subscribing takes effect at each board's next open, the purchase flow offers once to close and reopen the boards you have open. Only the Settings section touches the network: product loading, purchasing and Restore Purchases live there and nowhere else. - **Git integration (Lanework Pro)** — git is **opt-in per board and never silent**. Under a subscription, a board's mode is detected freshly at every open, nearest-`.git`-wins: a `.git` at the board root means git mode, a `.git` only further up means the board lives inside somebody else's repository, and neither means no git at all. Detection is an open-time fact by design — a `git init` run in a terminal under an open board takes effect the next time you open it, and nothing watches for a repository appearing. **Adoption is not initialization**: a board whose folder already holds a repository (you cloned it, or you ran `git init` yourself) simply opens in git mode, with no dialog and no adoption step — the repository's presence *is* the opt-in, which is how a second machine joins a shared board. **Add Git** in the board popover is the only thing in the app that ever creates one: it initializes a repository in the board's folder and immediately commits the whole tree as "Initial board state", so the board is protected from the moment git exists, and it flips the open board into git mode on the spot. A board inside an existing repository is left strictly alone — no nested repository, no commits into your project — and the popover says so in plain words instead of showing a disabled button. The git client is **bundled** (libgit2, in-process via SwiftGitX): nothing here shells out, and none of it needs git installed. Git history also settles duplicate-id collisions on git boards — of two folders claiming one identity, the path that entered history first wins. -- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External ` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: git-backed undo/redo, branch switching, `.gitignore` seeding, and remotes. +- **Auto-commit (Lanework Pro)** — on a git board **every settled change becomes a commit**, debounced a couple of seconds past the churn of a drag or a burst of typing, so one gesture is one commit rather than forty. Agent and hand edits ride the same debounce, so work done in a terminal or by an agent is committed, attributed and undoable exactly like your own. What commits is the **tree**, not the app's model: strays, `CLAUDE.md` and anything else `git status` shows go in too (your `.gitignore` is respected), so a board is never left quietly dirty. **Commit authorship is structural**: the app knows, file by file, what it wrote and what it didn't, so your changes are authored as you while changes from outside are authored as `Lanework External ` — and when every file in an outside batch carries the same `modified-by:` stamp, that batch is authored as that agent instead. A window holding both kinds is split into two commits rather than mixed, outside changes first, and a scheduled repair's files commit separately again. Identity comes from the repository's own `.git/config` when it names one, and otherwise from your macOS account name and machine. **The card editor is the exception, deliberately**: its 700 ms saves keep the file crash-safe but stay uncommitted for the length of a session, and the body lands as exactly one commit when you leave Edit — so a lane move made while you're typing commits the move and steps around the card you're in. Closing a board window or quitting flushes everything pending before teardown, and a board opened with uncommitted changes commits them through the same engine. When another program holds the repository's index, the committer waits, retries briefly, and then simply tries again at the next quiet moment — never an error, and the lock is never removed, because it isn't the app's. A merge, rebase, cherry-pick or detached HEAD left by outside-the-app git **pauses** committing entirely rather than writing into a state the app didn't create; your edits keep landing on disk and commit as one batch when you've finished up in whichever tool started it. **What each commit says is composed, not templated**: at commit time the app diffs the board as HEAD has it against the board as it is now — never by watching what you clicked — and writes what actually happened. "Move card 'Fix login' to Doing". "Rename lane 'Todo' → 'Doing'". "Relabel card 'Fix login'". Items are matched by identity across the whole board, so a card dragged between lanes reads as a move rather than a deletion beside an addition, and a folder moved by hand in the Finder reads exactly the same. Several changes of one kind fold into one line with the destination kept ("Move 3 cards to Done"); a genuinely mixed batch reads "Update board" with every event listed underneath, so `git log --oneline` stays scannable and the full message stays complete. Deleting a lane says "Delete lane 'Todo'" with its cards as detail rather than burying the event in a count. The trash is told apart by shape alone: into `.trash/` is "Delete card 'X'", back out is "Restore card 'X'", and gone for good is "Permanently delete card 'X'" — so the log distinguishes moved-to-trash from gone-forever without being told which gesture you used. Files the board model doesn't cover are described too rather than silently swept in: a changed agent guide reads "Update agent guide (v7)", a comment gets its own verbs off its path shape — "Comment on 'Fix login'", "Edit comment on…", "Delete comment on…", and the quiet "Draft comment on…" for the composer's slow saves — and any other stray reads "Update 'notes.txt'". Bookkeeping stays out of it — a bumped timestamp, a rank rescale, or a backfilled key composes nothing. And because the origin of a change lives in the author field, **an agent's commit reads in exactly the same words as your own**. Still ahead in pro-m1/m2: branch switching, `.gitignore` seeding, and remotes. +- **Undo as forward commits (Lanework Pro)** — on a git board ⌘Z and ⇧⌘Z stop being an in-memory stack and become the commit trail itself. There is no stored stack anywhere: **the stack *is* HEAD's first-parent ancestry**, re-read from the repository, so it survives relaunch for free and nothing beside the repo can ever drift from it. **A restore is a new commit, never a rewind** — no reset, no force, no rewritten history: ⌘Z materializes the earlier state and commits it as "Undo: Move card 'Fix login' to Doing", ⇧⌘Z as "Redo: …", and every commit you have ever made stays exactly where it was, inspectable in any git client. Only the *difference* is written, so a card you happen to be editing that the change never touched is simply left alone. Because the app is not the only writer, the stack re-reads HEAD before every keystroke: an agent that committed its own work in the last twenty minutes becomes the top of the stack, so ⌘Z steps back exactly one commit and can never silently swallow somebody else's session — and any commit arriving from anywhere clears redo, the classic rule. The app's own repairs are **transparent**: a heal commit is never a step and is never reverted by one, so a ⌘Z run walks past it instead of fighting the healer. Anything still pending commits *before* the restore does, so both versions of what you undid exist in the trail. When the change would land on a card you have open in Edit or Raw Source, the restore stops and asks — **Save All**, **Discard**, or **Cancel** — rather than silently committing text you hadn't saved or quietly writing it back a moment later; a raw buffer that won't validate cancels the whole thing and puts you in front of the window that refused. Undo is board-local, disabled while an outside-the-app merge or rebase has the repository paused, and absent altogether on boards the app manages no git for — where the Edit menu's rows and the toolbar's twins simply dim. Adding git to an open board turns it on there and then. +- **A card's History (Lanework Pro)** — the card window's sidebar gains a read-only **History** section on git boards: every commit that touched that card, newest first, each row its own semantic subject over a relative date and the author who made it — so an agent's work and your own read as one story ("Move card 'Fix login' to Doing · 2 days ago · Claude"). It follows the card by identity rather than by path, so moving between lanes — or into the trash and back — keeps one continuous trail. Read-only in this version: restoring a single old version stays a git-client job. The section is simply absent on boards without app-managed git and throughout the free tier — no placeholder, no greyed-out promise. ## Development