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) } /// A synthetic native step that records its own crossing — `HistoryProviderTests`' fixture, file-private /// there and here. What the binding suite needs it for is the *discard*: a step that would announce /// itself loudly if the swap ever ran it. @MainActor private final class StepLog { private(set) var crossings: [String] = [] func step(_ name: String) -> HistoryStep { HistoryStep( name: name, undo: { [weak self] _ in self?.crossings.append("undo \(name)") return .applied }, redo: { [weak self] _ in self?.crossings.append("redo \(name)") return .applied } ) } } @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 — **exactly as production does it**: the window // reverts its buffer and ends its session, and the card's *folder name* (its id, which is // what `AppModel`'s gate hands over) goes to the plan to reconcile against the working // tree. Passing the `/` path here instead is what once made this test pass // over a rule that did not work at all — see `discardReconcilesACardIdentifiedByName`. committer.endEditSession(token) provider.noteDiscarded(cardFolderName: 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") } /// **The regression.** `AppModel`'s settle gate reports a discarded session by /// `CardWindowRef.cardID` — a folder *name* — and the plan matched it as a board-root-relative /// path prefix. Every live card is `/`, so the match never fired on any board: Discard /// reverted the in-memory buffer and left the uncommitted on-disk saves exactly where they were, /// which then rode into the next commit — the one outcome 06 ▸ Rules ▸ Undo restore vs open Edit /// sessions singles out ("a surviving dirty buffer's next debounced save would write pre-undo text /// over the restored card — a ⌘Z that visibly doesn't happen"). /// /// The fix is one shared resolver (`GitRestoreOperation.folderPaths(named:at:)`), so this asserts /// the property the resolver exists for: a card nested under a lane, named only by its id. /// /// **What it takes to see the bug.** The restore's own diff already rewrites the session card's /// `index.md` — that is why the gate appeared at all — so a test that only checks the body is /// green either way. What reconciliation alone can reach is the session's uncommitted state the /// diff *cannot* name: a file the session created, present in neither HEAD nor the target, which /// the plan can only learn about by walking the folder on disk. That is what this leaves behind /// and then looks for. @Test("Discard reconciles a card identified by folder name alone, however deep it is nested") func discardReconcilesACardIdentifiedByName() 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) // The open session's uncommitted work, staged around and committed by nothing: a crash-safe // body save, and a file the session added beside it (05-card-window.md's attachments land in // the card's own folder) — which no commit anywhere has ever seen. 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: "Renamed", body: "Half-typed.")) let stray = "\(Ident.lane1)/\(Ident.card1)/attachments/sketch.txt" try fixture.file(stray, Data("dropped mid-session".utf8)) provider.settleSessions = { _ in committer.endEditSession(token) // The bare id — never the path. This is the whole regression. provider.noteDiscarded(cardFolderName: Ident.card1) return .proceed } await provider.cross(.undo) #expect(!fixture.exists(stray), "the discarded session's uncommitted file is gone from disk, not merely from a buffer") #expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "First", "and the restore landed over the body") #expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "on a settled tree — nothing is left for the next flush to sweep into a commit") } } // MARK: - The provider binding /// **The provider follows the board, not the tier alone** (re-ruled 2026-07-31 — 12-editions.md /// ▸ The provider seam; 13-native-undo.md's header; 06-history-undo.md ▸ Rules), stated as the /// matrix it is: gitless boards bind the native stack in *every* tier ("an upgrade never removes /// undo"), a Pro git board binds the git provider, and a repo-nested board binds nothing. /// /// ### The free tier's row is one cell wide, structurally /// /// `HistoryStore.compose` returns `nil` off Pro, so a free-tier session never detects a mode at all /// and cannot tell a repo-nested board from a plain one — which is not an omission but 12 ▸ The free /// tier and `.git` verbatim: "opening a board that has one (a formerly-subscribed user's board, a /// 1.x board, **a repo-nested board**) works normally — files read and write as on any board, /// **native undo runs**". 06's no-undo rule for repo-nested boards is a rule of a doc whose own first /// line reads "Tier scope: Lanework Pro", and the tests below pin both halves. @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 the native stack — an upgrade never removes undo") func proOnAPlainBoardBindsTheNativeStack() 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)) // The board is Pro's — it composed a git state and detected a mode — and the mode is what // chose the substrate: "gitless boards bind the native undo stack in every tier" (12). #expect(session.git != nil, "Pro composes a git state even where there is no repository") #expect(session.gitMode == .none) #expect(session.history is NativeHistoryProvider) // And it is a *working* stack, not a placeholder: the same command surface a free-tier // session gets, which is what "a user subscribing relearns nothing" means here. let log = StepLog() #expect(session.undoManager.canUndo == false, "empty, not absent") session.history?.register(log.step("Move Card")) #expect(session.undoManager.canUndo) #expect(session.undoManager.undoMenuItemTitle == "Undo Move Card") session.undoManager.undo() #expect(log.crossings == ["undo Move Card"]) } @Test("Pro on a repo-nested board binds no provider — the one no-undo case") 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)) // 06 ▸ Rules: a board inside somebody else's repository is "left strictly alone … so they get // **no undo**" — no app-managed undo journal, which an in-memory stack here would be. #expect(session.gitMode == .repoNested) #expect(session.history == nil, "the app leaves that repository strictly alone") #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("The free tier's repo-nested board still binds the native stack — it never detects one") func freeTierOnARepoNestedBoardIsNativeToo() throws { let outer = try WriterFixture() defer { outer.tearDown() } 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 = { .free } let ref = try openBoard(model, at: boardRoot) let session = try #require(model.session(for: ref)) // 12 ▸ The free tier and `.git` names this board by hand: "opening a board that has one … // a repo-nested board … works normally … native undo runs". The tier composes no git state // at all, so there is nothing here that *could* tell this board from a plain one — the inert // posture made structural rather than remembered. #expect(session.git == nil) #expect(session.gitMode == .none, "no detection ran; the session reports the tier's one mode") #expect(session.history is NativeHistoryProvider) } @Test("Add-git swaps the substrate — the native stack is discarded, the git trail seeded") func addGitSwapsTheSubstrate() 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) let native = try #require(model.session(for: ref)?.history as? NativeHistoryProvider) // The AppKit face AppKit already holds: the swap must not replace *this* object, or every // window's `windowWillReturnUndoManager` answer would go stale. let manager = try #require(model.session(for: ref)?.undoManager) // A real in-session step, mid-flight when the flip arrives. let log = StepLog() native.register(log.step("Move Card")) #expect(manager.canUndo) #expect(manager.undoMenuItemTitle == "Undo Move Card") 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.store.history is GitHistoryProvider, "and the Writer boundary registers there") #expect(session.undoManager === manager, "the same manager, over a different stack") // **The stack dies with the substrate** (13's header — the branch-switch discard-and-reseed // precedent): no migration, and the discarded stack is cleared rather than merely dropped, // so nothing holding a reference to it can cross a step against a board that now has a trail. #expect(native.canUndo == false) #expect(native.canRedo == false) #expect(log.crossings.isEmpty, "the in-flight step never ran — it was discarded, not applied") // **Seeded from the root commit**, which is the stack's floor and not a step (06 ▸ Rules), so // ⌘Z is correctly empty the instant the flip lands — and the menu row says nothing. #expect(try trail(at: fixture.root).count == 1) #expect(manager.canUndo == false, "on a trail whose only commit is the root") #expect(manager.canRedo == false) #expect(manager.undoMenuItemTitle == "Undo") } @Test("The swapped-in git provider is live — the next landed commit is a step on it") func theSwappedProviderHearsTheCommitter() 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) let git = try #require(model.session(for: ref)?.git) #expect(await git.addGit()) let session = try #require(model.session(for: ref)) let provider = try #require(session.history as? GitHistoryProvider) // `activateAutoCommit` remembered the session's wiring for exactly this — the committer // add-git built reports landed commits to whatever provider the session now holds, which is // the one the swap just installed. let committer = try quickCommitter(git) try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed")) committer.noteReloadLanded(sawForeignChange: true) await commitAndSettle(committer, provider) #expect(try trail(at: fixture.root).count == 2) #expect(session.undoManager.canUndo, "the trail behind ⌘Z is the repository's, live") } } // 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) } }