import Foundation import SwiftGitX import Testing @testable import Kanban /// **The card window's session as the commit unit** (06-history-undo.md ▸ Rules ▸ Auto-commit, /// widened 2026-07-31; 13-native-undo.md ▸ Interaction with the trash; 05-card-window.md ▸ The /// comments column). /// /// > Board history sees **card-window sessions, not gestures** … while a card's window is open, /// > everything happening inside it … stays **uncommitted**, and the committer **stages around the /// > whole open card folder** … **window close flushes the session as one commit**. /// /// The claims here are all about *when* a commit exists, which is exactly the class of thing that /// looks right in a running app and is wrong: a comment post that quietly landed its own commit, a /// session's body arriving under `Lanework External` because an interim flush spent its receipt, a /// `comments/.trash/` purge that committed separately from the delete it belongs to. So every test /// runs a **real** repository over bundled libgit2, drives the window through the same seams /// `CardWindowHost` wires, and reads every commit back through libgit2 rather than through the engine /// that made it. Nothing shells out to `git` (`AutoCommitTests`' rule, kept). // MARK: - Fixtures private let cardID = ItemID(rawValue: Ident.card1) private let cardPath = "\(Ident.lane1)/\(Ident.card1)" private let earlierComment = CommentIdent.one /// One card window on a Pro git board — the store, the committer, and the session, wired to each /// other exactly as `AppModel.beginSession` and `CardWindowHost` wire them. /// /// The stage-around is opened and closed through `open()` / `close()` below, which spell what /// `AppModel.setCardSession(_:for:)` does; that method's *own* wiring — that a card window's /// registration is what opens it — is pinned separately in `CardSessionStagingWiringTests`, over a /// real `AppModel`. @MainActor private final class Window { let fixture: WriterFixture let store: BoardStore let git: HistoryStore let committer: GitAutoCommitter let session = CardWindowSession() /// Commits the board already had when the window opened — every assertion here is a delta, so a /// board-open heal landing in the setup cannot be mistaken for a session's commit. private(set) var baseline = 0 private var token: UUID? init(fixture: WriterFixture, store: BoardStore, git: HistoryStore, committer: GitAutoCommitter) { self.fixture = fixture self.store = store self.git = git self.committer = committer } var comments: CardComments { session.comments } var body: CardBodyEditSession { session.body } /// Commits landed since the window opened. var commits: Int { committer.commitCount - baseline } func recordBaseline() { baseline = committer.commitCount } /// The window joins its board — `AppModel.registerCardWindow`, whose one git consequence is this /// exclusion. func open() { let token = UUID() self.token = token committer.beginCardSession(token) { [weak store] in guard let store, let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil } return path.folder(under: store.rootURL) } session.comments.open() } /// The close, in the order production runs it: the session's own writes land, *then* the folder is /// released, *then* the store settles, *then* the pipeline flushes (`CardWindowHost.finish`, /// `CloseFlushCoordinator.flushPendingWork` — "the store's pipeline, then the editor saves, then /// the pending commit"). /// /// The quiescence matters to the *message*, not to the commit: the composer diffs the store's /// snapshot against HEAD's tree, so a flush that raced the session's own reload would describe the /// window by its comment events alone. Production gets the same ordering from the committer's /// two-second debounce outliving the watcher's. func close() async { await session.endSession() if let token { committer.endCardSession(token) } token = nil await settle() await committer.flushNow() } /// Brings the store's snapshot up to what the session wrote, then waits for it to settle — the /// close flush's own first step (`CloseFlushCoordinator.flushPendingWork`: "the store's pipeline, /// then the editor saves, then the pending commit"). /// /// The reload is delivered by hand because this store has no watcher: the registry is what wires /// `FolderWatcher` to `handleWatcherEvent(_:)` in production, and a suite that acquired one would /// be testing FSEvents. What matters here is the *ordering* — the composer diffs the store's /// snapshot against HEAD's tree, so a flush that ran ahead of the session's own reload would /// describe the window by its comment events alone and lose the body edit. func settle() async { store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() } /// The half of the close that happens before the release — used to prove the release is what /// unblocks the commit rather than the passage of time. func endSessionOnly() async { await session.endSession() } func releaseAndFlush() async { if let token { committer.endCardSession(token) } token = nil await settle() await committer.flushNow() } } /// A board with a card, one already-posted comment, a repository, and a root commit that has all of /// it — the state a card window opens over. @MainActor private func makeWindow() async throws -> Window { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) try fixture.item(commentPath(earlierComment, inCard: cardPath), commentText(body: "posted earlier\n")) // The store first, and settled, so the board-open heals (the agent guide) are on disk *before* // the root commit rather than arriving as a mystery commit in the middle of a test. let store = try BoardStore(rootURL: fixture.root) await store.awaitQuiescence() let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: store.echoes)) #expect(await git.addGit()) let committer = try #require(git.committer) // Long enough that **only** an explicit `flushNow()` commits: "zero commits until close" has to be // a fact about the stage-around, not about a debounce that had not fired yet. committer.debounceInterval = .seconds(60) committer.lockRetryDelay = .milliseconds(5) committer.currentSnapshot = { [weak store] in store?.snapshot } store.commitSeam = .binding(to: committer) let window = Window(fixture: fixture, store: store, git: git, committer: committer) // The window's own seams, `CardWindowHost.configureSession`'s three lines. CardWindowHost.configureUndo(window.session, store: store, cardID: cardID) CardWindowHost.configureComments(window.session.comments, store: store, cardID: cardID, on: window.session.undo) window.session.comments.isEditable = true window.session.comments.cardFolder = fixture.url(cardPath) window.session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished } window.session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body) // One reconciling reload lands the board-open heals (the agent guide), and whatever the setup // left dirty commits now — so every assertion below is about the session and nothing else. await window.settle() await committer.flushNow() #expect(isClean(at: fixture.root), "the window opens over a settled tree") window.recordBaseline() return window } @MainActor private func editBody(_ window: Window, to text: String) { window.body.beginEditSession() window.body.edited(text) window.body.endEditSession() } @MainActor @discardableResult private func postComment(_ window: Window, body: String) -> ItemID? { window.comments.composer.edited(body) _ = window.comments.composer.flush() return window.comments.composer.postNow() } // MARK: Reading the repository back private struct Landed: Equatable { let subject: String let message: String let authorEmail: String } /// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer. private func landed(at boardRoot: URL, limit: Int = 32) throws -> [Landed] { let repository = try Repository.open(at: boardRoot) guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] } var records: [Landed] = [] var current: Commit? = tip while let commit = current, records.count < limit { records.append(Landed( subject: commit.summary, message: commit.message, authorEmail: commit.author.email )) current = (try? commit.parents)?.first } return records } private func isClean(at boardRoot: URL) -> Bool { GitCommitOperation.changedPaths(at: boardRoot).isEmpty } private func tracked(at boardRoot: URL) -> Set { Set(GitRepository.trackedPaths(at: boardRoot)) } // MARK: - The close flush @MainActor @Suite("Card session commits ▸ the close flush") struct CardSessionCloseFlushTests { @Test("A body edit, a comment post and a comment delete commit nothing until the window closes") func theSessionIsTheCommitUnit() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } window.open() editBody(window, to: "Edited in the window.\n") let posted = try #require(postComment(window, body: "A remark.\n")) #expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true) // Not "the debounce has not fired": the flush runs, sees the whole card folder staged around, // and commits nothing. await window.committer.flushNow() #expect(window.commits == 0, "no gesture inside an open card window is a commit") #expect(!isClean(at: window.fixture.root), "the session's writes are on disk, uncommitted") #expect(window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)"), "and the purge has not run: it belongs inside the close flush") await window.close() #expect(window.commits == 1, "window close flushes the session as one commit") #expect(isClean(at: window.fixture.root)) // One commit, three changes: "'Update card 'Fix login''-shaped, the composer folding the // card-scoped diff, body bullets carrying the events" (06 ▸ Rules ▸ Auto-commit) — the model // event keeps the subject, the thread rides in the body. let head = try #require(try landed(at: window.fixture.root).first) #expect(head.subject == "Edit card 'Fix login'") // A set, because the thread's two events sort by comment id and the posted one's is minted // fresh every run — the *events* are the claim, not their order among themselves. #expect(Set(head.message.split(separator: "\n").filter { $0.hasPrefix("- ") }) == [ "- Edit card 'Fix login'", "- Comment on 'Fix login'", "- Delete comment on 'Fix login'", ]) let paths = tracked(at: window.fixture.root) #expect(paths.contains("\(cardPath)/comments/\(posted.rawValue)/index.md"), "the post is in the commit") #expect(!paths.contains("\(cardPath)/comments/\(earlierComment)/index.md"), "so is the delete") #expect(!paths.contains { $0.hasPrefix("\(cardPath)/comments/.trash/") }, "and the purge — delete plus purge net to a removal (13 ▸ Interaction with the trash)") #expect(!window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)")) } @Test("The release is what unblocks the commit, not the end of the session's writes") func theReleaseIsTheGate() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } window.open() editBody(window, to: "Edited in the window.\n") // Everything the session owed disk is written, and the folder is still held. await window.endSessionOnly() await window.committer.flushNow() #expect(window.commits == 0) await window.releaseAndFlush() #expect(window.commits == 1) } @Test("A session with no net change registers no commit at all") func anEmptySessionCommitsNothing() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } window.open() await window.close() #expect(window.commits == 0, "a window that was only read is not an event") #expect(window.committer.lastFailure == nil, "an empty window is a no-op, never a failure") #expect(isClean(at: window.fixture.root)) } @Test("A session mixing two model events keeps the card's name in the subject") func aMixedSessionNamesItsCard() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } window.open() // Two *model* kinds — an edit and a restyle — so no single verb can head the window. 06's // retired "Update board" is exactly the subject that could not say which card this was. editBody(window, to: "Edited in the window.\n") window.store.applyStyle(to: .items([cardID]), background: .set("#334455"), on: window.session.undo) postComment(window, body: "A remark.\n") await window.close() #expect(window.commits == 1) let head = try #require(try landed(at: window.fixture.root).first) #expect(head.subject == "Mixed update — 3 changes to card 'Fix login'") } @Test("A draft the session never posted rides the close flush too, as one commit") func theDraftRidesTheClose() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } window.open() window.comments.composer.edited("half a thought\n") _ = window.comments.composer.flush() await window.committer.flushNow() #expect(window.commits == 0, "the draft-save cadence never becomes a commit stream") await window.close() #expect(window.commits == 1) #expect(tracked(at: window.fixture.root).contains("\(cardPath)/comments/.draft/index.md")) } } // MARK: - Board-side work, and the split @MainActor @Suite("Card session commits ▸ what an open window does not hold back") struct CardSessionInterimCommitTests { @Test("Board-side changes commit normally while a card window is open") func theRestOfTheBoardIsUnaffected() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } window.open() editBody(window, to: "Edited in the window.\n") try window.fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) window.committer.noteReloadLanded(sawForeignChange: true) await window.committer.flushNow() #expect(window.commits == 1, "the board's own change is not held by somebody's card window") #expect(tracked(at: window.fixture.root).contains("\(Ident.lane2)/\(BoardLoader.indexFileName)")) #expect(!isClean(at: window.fixture.root), "and the session folder is still held back") await window.close() #expect(window.commits == 2) #expect(isClean(at: window.fixture.root)) } @Test("A held window mixing foreign work with the session's splits into two commits at close") func theTwoCommitSplitSurvivesTheHeldWindow() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } window.open() // The app's own gesture, vouched for by a receipt in the store's ledger. postComment(window, body: "Mine.\n") // Somebody else's, inside the same card folder — an agent dropping a file the app never // witnessed. It is held back by the same exclusion, so the close flush is the first moment it // can land, and the split is what keeps it out of the user's commit. try window.fixture.file("\(cardPath)/attachments/notes.txt", Data("theirs\n".utf8)) window.committer.noteReloadLanded(sawForeignChange: true) // An interim flush that commits nothing must not spend the session's receipts — this is the // line the whole split depends on. await window.committer.flushNow() #expect(window.commits == 0) await window.close() #expect(window.commits == 2, "foreign and app-mediated never mix in one commit") let trail = try landed(at: window.fixture.root) let user = GitCommitOperation.userIdentity(at: window.fixture.root).email #expect(trail.first?.authorEmail == user, "the user's overwrite lands after the foreign version") #expect(trail.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail) #expect(trail.first?.subject.contains("Comment on 'Fix login'") == true) #expect(isClean(at: window.fixture.root)) } @Test("A second card window's session is held independently of the first's") func sessionsAreHeldPerCard() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } try window.fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) window.committer.noteReloadLanded(sawForeignChange: true) await window.committer.flushNow() window.recordBaseline() window.open() let other = UUID() window.committer.beginCardSession(other) { window.fixture.url("\(Ident.lane1)/\(Ident.card2)") } editBody(window, to: "Edited in the window.\n") try window.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/theirs.txt", Data("typing\n".utf8)) window.committer.noteReloadLanded(sawForeignChange: true) await window.committer.flushNow() #expect(window.commits == 0, "two held folders, nothing to commit") await window.close() #expect(window.commits == 1, "the first window's session, and only it") #expect(!isClean(at: window.fixture.root), "the second card is still somebody's open session") } } // MARK: - Crossing the session commit @MainActor @Suite("Card session commits ▸ undo crosses the session") struct CardSessionRestoreTests { @Test("Board ⌘Z after the close crosses the session commit and restores the deleted comment") func theSessionCommitIsOneUndoStep() async throws { let window = try await makeWindow() defer { window.fixture.tearDown() } let provider = GitHistoryProvider(boardRoot: window.fixture.root) provider.flushPendingCommit = { [weak committer = window.committer] in await committer?.flushNow() } provider.isHeld = { [weak committer = window.committer] in committer?.pause != nil } provider.suspendCommitting = { [weak committer = window.committer] in committer?.stop() } provider.resumeCommitting = { [weak committer = window.committer] in committer?.start() } window.committer.reportLanded = { [weak provider] landed in provider?.noteLanded(landed) } await provider.reseed() window.open() editBody(window, to: "Edited in the window.\n") postComment(window, body: "A remark.\n") #expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true) await window.close() await provider.settled() #expect(window.commits == 1) #expect(provider.canUndo, "the close commit is an ordinary step on the board's stack") await provider.cross(.undo) // A forward restore, never a rewrite (14-git-operations.md ▸ The forward-restore model). let trail = try landed(at: window.fixture.root) #expect(trail.first?.subject.hasPrefix("Undo: ") == true) #expect(try FrontmatterDocument.parse(window.fixture.indexText(cardPath)).body != "Edited in the window.\n", "the session's body edit is undone") #expect(window.fixture.exists("\(cardPath)/comments/\(earlierComment)"), "and the purged comment came back out of history — the whole session, in one step") } } // MARK: - The staging wiring /// What `AppModel` itself owes the rule: which moment opens the exclusion, which closes it, and the /// settle step's release. Over a real model and a real board session, because every one of these is a /// claim about production wiring rather than about the committer's own grammar. @MainActor @Suite("Card session commits ▸ the staging wiring") struct CardSessionStagingWiringTests { private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("CardSessionCommitTests-\(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) ) model.currentTier = { .pro } return (model, { try? FileManager.default.removeItem(at: folder) }) } 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.beginSession(ref: ref, store: store, recordID: recordID, access: nil) return ref } /// A Pro git board, opened through the model — so the committer under test is the one production /// composes, ledger and all. private func makeBoard(_ model: AppModel) async throws -> (fixture: WriterFixture, ref: BoardWindowRef) { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await seed.addGit()) return (fixture, try openBoard(model, at: fixture.root)) } @Test("Registering a card window opens the stage-around; unregistering releases it") func theWindowIsTheUnit() async throws { let (model, tearDown) = try makeModel() defer { tearDown() } let (fixture, ref) = try await makeBoard(model) defer { fixture.tearDown() } let committer = try #require(model.session(for: ref)?.git?.committer) let card = CardWindowRef(board: ref, cardID: cardID) model.registerCardWindow(card, session: CardWindowSession()) #expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1], "the whole open card folder, from the moment the window joins its board") model.unregisterCardWindow(card) #expect(committer.stagedAroundFolders.isEmpty) model.storeRegistry.release(try #require(model.session(for: ref)?.store)) } @Test("The settle step releases every open session's staging, and the operation's end restores it") func theSettleReleasesTheStaging() async throws { let (model, tearDown) = try makeModel() defer { tearDown() } let (fixture, ref) = try await makeBoard(model) defer { fixture.tearDown() } let committer = try #require(model.session(for: ref)?.git?.committer) let switcher = try #require(model.session(for: ref)?.git?.switcher) let card = CardWindowRef(board: ref, cardID: cardID) model.registerCardWindow(card, session: CardWindowSession()) #expect(!committer.stagedAroundFolders.isEmpty) // A window that is merely *open* answers `needsSettling` with `false` — no dirty buffer, no // raw source — so no modal is presented and the gate proceeds. Its folder is still held, and // a checkout over a held folder is the dirty tree the settle exists to prevent. #expect(await switcher.settleSessions?() == .proceed) #expect(committer.stagedAroundFolders.isEmpty, "the widened stage-around releases at settle, modal or no modal") switcher.resumeCommitting?() #expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1], "and the still-open window is a session again on the other side") model.unregisterCardWindow(card) model.storeRegistry.release(try #require(model.session(for: ref)?.store)) } @Test("The board's close flush releases each session before the pipeline flushes") func theCloseFlushReleasesBeforeItCommits() async throws { let (model, tearDown) = try makeModel() defer { tearDown() } let (fixture, ref) = try await makeBoard(model) defer { fixture.tearDown() } let committer = try #require(model.session(for: ref)?.git?.committer) let store = try #require(model.session(for: ref)?.store) let card = CardWindowRef(board: ref, cardID: cardID) model.registerCardWindow(card, session: CardWindowSession()) // The session's uncommitted work — the state a quit must not leave behind. _ = store.writeCardBody(inCard: cardID, body: "Typed and never committed.\n") await committer.flushNow() #expect(!isClean(at: fixture.root), "held, as an open window's folder should be") await model.closeBoard(ref: ref, cause: .quit) #expect(isClean(at: fixture.root), "nothing settled is left uncommitted by closing (06 ▸ Rules ▸ Auto-commit)") } }