diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 70a75f1..b3cc849 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -772,7 +772,14 @@ public final class AppModel { // **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 ledger is the store's own** (06 ▸ Interaction with external writers: "the Writer/echo + // machinery — the EchoLedger — lets the auto-committer classify every observed change, per + // file, as app-mediated or foreign"). `compose` defaults to a fresh one for the store-less + // callers (the add-git surface, unit tests), and a session that took that default would hand + // the committer a ledger nothing ever writes to: every commit this app made would classify + // foreign and be authored `Lanework External`. The default is a fallback, never this path's. + let git = HistoryStore.compose(boardRoot: store.rootURL, tier: tier, ledger: store.echoes) // 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 @@ -875,7 +882,12 @@ public final class AppModel { } 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() } + // The stage-around the settle released comes back with the committer: a card window still open + // after the restore is still a session (`resumeCardSessionStaging(for:)`). + provider.resumeCommitting = { [weak self, weak git] in + git?.committer?.start() + self?.resumeCardSessionStaging(for: ref) + } // **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"). // @@ -903,7 +915,11 @@ public final class AppModel { // `GitRestoreOperation.plan`. provider?.noteDiscarded(cardFolderName: folder) } - return await gate.settle(touching: paths) + let outcome = await gate.settle(touching: paths) + // **Only on `.proceed`** — a cancelled or failed settle leaves the board exactly as it + // was, sessions and their staging included. + if outcome == .proceed { self.releaseCardSessionStaging(for: ref) } + return outcome } provider.seed() @@ -929,7 +945,12 @@ public final class AppModel { switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() } switcher.isHeld = { [weak git] in git?.committer?.pause != nil } switcher.suspendCommitting = { [weak git] in git?.committer?.stop() } - switcher.resumeCommitting = { [weak git] in git?.committer?.start() } + // As on the restore path: what the settle released is a session that has not ended, and the + // window is still open on the other side of the checkout. + switcher.resumeCommitting = { [weak self, weak git] in + git?.committer?.start() + self?.resumeCardSessionStaging(for: ref) + } // **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by // construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo // starts empty". @@ -974,7 +995,11 @@ public final class AppModel { switcher?.noteDiscarded(cardFolderName: folder) } // Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`. - return await gate.settleAll() + let outcome = await gate.settleAll() + // The switch's flush runs next and must find a tree it can settle whole — see + // `releaseCardSessionStaging(for:)` for why the modal's own predicate is not enough. + if outcome == .proceed { self.releaseCardSessionStaging(for: ref) } + return outcome } // **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the @@ -1097,47 +1122,94 @@ public final class AppModel { } sessions[ref.board]?.cardRefs.insert(ref) cardSessions[ref] = session + // **The window *is* the commit unit** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31), so the + // stage-around opens here — with the window — rather than at the body's first Edit→Preview + // flip. From this line to `unregisterCardWindow` nothing this card's folder receives can land + // in an interim commit. + setCardSession(true, for: ref) } func unregisterCardWindow(_ ref: CardWindowRef) { sessions[ref.board]?.cardRefs.remove(ref) cardSessions[ref] = nil - // A window that left without its session ending — a crash-shaped teardown, or a dismissal - // that raced the flush — must not leave its card folder excluded from staging forever. - setEditSession(false, for: ref) + // **The close flush's release** — and, for a window that left without its session ending (a + // crash-shaped teardown, a dismissal that raced the flush), the backstop that must not leave a + // card folder excluded from staging forever. Both are the same line because both mean the same + // thing: this window is no longer holding its folder back. + setCardSession(false, for: ref) } - /// Tokens the committer knows each card window's Edit session by. Beside `cardSessions` for its + /// Tokens the committer knows each card window's session by. Beside `cardSessions` for its /// reason: this is the seam table's third column, written only here. @ObservationIgnored - private var editSessionTokens: [CardWindowRef: UUID] = [:] + private var cardSessionTokens: [CardWindowRef: UUID] = [:] - /// **A card window's Edit session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit: - /// the committer "stages around open Edit sessions"). + /// **A card window's session opened or closed** (06-history-undo.md ▸ Rules ▸ Auto-commit: the + /// committer "stages around the whole open card folder"). /// - /// This is the honest seam between the two halves of the rule: `CardBodyEditSession` knows a - /// session is open, the committer knows what staging is, and only the app model knows which board - /// a card window belongs to and how to reach its committer. A board with no committer — the free - /// tier, a Pro board with no repository — records nothing, which is the same `nil` every other - /// git seam takes. + /// This is the honest seam between the two halves of the rule: the host knows a window exists, the + /// committer knows what staging is, and only the app model knows which board a card window belongs + /// to and how to reach its committer. A board with no committer — the free tier, a Pro board with + /// no repository — records nothing, which is the same `nil` every other git seam takes. /// /// The card's folder is handed over as a **closure**, not a URL: a card can change lane, or be /// moved into the trash, in the middle of a session, and what must be staged around is wherever /// it is at the moment of the commit. `BoardStore.cardBodyTarget` is the resolution that spans /// both containers, which is exactly why the body save uses it too. - func setEditSession(_ isOpen: Bool, for ref: CardWindowRef) { + /// + /// Idempotent both ways: re-opening reuses the token (a settle that released it, then a resume), + /// and closing an already-closed session reaches a committer that has nothing to remove. + func setCardSession(_ isOpen: Bool, for ref: CardWindowRef) { + guard isOpen else { + // **The token goes whether or not there is anyone left to tell.** A card window's own + // teardown can land after its board's, and a token kept past the board it names would + // outlive everything that could ever release it. + guard let token = cardSessionTokens.removeValue(forKey: ref) else { return } + sessions[ref.board]?.git?.committer?.endCardSession(token) + return + } guard let session = sessions[ref.board], let committer = session.git?.committer else { return } - if isOpen { - let token = editSessionTokens[ref] ?? UUID() - editSessionTokens[ref] = token - let cardID = ref.cardIdentity - committer.beginEditSession(token) { [weak store = session.store] in - guard let store, - let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil } - return path.folder(under: store.rootURL) - } - } else if let token = editSessionTokens.removeValue(forKey: ref) { - committer.endEditSession(token) + let token = cardSessionTokens[ref] ?? UUID() + cardSessionTokens[ref] = token + let cardID = ref.cardIdentity + committer.beginCardSession(token) { [weak store = session.store] in + guard let store, + let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil } + return path.folder(under: store.rootURL) + } + } + + /// **The stage-around releases at the settle step** (06 ▸ Branch switching; ▸ Rules ▸ Undo restore + /// vs open Edit sessions) — every open card window on this board, unconditionally. + /// + /// ### Why unconditionally, rather than through the modal + /// + /// The save-or-discard step asks about *buffers* — "unsaved keystrokes, or on-disk ~700 ms saves + /// the session hasn't committed" — and a window that is merely open, with a comment posted an hour + /// ago and a clean editor, answers `needsSettling` with `false`. Under the widened stage-around + /// that window is still holding its whole folder out of every commit, so leaving it held would + /// walk a checkout onto a dirty tree and break the one guarantee the settle exists to buy: "with + /// sessions settled the restore runs on a settled tree … it cannot fail dirty". + /// + /// Releasing is therefore structural and silent, and the modal keeps its own narrower predicate: + /// Save All's flush then carries the session's commit, and Discard's reverted bytes are + /// reconciled by the operation itself (`GitRestoreOperation.plan`, `GitBranchSwitcher`), which is + /// why this runs *after* the gate has answered rather than before it. + func releaseCardSessionStaging(for ref: BoardWindowRef) { + for cardRef in sessions[ref]?.cardRefs ?? [] { + setCardSession(false, for: cardRef) + } + } + + /// **The next session begins** — the other half of `releaseCardSessionStaging(for:)`, run when the + /// operation behind the settle has finished with the tree. + /// + /// A window that is still open after a restore or a branch switch is still a session, and its + /// folder must go back to being staged around. Idempotent, so the paths that resume without ever + /// having released (a cancelled switch, the open-time leftover check) cost a dictionary lookup. + func resumeCardSessionStaging(for ref: BoardWindowRef) { + for cardRef in sessions[ref]?.cardRefs ?? [] { + setCardSession(true, for: cardRef) } } @@ -1353,6 +1425,15 @@ public final class AppModel { }, endCardSession: { [weak self] cardRef in await self?.cardSessions[cardRef]?.endSession() + // **The release, here rather than only at the host's unregister** (06 ▸ Rules ▸ + // Auto-commit). The unregister does release it — that is what closes a window on its + // own — but it arrives from the *window's* teardown, which this sequence waits for + // only up to `cardDrainDeadline` and then proceeds anyway. A quit whose last window + // was slow to disappear would then flush with the folder still staged around and leave + // a settled session uncommitted, which is precisely what "nothing settled is ever left + // ... uncommitted by closing" forbids. Ending the session is this step's own act, so + // releasing what the session held is too. Idempotent with the unregister. + self?.setCardSession(false, for: cardRef) }, dismissCardWindow: { [weak self] cardRef in self?.windowDismisser?(value: cardRef) @@ -1372,8 +1453,9 @@ public final class AppModel { // window close and app quit flush the pipeline — any pending editor save, then the // pending auto-commit — before teardown; nothing settled is ever left unsaved or // uncommitted by closing"). By the time it runs, step 1 has ended every card window's - // Edit session, so nothing is staged around and each session's body lands in exactly one - // commit. `nil` on every board with no committer, which is the whole free tier. + // session *and released its stage-around*, so each session — body, comments, purge and + // all — lands in exactly one commit. `nil` on every board with no committer, which is the + // whole free tier. committerFlush: { [weak self] in await self?.sessions[ref]?.git?.committer?.flushNow() }, diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 5c45a40..5e54f5c 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -634,14 +634,12 @@ struct CardWindowHost: View { bodyPresentation.beginEdits = { [session] in session.body.beginEditSession() } - // **The stage-around registry's one wire** (06-history-undo.md ▸ Rules ▸ Auto-commit). The - // buffer announces its session boundary, the app model knows which board this card belongs - // to, and the committer knows what staging is; this line is the join, and it is the only - // place all three are in scope. A free-tier board — or any board with no repository — has no - // committer, so `setEditSession` records nothing and the buffer never learns the difference. - session.body.editSessionDidChange = { [appModel, ref] isEditing in - appModel.setEditSession(isEditing, for: ref) - } + // **No stage-around wire here any more** (06-history-undo.md ▸ Rules ▸ Auto-commit, widened + // 2026-07-31 — recorded because its absence is the change): the Edit→Preview flip used to open + // and close the committer's exclusion, and the unit is now the *window*, so the exclusion is + // opened by `AppModel.registerCardWindow` and released by `unregisterCardWindow` — after the + // session's own last writes. A flip that still moved it would un-hold the folder in the middle + // of a session whose comment posts and draft saves are supposed to be inside one commit. Self.configureRawSource( rawSource, body: session.body, @@ -949,15 +947,22 @@ struct CardWindowHost: View { /// Leaves the session and lets the store go. /// /// The release rides **behind** the session's end rather than beside it: a session that has - /// something to commit (m6) needs the store it is committing through, and a refcount that hit - /// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the - /// ordering costs one run-loop turn — the point is that the shape is already right. + /// something to commit needs the store it is committing through, and a refcount that hit zero + /// first would have stopped the watcher underneath it. + /// + /// **Unregistering rides behind it too** (06-history-undo.md ▸ Rules ▸ Auto-commit: "window close + /// flushes the session as one commit"), which is new in this milestone and is the whole ordering + /// the one-commit rule rests on: unregistering is what releases the committer's stage-around, and + /// releasing it before `endSession()` had written the body's last keystrokes, posted the draft and + /// purged `comments/.trash/` would leave a debounce free to fire over a half-finished session — + /// two commits where the design promises one. The board's own close flush drives the same two + /// steps in the same order through `CloseFlushCoordinator`, one window at a time. private func finish() { guard case let .open(store) = phase else { return } phase = .closing - appModel.unregisterCardWindow(ref) Task { @MainActor in await session.endSession() + appModel.unregisterCardWindow(ref) appModel.storeRegistry.release(store) } } diff --git a/Kanban/Git/CommitMessageEngine.swift b/Kanban/Git/CommitMessageEngine.swift index 92a6f57..80e7e13 100644 --- a/Kanban/Git/CommitMessageEngine.swift +++ b/Kanban/Git/CommitMessageEngine.swift @@ -35,9 +35,35 @@ import Foundation /// window still commits with its strays named. enum CommitMessageEngine { - /// **06's own mixed-window fallback**: "genuinely mixed windows fall back to 'Update board' — - /// always with a bulleted body naming every event". - static let mixedSubject = "Update board" + /// **A genuinely mixed window says so** (06 ▸ Commit messages, re-ruled 2026-07-31 — "retiring + /// the bare 'Update board' fallback"): + /// + /// > **"Mixed update — N changes"**, always with a bulleted body naming every event, so the + /// > oneline log stays scannable and never dresses a grab-bag as one thing; when every event in + /// > the window shares one item — the card-window session flush's usual shape — the subject keeps + /// > the name: **"Mixed update — N changes to card '⟨title⟩'"**. + /// + /// The named case is the reason this exists: a card window's close flush is *by construction* a + /// window of one card's changes, and "Update board" was the one subject that could not say which + /// card a whole session belonged to. + /// + /// - Parameters: + /// - count: how many events the body will list — the subject counts changes, not commits. + /// - item: the rendered noun phrase every event shares ("card 'Fix login'"), or `nil` when they + /// genuinely span the board. + static func mixedSubject(_ count: Int, item: String? = nil) -> String { + let changes = count == 1 ? "1 change" : "\(count) changes" + guard let item else { return "Mixed update — \(changes)" } + return "Mixed update — \(changes) to \(item)" + } + + /// The subject a window with no describable event at all falls to. + /// + /// Vanishingly rare and deliberately kept: the tree's commit condition is the *tree*, not the + /// snapshot diff, so a window whose every path composed nothing still commits rather than leaving + /// the tree dirty (06 ▸ Commit messages ▸ Non-snapshot files commit too). "0 changes" would be a + /// lie about a commit that does contain something; this says what it honestly is. + static let unnamedSubject = "Update board" /// **An untitled item reads "(untitled)" — never a bare `""`** (06 ▸ Commit messages: "a /// pathfinder edge fixed, not carried"). @@ -65,7 +91,7 @@ enum CommitMessageEngine { // commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is // no last-committed snapshot to diff against." guard !request.isRootCommit else { return GitRepository.initialCommitSubject } - return assemble(events(for: request)) + return assemble(events(for: request), request: request) } /// Every event this commit is composed of — model events in board reading order, then the @@ -91,14 +117,16 @@ enum CommitMessageEngine { /// - One event: its own subject, plus its detail line where it has one. /// - Several: a subject chosen from the **headline** events, and a bullet naming *every* event — /// "so the oneline log stays scannable and the full message stays complete". - private static func assemble(_ events: [Event]) -> String { - guard !events.isEmpty else { return mixedSubject } + private static func assemble(_ events: [Event], request: CommitMessageRequest) -> String { + guard !events.isEmpty else { return unnamedSubject } if events.count == 1 { guard let detail = events[0].detail else { return events[0].subject } return "\(events[0].subject)\n\n\(detail)" } let body = events.map { "- \($0.bullet)" }.joined(separator: "\n") - return "\(subject(for: headline(of: events)))\n\n\(body)" + let subject = subject(for: headline(of: events)) + ?? mixedSubject(events.count, item: sharedItem(of: events, request: request)) + return "\(subject)\n\n\(body)" } /// The events allowed to *choose* the subject, in the order 06 ranks them. @@ -116,15 +144,52 @@ enum CommitMessageEngine { } /// "A single event is the subject; several events of one kind fold into a plural subject, with - /// shared destinations preserved; genuinely mixed windows fall back to 'Update board'." - private static func subject(for headline: [Event]) -> String { - guard let first = headline.first else { return mixedSubject } + /// shared destinations preserved" — or `nil`, which is the genuinely mixed window the caller names + /// with `mixedSubject(_:item:)`. + private static func subject(for headline: [Event]) -> String? { + guard let first = headline.first else { return nil } if headline.count == 1 { return first.subject } - guard Set(headline.map(\.kind)).count == 1 else { return mixedSubject } + guard Set(headline.map(\.kind)).count == 1 else { return nil } let destinations = Set(headline.compactMap(\.destination)) return first.kind.plural(headline.count, destination: destinations.count == 1 ? destinations.first : nil) } + /// **The one item every event in this window belongs to, rendered** — "card 'Fix login'" — or + /// `nil` when they span more than one. + /// + /// Read from the events' own **paths** rather than from a field each event would have to remember + /// to carry: an event's paths are the only thing in this engine that is always true about where it + /// came from, and a card's folder is the one component that survives a lane move. A window with a + /// path that belongs to no card at all — the board's own `index.md`, a lane, a stray, the agent + /// guide — is by definition not one card's window, so a single `nil` answer decides the whole + /// question. + private static func sharedItem(of events: [Event], request: CommitMessageRequest) -> String? { + var folder: String? + for event in events { + for path in event.paths { + guard let card = cardFolder(of: path) else { return nil } + if let folder, folder != card { return nil } + folder = card + } + } + guard let folder else { return nil } + let title = cardTitlesByPath(request)[folder] ?? untitledPlaceholder + return "card \(quotedSubject(title))" + } + + /// The `/` (or `.trash/`) folder a board-root-relative path belongs to, or `nil` + /// when it belongs to no card — the board's `index.md`, a lane's, a root stray. + /// + /// Comment paths answer through `CommentPath`, which already knows the thread's two containers, so + /// the one rule about where a card lives is not spelled twice. + private static func cardFolder(of path: String) -> String? { + if let comment = CommentPath.classify(path) { return comment.cardPath } + let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard components.count >= 2, BoardLoader.isUUIDShaped(components[1]) else { return nil } + guard components[0] == Paths.trashFolder || BoardLoader.isUUIDShaped(components[0]) else { return nil } + return "\(components[0])/\(components[1])" + } + // MARK: - The structural diff private static func modelEvents( @@ -983,7 +1048,7 @@ enum CommitMessageEngine { case .relabelBoard: return "Relabel board" case .assignBoard: return "Assign board" case .dueBoard: return "Set due date on board" - case .updateBoard: return CommitMessageEngine.mixedSubject + case .updateBoard: return CommitMessageEngine.unnamedSubject case .agentGuide: return "Update agent guide" case .updatePath: return "Update \(count) files" diff --git a/Kanban/Git/GitAutoCommitter.swift b/Kanban/Git/GitAutoCommitter.swift index 2cacc33..050ee75 100644 --- a/Kanban/Git/GitAutoCommitter.swift +++ b/Kanban/Git/GitAutoCommitter.swift @@ -208,13 +208,13 @@ public final class GitAutoCommitter { @ObservationIgnored private var holdsForeignChanges = false - /// Open Edit sessions, each answering with the folder to stage around *right now*. + /// Open **card-window sessions**, each answering with the folder to stage around *right now*. /// /// A closure per session rather than a stored URL, because a card can move lane, or into the /// trash, in the middle of a session — its folder is a fact about the current snapshot, not - /// about when Edit was entered. + /// about when the window opened. @ObservationIgnored - private var editSessions: [UUID: @MainActor () -> URL?] = [:] + private var cardSessions: [UUID: @MainActor () -> URL?] = [:] @ObservationIgnored private var pending: Task? @@ -317,32 +317,46 @@ public final class GitAutoCommitter { apply(result.outcome, healPaths: result.healPaths) } - // MARK: - Edit sessions + // MARK: - Card-window sessions - /// **Registers an open Edit session's card folder** (06 ▸ Rules ▸ Auto-commit: "The committer - /// stages around open Edit sessions: a board change committing mid-session excludes the session - /// card's folder from staging, so a lane move never sweeps half-typed body text into its - /// commit"). + /// **Registers an open card window's folder** (06 ▸ Rules ▸ Auto-commit, widened 2026-07-31 — + /// "Board history sees **card-window sessions, not gestures**"): + /// + /// > while a card's window is open, everything happening inside it — the body editor's ~700 ms + /// > crash-safe disk saves, comment posts and deletes, draft-save cadence, sidebar changes — + /// > stays **uncommitted**, and the committer **stages around the whole open card folder** (the + /// > former Edit-session stage-around, widened; comments included). + /// + /// So the unit is the **window**, not the body's Edit session: the token is minted when the + /// window joins its board and released when its session ends, and everything the window writes in + /// between — body saves, comment posts and deletes, inline comment edits, the composer's draft, + /// the `comments/.trash/` purge — is inside one folder that no interim flush can see. /// /// The exclusion is absolute where it applies: "whole-root staging widening *what* commits, never /// overriding the exclusion" (06 ▸ Commit messages ▸ Non-snapshot files commit too). A stray /// dropped inside the session card's folder therefore waits for the session to end, along with - /// the body. + /// everything else under it — a foreign write to the same card included, which is what makes the + /// close flush's two-commit split the *first* moment that change can land (06 ▸ Rules ▸ + /// Auto-commit: "The EchoLedger's two-commit split still applies at close when the held window + /// mixes foreign changes to that card with the app's own"). /// /// - Parameters: /// - token: the window's identity, so ending twice is idempotent. /// - cardFolder: asked at every flush rather than stored, so a card moved mid-session is staged /// around at wherever it now is. - public func beginEditSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) { - editSessions[token] = cardFolder + public func beginCardSession(_ token: UUID, cardFolder: @escaping @MainActor () -> URL?) { + cardSessions[token] = cardFolder } - /// Ends one, and **nudges** — which is what makes "exactly one body commit per session" true: - /// the session's debounced saves committed nothing while it was open, and this is the moment its - /// whole diff becomes committable (06 ▸ Rules ▸ Auto-commit: the Edit→Preview flip is "the - /// effective Save button"; raw-source entry and window close end the session too). - public func endEditSession(_ token: UUID) { - guard editSessions.removeValue(forKey: token) != nil else { return } + /// Ends one, and **nudges** — which is what makes "window close flushes the session as one + /// commit" true: the session's writes committed nothing while the window stood, and this is the + /// moment its whole diff becomes committable (06 ▸ Rules ▸ Auto-commit). + /// + /// Called after the session's own last writes have landed (`CardWindowSession.endSession()` runs + /// to completion first — `AppModel.unregisterCardWindow`), so the diff this arms over is the + /// session's *final* state rather than its second-to-last. + public func endCardSession(_ token: UUID) { + guard cardSessions.removeValue(forKey: token) != nil else { return } arm() } @@ -370,7 +384,7 @@ public final class GitAutoCommitter { /// Whether a card window's folder is currently staged around — the stage-around rule, made /// assertable without reaching into private state. public var stagedAroundFolders: [URL] { - editSessions.values.compactMap { $0() } + cardSessions.values.compactMap { $0() } } // MARK: - Flushing @@ -436,7 +450,7 @@ public final class GitAutoCommitter { private func makeInput() -> FlushInput? { FlushInput( boardRoot: boardRoot, - excludedFolders: editSessions.values.compactMap { $0() }.map(EchoLedger.key), + excludedFolders: stagedAroundKeys, receipts: harvested, composer: composer, snapshot: currentSnapshot?() @@ -639,7 +653,7 @@ public final class GitAutoCommitter { 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() + dropHarvestOutsideOpenSessions() holdsForeignChanges = false reportRecovery?() Self.logger.debug("auto-commit landed \(oids.count, privacy: .public) commit(s)") @@ -649,7 +663,7 @@ public final class GitAutoCommitter { // whole window was staged around. Silent, and the window closes either way. pause = nil lastFailure = nil - harvested.removeAll() + dropHarvestOutsideOpenSessions() holdsForeignChanges = false reportRecovery?() @@ -688,4 +702,34 @@ public final class GitAutoCommitter { harvested[path] = entry } } + + /// **Forgets the receipts a flush has spent — and keeps the ones it could not** (06 ▸ Interaction + /// with external writers: attribution "per file", off the ledger). + /// + /// A receipt is cleared because the commit it described has landed. Under the widened + /// stage-around (`beginCardSession`) a flush routinely lands *without* the session folder, so its + /// receipts have not been spent at all: they describe writes still sitting uncommitted on disk, + /// waiting for the close flush. Clearing them wholesale is what would make the two-commit split at + /// close wrong in exactly the case it exists for — the app's own body save and comment posts would + /// arrive at the close unvouched-for and commit as `Lanework External`, blaming the outside world + /// for the user's own session. + /// + /// So the drop is scoped to what the flush could see: everything outside every open session's + /// folder goes, everything inside one stays until that session's own commit spends it. + private func dropHarvestOutsideOpenSessions() { + let open = stagedAroundKeys + guard !open.isEmpty else { + harvested.removeAll() + return + } + harvested = harvested.filter { key, _ in + open.contains { key == $0 || key.hasPrefix($0 + "/") } + } + } + + /// The open sessions' folders as `EchoLedger` keys — what both the staging exclusion and the + /// harvest's scoped drop compare against, resolved in one place so they cannot disagree. + private var stagedAroundKeys: [String] { + cardSessions.values.compactMap { $0() }.map(EchoLedger.key) + } } diff --git a/Kanban/History/CardWindowUndo.swift b/Kanban/History/CardWindowUndo.swift new file mode 100644 index 0000000..2750ef4 --- /dev/null +++ b/Kanban/History/CardWindowUndo.swift @@ -0,0 +1,218 @@ +import Foundation + +// MARK: - CardWindowUndo + +/// **One card window's undo session** — the second of 13-native-undo.md's two levels (re-ruled +/// 2026-07-31, the session-coarsening model). +/// +/// > "the **board stack** is owned by the board session and shared by board surfaces; a **card window +/// > owns its own stack** for the session it represents — every gesture issued in that window +/// > (comment post/delete/edit, body Edit sessions, style/details changes, attachment ops where +/// > undoable) registers there at fine grain, and `window.undoManager` answers with it (standard +/// > per-window AppKit scoping)." +/// +/// ### Two jobs, and the second is why this is a type rather than a stored provider +/// +/// **The fine stack**: an ordinary `NativeHistoryProvider`, in *both* tiers. The steps a card window +/// registers are values-based inverses at the Writer boundary — the same shape whatever substrate the +/// board's own history has — so a Pro git board's card window still walks its own gestures with the +/// native grammar, and only the *coarse* close unit is tier-split ("one native board step, or one +/// commit" — 06-history-undo.md ▸ Undo routing). +/// +/// **The fold**: window close registers "one coarse step ... whose undo restores the card subtree to +/// its session-start state ... and whose redo reapplies the net effect". That net effect is exactly +/// the steps still on this stack, composed — see `netEffect()`. +/// +/// ### Why the session-start capture is distributed rather than a subtree snapshot +/// +/// A single snapshot of the card folder taken at window open, diffed against disk at close, was the +/// obvious shape and is the wrong one: it would sweep in **every** change to the card during the +/// window's life, an agent's included, and 13 ▸ Rules is explicit that "foreign writes never join the +/// stack". A disk diff cannot tell the user's gesture from somebody else's write; a stack of the +/// user's gestures never has to. +/// +/// So the capture lives where it already lived — each fine step carries the value its write +/// overwrote (`CardBodyEditSession`'s session-origin bytes, `CommentEditSession.sessionStart`, the +/// prior style fields) — and the coarse step is their composition. That also makes the coarse step's +/// *after*-values the values the app itself wrote, so a foreign edit landing between a gesture and +/// the close makes the step stale (13's field-level predicate) instead of being quietly reverted. +@MainActor +public final class CardWindowUndo { + + // MARK: The fine stack + + /// This window's own steps. `NativeHistoryProvider` verbatim: the grammar a window needs — one + /// register is one step, a stale step is skipped and falls through, a failed one stays — is the + /// grammar that type already implements and this milestone had no reason to fork. + public let stack = NativeHistoryProvider() + + /// Whether the board is refusing writes, wired by the host once the window has joined its board + /// (`BoardUndoManager.isReadOnly`'s closure, one level down): the lock disables Undo and Redo in + /// a card window exactly as it does on the board (13 ▸ Rules ▸ locks). + public var isReadOnly: @MainActor () -> Bool = { false } + + /// **What this window hands back from `windowWillReturnUndoManager`** — the AppKit face over the + /// stack above, so the Edit menu's rows light up, disable and retitle from *this window's* + /// gestures. + /// + /// There is deliberately **no fall-through**: this manager never consults the board's stack, so + /// "exhausting the window stack beeps; it never reaches board history" (06 ▸ Undo routing) is a + /// property of what the window answers with rather than a rule someone has to remember to apply. + public private(set) lazy var manager: BoardUndoManager = BoardUndoManager( + history: stack, + isReadOnly: { [weak self] in self?.isReadOnly() ?? false } + ) + + // MARK: The writes behind the steps + + /// One gesture's write, in the raw terms a fold needs: the two halves and what each of them + /// leaves behind. + /// + /// It is the *unwrapped* pair — `BoardStore.registerStep`'s arguments before that method wraps + /// them in validation and a `performWrite` bracket. The coarse step has to compose the writes + /// themselves, because a composition of wrapped steps would validate and bracket each component + /// separately, which is precisely the partial session revert 13 forbids ("any stale component + /// skips the whole step — never a partial session revert"). + struct Write { + let undoExpects: [HistoryExpectation] + let redoExpects: [HistoryExpectation] + let undo: @MainActor (BoardStore) throws -> Void + let redo: @MainActor (BoardStore) throws -> Void + } + + /// The raw writes, keyed by the step they belong to. Keyed rather than appended so that a gesture + /// undone inside the window drops out of the fold for free: membership is `stack.pendingSteps`', + /// and this is only the lookup. + private var writes: [UUID: Write] = [:] + + public init() {} + + /// Records the raw write behind a step this window is about to register. Called by + /// `BoardStore.registerStep`, which is the one place both halves are in hand. + func record(_ id: UUID, _ write: Write) { + writes[id] = write + } + + // MARK: - The fold + + /// **The session's net effect, or `nil` when there is none** — what the window's close registers + /// on the board stack as one coarse step, and what "a session with no net change registers + /// nothing" means in code. + /// + /// ### The composition + /// + /// - **undo** — every live step's undo, newest first. Replaying the session backwards lands on the + /// state it started from, deleted comments included: their backing is still in + /// `comments/.trash/` because this step's own existence is what defers the purge. + /// - **redo** — every live step's redo, oldest first. The session, replayed. + /// - **the undo's expectations** — the state the session's writes left, folded **last-write-wins** + /// per field: what must still be true for the whole step to be safe to cross. + /// - **the redo's expectations** — the state the coarse undo leaves, folded **first-write-wins**: + /// the mirror, for the same reason. + /// + /// One `HistoryStep` carrying every component's expectations is what makes validation + /// transactional without a word of new machinery: `BoardStore.cross` already checks the whole list + /// before writing anything, so any stale component skips the whole step with the ordinary + /// info-tone banner. + /// + /// ### "No net change" is an equality, not a step count + /// + /// A body typed away and typed back across two Edit sessions is two steps whose folded before and + /// after say the same thing, and 13 says that session registers nothing. Comparing the two folds + /// is the whole test — and it is exact, because both sides are the values the app itself wrote. + func netEffect() -> Write? { + // Every step on this stack was recorded here as it was registered, and nothing is ever + // removed — a step undone inside the window may still be redone, so its write has to survive + // being off the undo stack. The table is therefore complete by construction and dies with the + // window; pruning it against one stack would silently empty a redone step's half of the fold. + let live = stack.pendingSteps.compactMap { writes[$0.id] } + guard !live.isEmpty else { return nil } + + let after = Self.fold(live.map(\.undoExpects)) + let before = Self.fold(live.reversed().map(\.redoExpects)) + guard after != before else { return nil } + + return Write( + undoExpects: after.expectations, + redoExpects: before.expectations, + undo: { store in + for write in live.reversed() { try write.undo(store) } + }, + redo: { store in + for write in live { try write.redo(store) } + } + ) + } + + // MARK: Folding + + /// The merged expectations of a sequence of writes, **later entries winning** — so folding a list + /// in registration order yields the state the last write left, and folding it reversed yields the + /// state the first write found. + /// + /// Merging is per target *and* per field: two style gestures that set different dimensions of one + /// card fold to one target carrying both, while two that set the same dimension fold to one value. + /// Presence is whole-target and takes the later answer, which is what makes a post-then-delete of + /// one comment fold to "in the trash" rather than to two contradictory claims. + static func fold(_ lists: [[HistoryExpectation]]) -> Fold { + var fold = Fold() + for list in lists { + for expectation in list { fold.merge(expectation) } + } + return fold + } + + /// A set of expectations being merged, in first-seen target order. + struct Fold: Equatable { + + private struct Target: Equatable { + var presence: HistoryExpectation.Presence + var fields: [ExpectedField.Kind: ExpectedField] + } + + private var targets: [URL: Target] = [:] + /// First-seen order, so the folded list a step carries is stable rather than hash-ordered — + /// a skip banner and a test both read better when the card comes before its comments. + /// + /// **Deliberately outside equality** (see `==`): the two folds a net-effect test compares are + /// built by walking the same writes in opposite directions, so their orders differ by + /// construction while the question being asked — did anything actually change — is about the + /// values alone. + private var order: [URL] = [] + + static func == (lhs: Fold, rhs: Fold) -> Bool { lhs.targets == rhs.targets } + + /// **A later `.absent` clears what earlier writes said about the path**, which is how a move + /// inside one session folds correctly: a comment edited and then deleted leaves nothing at its + /// live path, and carrying the edit's body expectation there would make the session's own step + /// stale the moment it was registered. Every move-shaped step declares both of its paths + /// precisely so this is expressible (`BoardStore.deleteComment`, `postComment`). + mutating func merge(_ expectation: HistoryExpectation) { + let key = expectation.folder.standardizedFileURL + if targets[key] == nil { + order.append(key) + targets[key] = Target(presence: expectation.presence, fields: [:]) + } + if expectation.presence == .absent { + targets[key] = Target(presence: .absent, fields: [:]) + return + } + targets[key]?.presence = expectation.presence + for field in expectation.fields { + targets[key]?.fields[field.kind] = field + } + } + + /// The fold, back in the currency `HistoryStep` speaks. Fields are ordered by the kind's own + /// declaration order so two equal folds always render identically. + var expectations: [HistoryExpectation] { + order.compactMap { key in + guard let target = targets[key] else { return nil } + let fields = Self.fieldOrder.compactMap { target.fields[$0] } + return HistoryExpectation(folder: key, presence: target.presence, fields: fields) + } + } + + private static let fieldOrder: [ExpectedField.Kind] = [.title, .order, .width, .background, .icon, .body] + } +} diff --git a/Kanban/UI/Card/CardBodyEditSession.swift b/Kanban/UI/Card/CardBodyEditSession.swift index 9b1d6c3..86508db 100644 --- a/Kanban/UI/Card/CardBodyEditSession.swift +++ b/Kanban/UI/Card/CardBodyEditSession.swift @@ -113,16 +113,17 @@ public final class CardBodyEditSession { /// **The session boundary, announced** — called with `true` when an Edit session opens and /// `false` when it ends, and with nothing in between. /// - /// `CardWindowHost` points it at the board's auto-committer, which registers the card's folder to - /// stage around while the session stands and **nudges** when it ends (06-history-undo.md ▸ Rules - /// ▸ Auto-commit: the Edit→Preview flip is "the effective Save button", and raw-source entry and - /// window close end the session too). That nudge is what turns a session's several debounced - /// saves into exactly one commit: they commit nothing while the folder is excluded, and the - /// whole diff becomes committable at once when it is not. + /// **No longer the committer's stage-around boundary** (06-history-undo.md ▸ Rules ▸ Auto-commit, + /// widened 2026-07-31 — "Board history sees card-window sessions, not gestures"): the exclusion + /// used to open and close with this flip, and it now opens with the *window* and releases when its + /// session ends, so a comment posted with the body in Preview is inside the same one commit as the + /// body. `CardWindowHost` therefore no longer wires this to anything, and the Edit→Preview flip is + /// a save point rather than a commit point. /// - /// A closure for `save`'s reason exactly — this type is a buffer and a clock, and it stays - /// testable by having no idea what a repository is. `nil` (the free tier, a storeless test) means - /// nothing is listening, which is the same shape every other seam here takes. + /// The seam stays, unwired, because it is the only announcement of the boundary this type makes + /// and the ordering it carries — that the flush precedes the announcement — is a property worth + /// keeping proved (`AutoCommitTests`). A closure for `save`'s reason exactly: this type is a + /// buffer and a clock, and it stays testable by having no idea what a repository is. @ObservationIgnored public var editSessionDidChange: ((_ isEditing: Bool) -> Void)? diff --git a/KanbanTests/AutoCommitTests.swift b/KanbanTests/AutoCommitTests.swift index b2e8605..21e9c25 100644 --- a/KanbanTests/AutoCommitTests.swift +++ b/KanbanTests/AutoCommitTests.swift @@ -439,7 +439,7 @@ struct AutoCommitAttributionTests { // MARK: - The stage-around @MainActor -@Suite("Auto-commit ▸ staging around open Edit sessions") +@Suite("Auto-commit ▸ staging around open card windows") struct AutoCommitStageAroundTests { @Test("A lane move mid-session commits the move without touching the session card's folder") @@ -449,7 +449,7 @@ struct AutoCommitStageAroundTests { let committer = try quickCommitter(git) let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") - committer.beginEditSession(UUID()) { sessionFolder } + committer.beginCardSession(UUID()) { sessionFolder } // The editor's ~700 ms save lands on disk, uncommitted… try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "half-typed")) @@ -474,7 +474,7 @@ struct AutoCommitStageAroundTests { let committer = try quickCommitter(git) let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") - committer.beginEditSession(UUID()) { sessionFolder } + committer.beginCardSession(UUID()) { sessionFolder } try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt", Data("x\n".utf8)) try fixture.file("elsewhere.txt", Data("y\n".utf8)) @@ -486,7 +486,7 @@ struct AutoCommitStageAroundTests { .contains("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt")) } - @Test("Ending the session produces exactly one body commit for it") + @Test("Ending the session produces exactly one commit for it") func endingTheSessionCommitsOnce() async throws { let (fixture, git, _) = try await makeGitBoard() defer { fixture.tearDown() } @@ -494,7 +494,7 @@ struct AutoCommitStageAroundTests { let token = UUID() let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)") - committer.beginEditSession(token) { sessionFolder } + committer.beginCardSession(token) { sessionFolder } // Three debounced saves inside one session — each a real write, none of them a commit // ("the body editor's ~700 ms disk saves … stay uncommitted"). @@ -506,11 +506,12 @@ struct AutoCommitStageAroundTests { } #expect(committer.commitCount == 0, "no save tick may become a commit") - // The Edit→Preview flip — "the effective Save button". - committer.endEditSession(token) + // The window close — "window close flushes the session as one commit" (06 ▸ Rules + // ▸ Auto-commit, widened 2026-07-31: the unit is the window, not the Edit→Preview flip). + committer.endCardSession(token) try await waitUntil { committer.commitCount == 1 } - #expect(committer.commitCount == 1, "exactly one body commit per session") + #expect(committer.commitCount == 1, "exactly one commit per card-window session") #expect(isClean(at: fixture.root)) } @@ -523,7 +524,7 @@ struct AutoCommitStageAroundTests { // The registry holds a resolver, not a URL, so a lane move under an open session keeps the // right folder excluded rather than the one Edit was entered in. var lane = Ident.lane1 - committer.beginEditSession(UUID()) { fixture.url("\(lane)/\(Ident.card1)") } + committer.beginCardSession(UUID()) { fixture.url("\(lane)/\(Ident.card1)") } try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing")) try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)") @@ -544,7 +545,7 @@ struct AutoCommitStageAroundTests { defer { fixture.tearDown() } let committer = try quickCommitter(git) - committer.beginEditSession(UUID()) { fixture.url("\(Ident.lane1)/\(Ident.card1)") } + committer.beginCardSession(UUID()) { fixture.url("\(Ident.lane1)/\(Ident.card1)") } try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "typing")) committer.noteWriteBracketClosed() await committer.flushNow() diff --git a/KanbanTests/CardSessionCommitTests.swift b/KanbanTests/CardSessionCommitTests.swift new file mode 100644 index 0000000..258ed70 --- /dev/null +++ b/KanbanTests/CardSessionCommitTests.swift @@ -0,0 +1,570 @@ +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)") + } +} diff --git a/KanbanTests/CardSessionUndoTests.swift b/KanbanTests/CardSessionUndoTests.swift new file mode 100644 index 0000000..260deb7 --- /dev/null +++ b/KanbanTests/CardSessionUndoTests.swift @@ -0,0 +1,616 @@ +import Foundation +import Testing +@testable import Kanban + +/// **The two-level undo model** — a card window's own stack, and the one coarse step its close +/// registers on the board's (13-native-undo.md ▸ Rules, re-ruled 2026-07-31; 05-card-window.md ▸ The +/// comments column; 01-storage-format.md § Enhanced schema ▸ the deferred purge). +/// +/// The claims here are all about *which stack* and *when*, which is exactly the class of thing that +/// looks right in a running window and is wrong: a step on the board's stack while a window is still +/// open, a purge that ran while an undo still needed the folder it removed, a session that registered +/// a step for a card that had left the board. So every test drives the production wiring +/// (`CardWindowHost.configureUndo` / `.configureComments`, both `static` for this reason) over a real +/// store and asserts against the two stacks and the bytes on disk. +/// +/// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider +/// grammar is `HistoryProviderTests`'. + +// MARK: - The window under test + +@MainActor +private struct Window { + let store: BoardStore + /// The **board's** stack — what must stay empty while the window is open. + let board: NativeHistoryProvider + let session: CardWindowSession + + var window: CardWindowUndo { session.undo } + var comments: CardComments { session.comments } + var body: CardBodyEditSession { session.body } +} + +private let cardID = ItemID(rawValue: Ident.card1) +private let cardPath = "\(Ident.lane1)/\(Ident.card1)" + +/// A card window over a board with one card, wired exactly as `CardWindowHost` wires one. +@MainActor +private func makeWindow(_ fixture: WriterFixture) throws -> Window { + let store = try BoardStore(rootURL: fixture.root) + let board = NativeHistoryProvider() + store.history = board + + let session = CardWindowSession() + CardWindowHost.configureUndo(session, store: store, cardID: cardID) + CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) + session.comments.isEditable = true + session.comments.cardFolder = fixture.url(cardPath) + // The one seam the host wires beside the undo ones and this suite is not testing — the buffer's + // write target (`configureSession`), spelled the same way. + session.body.save = { [weak store] text in + store?.writeCardBody(inCard: cardID, body: text) ?? .vanished + } + session.comments.open() + session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body) + return Window(store: store, board: board, session: session) +} + +@MainActor +private func editBody(_ window: Window, to text: String) { + window.body.edited(text) + window.body.endEditSession() +} + +@MainActor +private func postComment(_ window: Window, body: String) -> ItemID? { + window.comments.composer.edited(body) + _ = window.comments.composer.flush() + return window.comments.composer.postNow() +} + +private func body(_ fixture: WriterFixture, _ path: String) throws -> String { + try FrontmatterDocument.parse(fixture.indexText(path)).body +} + +// MARK: - Routing + +@MainActor +@Suite("Card session undo ▸ routing") +struct CardSessionRoutingTests { + + @Test("A body edit and a comment post register on the window's stack, never on the board's") + func windowGesturesStayOffTheBoardStack() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + + editBody(window, to: "Edited in the window.\n") + #expect(postComment(window, body: "A remark.\n") != nil) + + #expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps") + #expect(window.window.stack.canUndo) + #expect(window.window.stack.undoActionName == "Comment") + } + + @Test("⌘Z in the window walks its own gestures, newest first") + func theWindowStackWalksItsOwnGestures() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + let original = try body(fixture, cardPath) + + editBody(window, to: "Edited in the window.\n") + let posted = try #require(postComment(window, body: "A remark.\n")) + + // Newest first: the post, then the body edit. + window.window.stack.undo() + #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)")) + #expect(fixture.exists("\(card)/comments/.draft")) + #expect(try body(fixture, cardPath) == "Edited in the window.\n") + + window.window.stack.undo() + #expect(try body(fixture, cardPath) == original) + #expect(!window.window.stack.canUndo) + #expect(!window.board.canUndo, "and the board's stack was never involved") + } + + @Test("A comment's inline edit session is one window step, at its commit point") + func inlineEditRegistersOneWindowStep() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText(body: "original\n")) + let window = try makeWindow(fixture) + window.comments.reload() + + window.comments.beginEdit(ItemID(rawValue: CommentIdent.one)) + window.comments.editing?.edited("first pass\n") + window.comments.editing?.flush() + window.comments.editing?.edited("second pass\n") + window.comments.editing?.flush() + #expect(!window.window.stack.canUndo, "a save tick is not a step") + + window.comments.commitEdit() + #expect(window.window.stack.undoActionName == "Edit Comment") + #expect(!window.board.canUndo) + + window.window.stack.undo() + #expect(try body(fixture, path) == "original\n", "back to the bytes the session opened on") + } + + @Test("An inline session's Cancel registers nothing — its net effect is nothing") + func cancelRegistersNothing() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(body: "original\n")) + let window = try makeWindow(fixture) + window.comments.reload() + + window.comments.beginEdit(ItemID(rawValue: CommentIdent.one)) + window.comments.editing?.edited("rewritten\n") + window.comments.editing?.flush() + window.comments.cancelEdit() + + #expect(!window.window.stack.canUndo) + #expect(!window.board.canUndo) + } + + @Test("The window's manager answers for the window's stack, and never falls through") + func theWindowManagerNeverFallsThrough() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + + // A board step exists — a rename made from the board window, say. + window.store.renameBoard("Renamed") + #expect(window.board.canUndo) + + // The window's own stack is empty, so its manager says no: "exhausting the window stack + // beeps; it never reaches board history" (06-history-undo.md ▸ Undo routing). + #expect(!window.window.manager.canUndo) + #expect(!window.window.manager.canRedo) + #expect(window.window.manager.undoMenuItemTitle == "Undo") + + editBody(window, to: "Edited.\n") + #expect(window.window.manager.canUndo) + #expect(window.window.manager.undoMenuItemTitle == "Undo Edit Card") + } + + @Test("The read-only lock disables the window's pair, and the stack survives it") + func theLockDisablesTheWindowsPair() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + editBody(window, to: "Edited.\n") + #expect(window.window.manager.canUndo) + + window.store.enterVanishedRootLock() + #expect(!window.window.manager.canUndo, "13 ▸ Rules ▸ locks, one level down") + #expect(window.window.stack.canUndo, "the stack itself is untouched") + } +} + +// MARK: - The coarse close step + +@MainActor +@Suite("Card session undo ▸ the coarse close step") +struct CardSessionCloseTests { + + @Test("A session of three gestures closes as exactly one board step, and undo restores all three") + func oneStepForTheWholeSession() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let deleted = commentPath(CommentIdent.one, inCard: card) + try fixture.item(deleted, commentText(body: "kept somewhere\n")) + let window = try makeWindow(fixture) + window.comments.reload() + let original = try body(fixture, cardPath) + + editBody(window, to: "Edited in the window.\n") + let posted = try #require(postComment(window, body: "A remark.\n")) + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + + await window.session.endSession() + + // One step, named for the session rather than for any gesture inside it. + #expect(window.board.canUndo) + #expect(window.board.undoActionName == "Edit Card") + + window.board.undo() + #expect(!window.board.canUndo, "exactly one") + #expect(try body(fixture, cardPath) == original) + #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"), "the post is unposted") + #expect(fixture.exists("\(card)/comments/.draft")) + #expect(fixture.exists(deleted), "the deleted comment is back — restored from comments/.trash/") + #expect(try body(fixture, deleted) == "kept somewhere\n") + + window.board.redo() + #expect(try body(fixture, cardPath) == "Edited in the window.\n") + #expect(fixture.exists("\(card)/comments/\(posted.rawValue)")) + #expect(!fixture.exists(deleted)) + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) + } + + @Test("A session with no net change registers nothing") + func noNetChangeRegistersNothing() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + let original = try body(fixture, cardPath) + + // Two Edit sessions whose net effect on the file is nothing at all. + editBody(window, to: "A detour.\n") + editBody(window, to: original) + #expect(window.window.stack.canUndo, "two real gestures, on the window's stack") + + await window.session.endSession() + #expect(!window.board.canUndo, "a session with no net change registers nothing") + #expect(try body(fixture, cardPath) == original) + } + + @Test("A gesture undone inside the window is not part of the session's net effect") + func anUndoneGestureLeavesTheFold() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + let window = try makeWindow(fixture) + window.comments.reload() + + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + window.window.stack.undo() + #expect(fixture.exists(path), "the window's own ⌘Z put it back") + + await window.session.endSession() + #expect(!window.board.canUndo, "nothing is left of the session to coarsen") + } + + @Test("A comment edited and then deleted in one session folds to where it actually is") + func editThenDeleteFoldsToTheTrash() async throws { + // The fold's move case: the edit's expectations name the comment's live path, and the delete + // then empties that path. A fold that kept both would be stale at the moment it registered. + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText(body: "original\n")) + let window = try makeWindow(fixture) + window.comments.reload() + + window.comments.beginEdit(ItemID(rawValue: CommentIdent.one)) + window.comments.editing?.edited("rewritten\n") + window.comments.commitEdit() + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + await window.session.endSession() + #expect(window.board.undoActionName == "Edit Card") + + window.board.undo() + #expect(window.store.banners.signposts.isEmpty, "the session's own step is never stale on arrival") + #expect(fixture.exists(path)) + #expect(try body(fixture, path) == "original\n", "restored to the bytes the session opened on") + #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) + } + + @Test("A comment posted and then deleted in one session unwinds to the draft it came from") + func postThenDeleteUnwindsToTheDraft() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + + let posted = try #require(postComment(window, body: "A remark.\n")) + window.comments.reload() + window.comments.delete(posted) + await window.session.endSession() + + window.board.undo() + #expect(window.store.banners.signposts.isEmpty) + #expect(fixture.exists("\(card)/comments/.draft")) + #expect(try body(fixture, "\(card)/comments/.draft") == "A remark.\n") + #expect(!fixture.exists("\(card)/comments/\(posted.rawValue)")) + #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) + } + + @Test("A window whose card left the board registers nothing") + func aVanishedCardRegistersNoSessionStep() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + editBody(window, to: "Edited.\n") + + // The card window's own Actions ▸ Delete — a board gesture, on the board's stack, which + // dismisses this window (05-card-window.md ▸ Deletion & lifecycle). + window.store.deleteCard(cardID) + window.store.handleWatcherEvent(.treeChanged(.appMediated)) + await window.store.awaitQuiescence() + #expect(window.board.undoActionName == "Delete Card") + + await window.session.endSession() + #expect(window.board.undoActionName == "Delete Card", "no session step for a card that has gone") + } + + @Test("A style change made in the window's sidebar joins the session, not the board") + func sidebarStylingIsASessionGesture() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + + // The sidebar anchor's write, exactly as `CardStyleSection` makes it. + window.store.applyStyle( + to: CardStyleSection.target(forCard: cardID), + background: .set("blue"), + on: window.window + ) + #expect(window.window.stack.undoActionName == "Restyle Card") + #expect(!window.board.canUndo) + + await window.session.endSession() + #expect(window.board.undoActionName == "Edit Card") + + window.board.undo() + let document = try FrontmatterDocument.parse(fixture.indexText(cardPath)) + #expect(document.background.isMissing, "the session's net effect, walked back") + } +} + +// MARK: - Transactional staleness + +@MainActor +@Suite("Card session undo ▸ transactional staleness") +struct CardSessionStalenessTests { + + @Test("One stale component skips the whole step — nothing is partially reverted") + func anyStaleComponentSkipsTheWholeStep() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let deleted = commentPath(CommentIdent.one, inCard: card) + try fixture.item(deleted, commentText()) + let window = try makeWindow(fixture) + window.comments.reload() + + editBody(window, to: "Edited in the window.\n") + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + await window.session.endSession() + #expect(window.board.canUndo) + + // Somebody else rewrites the card's body after the window closed — one component of the + // session's step, and only one. + _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n") + + window.board.undo() + + #expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write") + #expect(!fixture.exists(deleted), "and the comment half did not half-happen either") + #expect(!window.board.canUndo, "the stale step was popped") + #expect(window.store.banners.signposts.map(\.message) + == ["Undo skipped — 'Fix login' changed outside Lanework"]) + } + + @Test("A foreign change to a field the session never wrote leaves the step alone") + func anUnrelatedForeignChangeDoesNotSkip() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + _ = try makeCommentBoard(fixture) + let window = try makeWindow(fixture) + let original = try body(fixture, cardPath) + + editBody(window, to: "Edited in the window.\n") + await window.session.endSession() + + // A foreign styling of the same card: the session wrote the body and nothing else, so the + // step names no style field to be stale against (13 ▸ Rules, the field-level predicate). + try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) { + $0.set(FrontmatterKeys.background, to: .string("blue")) + } + + window.board.undo() + #expect(try body(fixture, cardPath) == original) + #expect(window.store.banners.signposts.isEmpty) + } +} + +// MARK: - The deferred purge + +@MainActor +@Suite("Card session undo ▸ the deferred purge") +struct CardSessionPurgeTests { + + /// A window whose session deleted one comment and closed — the state every test below starts in. + @MainActor + private func closedWithADeletedComment(_ fixture: WriterFixture) async throws -> (Window, String) { + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + let window = try makeWindow(fixture) + window.comments.reload() + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + await window.session.endSession() + return (window, card) + } + + @Test("comments/.trash survives the close while the coarse step lives") + func theTrashOutlivesTheClose() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await closedWithADeletedComment(fixture) + + #expect(window.board.canUndo) + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"), + "the step's undo restores from here — the purge waits for it") + } + + @Test("The board session's end purges what the step was holding") + func theBoardSessionsEndPurges() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await closedWithADeletedComment(fixture) + + // `AppModel`'s teardown, and the add-git swap, both do exactly this. + window.board.clear() + #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) + } + + @Test("A stale step's skip purges too — the step is gone, so its backing is not needed") + func aSkippedStepPurges() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let (window, card) = try await closedWithADeletedComment(fixture) + + // A foreign delete of the trashed folder is not the interesting collision; a foreign body + // rewrite is — it makes the step stale without touching what the purge would remove. + _ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n") + // The session wrote the body too, so the step names it. + window.board.undo() + + #expect(!window.board.canUndo) + #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) + } + + @Test("A session that registers no step purges at the close, as it always did") + func aSessionWithNoStepPurgesAtOnce() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + // Residue with no session behind it: nothing this close does could need it back. + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + let window = try makeWindow(fixture) + + await window.session.endSession() + #expect(!window.board.canUndo) + #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) + } + + @Test("A substrate that keeps no steps purges at the close — Pro's rule, structurally") + func aSubstrateThatKeepsNoStepsPurgesAtOnce() async throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + let window = try makeWindow(fixture) + window.comments.reload() + // The git provider drops every registration (its substrate is the commit trail) and retires + // it on the way past — which is what makes "purge rides the close flush" true on Pro with no + // tier check at any call site. Bound directly here: `register` reads no repository. + window.store.history = GitHistoryProvider(boardRoot: fixture.root) + + window.comments.delete(ItemID(rawValue: CommentIdent.one)) + await window.session.endSession() + + #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) + } + + @Test("Undone-and-superseded releases the work the step was holding") + func supersedingAnUndoneStepRetiresIt() { + // The one release condition disk cannot show, because by the time it fires the coarse undo + // has already emptied the trash it was holding: a step undone and then superseded by a new + // gesture leaves history for good, and its retirement runs (13 ▸ Interaction with the trash). + let provider = NativeHistoryProvider() + let retirement = HistoryStep.Retirement {} + provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) + #expect(retirement.isOwed) + + provider.undo() + #expect(retirement.isOwed, "an undone step is still crossable — redo would need its backing") + + provider.register(HistoryStep(name: "Move Card", undo: { _ in .applied }, redo: { _ in .applied })) + #expect(!retirement.isOwed, "the redo stack cleared, so the step is gone for good") + } + + @Test("A retirement runs once, whatever a provider does to the step") + func aRetirementRunsOnce() { + var runs = 0 + let retirement = HistoryStep.Retirement { runs += 1 } + let provider = NativeHistoryProvider() + provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied })) + + provider.clear() + provider.clear() + #expect(runs == 1) + } + + @Test("A board with no substrate at all owes the purge immediately") + func noProviderRunsTheRetirementAtOnce() throws { + // Repo-nested boards bind no provider (06-history-undo.md ▸ Rules), so nothing could ever + // report the step's death — the work is owed at registration or never. + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + let store = try BoardStore(rootURL: fixture.root) + let session = CardWindowSession() + CardWindowHost.configureUndo(session, store: store, cardID: cardID) + CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) + session.comments.isEditable = true + session.comments.cardFolder = fixture.url(cardPath) + + var purged = false + _ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true }) + #expect(purged == false, "no net effect — nothing was registered, and the caller still owes it") + + session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished } + session.body.adopt(diskBody: try body(fixture, cardPath)) + session.body.edited("Edited.\n") + session.body.endEditSession() + _ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true }) + #expect(purged, "a step nothing keeps is a step nothing can retire later") + } +} + +// MARK: - The fold + +@MainActor +@Suite("Card session undo ▸ the fold") +struct CardSessionFoldTests { + + private let folder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true) + + @Test("Merging is per target and per field, later entries winning") + func laterEntriesWin() { + let other = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true) + let fold = CardWindowUndo.fold([ + [.present(folder, .background("blue"), .body("first\n"))], + [.present(folder, .background("green")), .present(other)], + ]) + + #expect(fold.expectations == [ + .present(folder, .background("green"), .body("first\n")), + .present(other), + ]) + } + + @Test("Two folds of the same writes, read from both ends, are equal only when nothing changed") + func equalityIsTheNoNetChangeTest() { + let there = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("b\n"))]]) + let back = CardWindowUndo.fold([[.present(folder, .body("b\n"))], [.present(folder, .body("a\n"))]]) + #expect(there != back) + + let round = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("a\n"))]]) + #expect(round == CardWindowUndo.fold([[.present(folder, .body("a\n"))]])) + } + + @Test("Presence takes the later answer, and an absence clears what was said about the path") + func presenceTakesTheLaterAnswer() { + let live = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true) + // An edit, then the delete that emptied the path: a fold still expecting the edited bytes + // there would be stale on arrival. + let fold = CardWindowUndo.fold([[.present(live, .body("rewritten\n"))], [.absent(live)]]) + #expect(fold.expectations == [.absent(live)]) + + // And back again — a restore names the path present, which is the ordinary later-wins. + let restored = CardWindowUndo.fold([[.absent(live)], [.present(live, .body("original\n"))]]) + #expect(restored.expectations == [.present(live, .body("original\n"))]) + } +} diff --git a/KanbanTests/CommitMessageTests.swift b/KanbanTests/CommitMessageTests.swift index 29ce668..2e20dc8 100644 --- a/KanbanTests/CommitMessageTests.swift +++ b/KanbanTests/CommitMessageTests.swift @@ -329,13 +329,15 @@ struct CommitMessageFoldingTests { #expect(subject(of: message) == "Move 2 cards") } - @Test("A genuinely mixed window is 'Update board' — with every event in the body") + @Test("A genuinely mixed window says so — with every event in the body") func mixedWindowsFallBack() throws { + // Re-ruled 2026-07-31: the bare "Update board" is retired. Two cards, so no shared item and + // no name to keep — "Mixed update — N changes", never a shrug dressed as one thing. let message = try compose { fixture in try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix logout") try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests") } - #expect(subject(of: message) == CommitMessageEngine.mixedSubject) + #expect(subject(of: message) == "Mixed update — 2 changes") #expect(body(of: message) == [ "- Add card 'Write tests' to Doing", "- Rename card 'Fix login' → 'Fix logout'", @@ -419,7 +421,7 @@ struct CommitMessageBookkeepingTests { modified: "2026-07-31T12:00:00Z" ) } - #expect(message == CommitMessageEngine.mixedSubject) + #expect(message == CommitMessageEngine.unnamedSubject) } @Test("A renumber's rescale preserves sequence and composes nothing") @@ -430,7 +432,7 @@ struct CommitMessageBookkeepingTests { try fixture.card(Ident.card1, in: Ident.lane1, order: "16384", title: "Fix login") try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it") } - #expect(message == CommitMessageEngine.mixedSubject) + #expect(message == CommitMessageEngine.unnamedSubject) } @Test("An on-touch heal's backfilled kind is not an event") @@ -441,7 +443,7 @@ struct CommitMessageBookkeepingTests { "---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n" ) } - #expect(message == CommitMessageEngine.mixedSubject) + #expect(message == CommitMessageEngine.unnamedSubject) } @Test("A renumber batches with the insert that triggered it") @@ -721,7 +723,7 @@ struct CommitMessageCommentTests { let message = try compose { fixture in try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note.")) } - #expect(message != CommitMessageEngine.mixedSubject) + #expect(message != CommitMessageEngine.unnamedSubject) #expect(!message.contains("comments/")) } diff --git a/KanbanTests/GitUndoTests.swift b/KanbanTests/GitUndoTests.swift index 7392a8d..fe2ac96 100644 --- a/KanbanTests/GitUndoTests.swift +++ b/KanbanTests/GitUndoTests.swift @@ -815,7 +815,7 @@ struct GitUndoSessionTests { // 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 } + committer.beginCardSession(token) { cardFolder } try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed")) committer.noteReloadLanded(sawForeignChange: false) await commitAndSettle(committer, provider) @@ -828,7 +828,7 @@ struct GitUndoSessionTests { // 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) + committer.endCardSession(token) provider.noteDiscarded(cardFolderName: Ident.card1) return .proceed } @@ -872,13 +872,13 @@ struct GitUndoSessionTests { // 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 } + committer.beginCardSession(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) + committer.endCardSession(token) // The bare id — never the path. This is the whole regression. provider.noteDiscarded(cardFolderName: Ident.card1) return .proceed