Files
lanework/KanbanTests/CardSessionCommitTests.swift
rzen a381fac742 Make the card window the commit unit on Pro boards
Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.

Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.

2444 tests in 422 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 20:23:45 -04:00

571 lines
26 KiB
Swift

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<String> {
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)")
}
}