The seams unbind — the provider is always native, and the git stack compiles dead

Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 11:25:41 -04:00
parent f6a24132b6
commit cdba512512
24 changed files with 281 additions and 2567 deletions
-138
View File
@@ -62,49 +62,6 @@ private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef
return ref
}
/// The same board with a **real repository** at its root, root commit and all what a Pro session
/// detects as mode `git`, and therefore the only shape that composes a branch switcher to test the
/// settle step's seams through.
@MainActor
private func makeProGitBoard() throws -> WriterFixture {
let fixture = try makeMixedBoard()
guard case .success = GitRepository.create(at: fixture.root) else {
fixture.tearDown()
Issue.record("could not initialize a repository for the fixture board")
throw CocoaError(.fileWriteUnknown)
}
return fixture
}
/// Opens a card window the way its host does registered with the board's session and leaves it
/// holding one fine step and an open Edit session, so it both *has* a stack to lose and answers the
/// save-or-discard step's `needsSettling` with `true`.
///
/// The step is registered through `BoardStore.registerStep` rather than pushed onto the provider, so
/// it is routed by the same line production routes a card-window gesture with (`on:` the window's
/// stack) and carries the raw write the close fold would look for.
@MainActor
@discardableResult
private func openCardWindow(
_ model: AppModel,
board: BoardWindowRef,
card id: String,
store: BoardStore
) -> CardWindowSession {
let window = CardWindowSession()
model.registerCardWindow(CardWindowRef(board: board, cardID: ItemID(rawValue: id)), session: window)
window.body.beginEditSession()
store.registerStep(
"Edit Card",
on: window.undo,
undoExpects: [.present(.card(ItemID(rawValue: id)), .body("after\n"))],
redoExpects: [.present(.card(ItemID(rawValue: id)), .body("before\n"))],
undo: { _ in },
redo: { _ in }
)
return window
}
// MARK: - Tests
@MainActor
@@ -319,101 +276,6 @@ struct AppModelTests {
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
}
// MARK: The branch switch's settle
/// **"The settle also clears each open card window's fine undo stack"** (06-history-undo.md
/// Branch switching, ruled 2026-07-31): "pre-switch steps describe the branch being left Save
/// All and Discard alike end with every window's stack empty the windows stay open, following
/// their cards onto the new branch with fresh stacks."
///
/// This is an `AppModel` test rather than a `GitBranchSwitcher` one because the clear is a fact
/// about the **composition**: the switcher's settle seam, the card-window registry and the stacks
/// themselves only meet in `wireBranchSwitching`, and a switcher wired by hand would be a test
/// asserting its own wiring (the `a381fac` lesson, applied one card later).
@Test(
"The branch switch's settle empties every open card window's fine stack",
arguments: [SessionSettleChoice.saveAll, .discard]
)
func theSettleClearsEveryFineStack(answering choice: SessionSettleChoice) async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let windows = [Ident.card1, Ident.card2].map { id in
openCardWindow(model, board: ref, card: id, store: session.store)
}
#expect(windows.allSatisfy { $0.undo.stack.canUndo })
#expect(windows.allSatisfy { $0.undo.netEffect() != nil }, "a session with a net effect to fold")
model.settleAsk = { _ in choice }
#expect(await switcher.settleSessions?() == .proceed)
for window in windows {
#expect(!window.undo.stack.canUndo, "the stack describes the branch being left")
#expect(!window.undo.manager.canUndo, "and ⌘Z in that window answers with it")
}
// "The windows stay open, following their cards onto the new branch with fresh stacks."
#expect(model.session(for: ref)?.cardRefs.count == 2)
}
/// **Closing the stack is not closing the window.** The coarse step a card window owes its board is
/// registered at *close*, folded from this stack (13-native-undo.md Rules "Window close
/// coarsens"); a settle clear registers nothing at all, which is exactly what
/// `registerCardSession` answering `false` and the deferred purge staying the caller's says.
@Test("A settle clear registers no coarse step — the fold that would have run finds nothing")
func theClearRegistersNoCoarseStep() async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
model.settleAsk = { _ in .saveAll }
#expect(await switcher.settleSessions?() == .proceed)
#expect(window.undo.netEffect() == nil, "nothing left to fold")
var purged = false
let registered = session.store.registerCardSession(
window.undo,
inCard: ItemID(rawValue: Ident.card1),
retiring: { purged = true }
)
#expect(!registered, "a close arriving right after the switch registers nothing")
#expect(!purged, "and the deferred purge is still the caller's, not a step's")
}
/// "**Cancel** keeps the current branch and the sessions" and now their stacks with them. The
/// same `if` that withholds the staging release withholds this.
@Test("Cancel clears nothing")
func cancelKeepsTheFineStacks() async throws {
let board = try makeProGitBoard()
defer { board.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: board.root)
let session = try #require(model.session(for: ref))
let switcher = try #require(session.git?.switcher)
let window = openCardWindow(model, board: ref, card: Ident.card1, store: session.store)
model.settleAsk = { _ in .cancel }
#expect(await switcher.settleSessions?() == .cancelled)
#expect(window.undo.stack.canUndo)
#expect(window.undo.netEffect() != nil)
}
// MARK: Launch restoration
/// App Settings's "Restore open boards at launch" (11-command-nexus.md) gates the flagged set
-122
View File
@@ -962,128 +962,6 @@ struct AutoCommitCompositionTests {
}
}
// MARK: - The composition root
/// **The wired-at-`beginSession` seams, pinned where they are wired** (02-architecture.md Layering;
/// 12-editions.md The provider seam).
///
/// Every suite above composes its own committer by hand, which is what makes them readable and is
/// exactly why they cannot see the defect this suite exists for: `AppModel.beginSession` once composed
/// the committer *without* the store's `EchoLedger` (`HistoryStore.compose`'s default is a fresh one,
/// for the store-less callers), so every unit layer passed while every production commit misattributed
/// the app's own writes arriving unvouched-for and authored `Lanework External`. It was fixed in
/// `a381fac` by passing `store.echoes`, and nothing but a test that opens a board *through the model*
/// could have caught it or can keep it caught.
///
/// So the assertions here are about the **composition** and never about the units: not "the ledger
/// classifies" (`AutoCommitAttributionTests`) and not "a bracket announces at completion"
/// (`BoardAnnouncerTests`), but that a board opened the way a window opens one has those two wires in
/// it.
@MainActor
@Suite("Auto-commit ▸ the composition root")
struct AutoCommitCompositionRootTests {
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home `AppModelTests`' own fixture, for its reason.
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("AutoCommitCompositionTests-\(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) })
}
/// Opens a board the way `BoardWindowHost` does record, acquire, flag, begin so what is under
/// test is the real `beginSession` and not a hand-assembled session.
private func openBoard(_ model: AppModel, at url: URL) throws -> AppModel.BoardSession {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return try #require(model.session(for: ref))
}
/// **(a) The committer is composed with the session store's own ledger** the same instance the
/// store's writes drop receipts into (`BoardStore.echoes`).
///
/// Asserted through the one thing the ledger decides: **authorship**. An ordinary app-mediated
/// write through the store, committed by the session's own committer, is authored by this
/// machine's user. Composed with any *other* ledger it would be authored `Lanework External`
/// which is not a hypothetical shape, it is what `AutoCommitAttributionTests`'
/// `foreignIsLaneworkExternal` pins for a write nobody vouched for, and what this board's every
/// commit did before `a381fac`.
@Test("beginSession composes the committer with the store's own EchoLedger")
func theCommitterIsComposedWithTheStoresLedger() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
let committer = try #require(session.git?.committer)
// Only the explicit flush commits, and it does not sit out a watcher that a temp directory may
// or may not deliver events for: this test is about *who* the commit is by.
committer.stop()
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotDeadline = .milliseconds(50)
committer.coveringSnapshotPollInterval = .milliseconds(5)
// An ordinary write through the store the Writer boundary, receipt and all. Nothing here
// touches the ledger by hand, which is the whole point: the receipt has to travel from the
// store's own ledger to the committer's, and there is only one way for that to be true.
let outcome = session.store.writeCardBody(inCard: ItemID(rawValue: Ident.card1), body: "By the app.\n")
#expect(outcome == .written)
await committer.flushNow()
let head = try #require(try history(at: fixture.root).first)
#expect(head.authorEmail == GitCommitOperation.userIdentity(at: fixture.root).email)
#expect(
head.authorEmail != CommitAttribution.externalAuthorEmail,
"a committer composed over any other ledger would blame the outside world for this write"
)
}
/// **(b) The announcer outlet is bound** the undo restore's bracket runs through the store's
/// `performWholesale(announcing:)`, so its subject reaches `BoardStore.announce`.
///
/// `GitHistoryProvider.runBracketed` is optional and "`nil` runs the work bare, which is what a
/// repository-level test wants" so an unwired seam is silent rather than broken, and every
/// repository-level suite in this file would keep passing over one. What a session owes it is the
/// store's bracket: the watcher suspension, the reload floor that locks the board if the closing
/// reload fails, and 10-accessibility.md's one sentence at completion.
@Test("beginSession binds the restore's bracket to the board's announcer outlet")
func theRestoreBracketReachesTheAnnouncer() async throws {
let (fixture, _, _) = try await makeGitBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
let session = try openBoard(model, at: fixture.root)
session.git?.committer?.stop()
let store = session.store
let provider = try #require(session.history as? GitHistoryProvider, "a git board binds the git provider")
var spoken: [String] = []
store.announce = { if let phrase = $0 { spoken.append(phrase) } }
let bracket = try #require(provider.runBracketed, "the restore has a bracket to run inside")
await bracket("Undid 'Add card'") {
try? fixture.item("\(Ident.lane1)/\(Ident.card2)", plain(order: "2048", title: "Restored"))
}
// The bracket's closing reload the one it armed, whichever way the operation went.
store.handleWatcherEvent(.treeChanged(.appMediated))
await store.awaitQuiescence()
#expect(spoken == ["Undid 'Add card'"], "the trail's sentence and the spoken one are the same one")
#expect(store.readOnlyLock == nil, "the closing reload succeeded, so nothing is locked")
}
}
// MARK: - The Edit-session flag
/// What the card body still owes the commit model after the stage-around widened to the whole window
+15 -277
View File
@@ -85,15 +85,8 @@ struct BannerCenterOrderingTests {
message: "Pasted 'Fix login' without its 3 attachments",
occurredAt: Date(timeIntervalSince1970: 150)
)
let operation = InProgressOperation(label: "Pulling…")
let operation = InProgressOperation(label: "Duplicating…")
let signpost = InfoSignpost(message: "This card changed on the remote")
// The failure class's second shape (settled 2026-07-31) newer than the failed move, so it
// leads the rank the two of them share.
let restore = GitFailureBanner(
operation: .undo,
reason: "could not write to 'index.md': Permission denied",
occurredAt: Date(timeIntervalSince1970: 120)
)
let rows = BannerCenter.rows(
lock: .vanishedRoot,
@@ -102,126 +95,27 @@ struct BannerCenterOrderingTests {
losses: [loss],
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
operations: [operation],
signposts: [signpost],
gitFailures: [restore],
repositoryUnreadable: true
signposts: [signpost]
)
// in-progress (pinned) > read-only lock > reload breakage > **the unreadable repository** >
// one-shot failures, both shapes > loss rows > commit and attachment failures > passive info
// rows. The two info classes sit at opposite ends of the strip, and the breakage class holds
// two rows now (06-history-undo.md Rules, ruled 2026-07-31): the reload breakage first,
// because it is the one saying the board on screen is not the board on disk.
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures > loss rows >
// commit and attachment failures > passive info rows. The two info classes sit at opposite
// ends of the strip.
#expect(rows.map(\.id) == [
"operation:\(operation.id.uuidString)",
"read-only-lock",
"reload-breakage",
"repository-unreadable",
"git-failure:\(restore.id.uuidString)",
"one-shot:\(move.id.uuidString)",
"loss:\(loss.id.uuidString)",
"history-suspension",
"one-shot:\(attachment.id.uuidString)",
"signpost:\(signpost.id.uuidString)",
])
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false, false],
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false],
"a spinner may never hide behind '+N more' — nothing else is pinned")
}
/// **The corrupt-`.git` loud failure's row** (06-history-undo.md Rules, ruled 2026-07-31)
/// it stands with the breakage class and above every one-shot, which is what "breakage-class"
/// buys it: a failed move posted a second ago never pushes it down the strip.
@Test("The unreadable repository outranks every failure, and only the breakage class outranks it")
func theUnreadableRepositoryStandsInTheBreakageClass() {
let move = OneShotBanner(error: error(.move(title: "Fix login")))
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [move],
losses: [],
suspension: nil,
operations: [],
repositoryUnreadable: true
)
#expect(rows.map(\.id) == ["repository-unreadable", "one-shot:\(move.id.uuidString)"])
#expect(rows.first?.tone == .error, "the ruling's word is breakage, and breakage is an error")
}
@Test("A readable repository contributes no row at all")
func aReadableRepositoryIsSilent() {
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [],
losses: [],
suspension: nil,
operations: [],
repositoryUnreadable: false
)
#expect(rows.isEmpty)
}
@Test("Both failure shapes share one rank, interleaved by recency")
func theFailureRankHoldsBothShapes() {
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
// banner surface, settled 2026-07-31): the two shapes are one precedence class, so recency
// not vocabulary decides which of them a user reads first.
let oldMove = OneShotBanner(error: error(.move(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
let newMove = OneShotBanner(error: error(.move(title: "New")), occurredAt: Date(timeIntervalSince1970: 3))
let oldSwitch = GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten",
occurredAt: Date(timeIntervalSince1970: 2)
)
let newUndo = GitFailureBanner(
operation: .undo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 4)
)
let rows = BannerCenter.rows(
lock: nil,
breakage: nil,
oneShots: [oldMove, newMove],
losses: [LossBanner(message: "Folders can't be attached — 1 skipped")],
suspension: nil,
operations: [],
gitFailures: [oldSwitch, newUndo]
)
#expect(rows.map(\.id).prefix(4) == [
"git-failure:\(newUndo.id.uuidString)",
"one-shot:\(newMove.id.uuidString)",
"git-failure:\(oldSwitch.id.uuidString)",
"one-shot:\(oldMove.id.uuidString)",
])
#expect(rows.map(\.tone) == [.error, .error, .error, .error, .warning],
"and every one of them is a failure, above the warning-tone loss row")
}
@Test("A git failure outranks a loss row however much older it is — the compromise is retired")
func aGitFailureOutranksALossRow() {
// The shipped build posted these as loss rows, which put a failed Z *below* a folder-drop
// notice and painted it warning-tone. Both halves of that are retired (settled 2026-07-31).
let ancient = GitFailureBanner(
operation: .redo,
reason: "the repository is locked",
occurredAt: Date(timeIntervalSince1970: 1)
)
let fresh = LossBanner(message: "Folders can't be attached — 2 skipped", occurredAt: Date(timeIntervalSince1970: 900))
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [fresh], suspension: nil, operations: [],
gitFailures: [ancient]
)
#expect(rows.map(\.id) == ["git-failure:\(ancient.id.uuidString)", "loss:\(fresh.id.uuidString)"])
#expect(rows.map(\.tone) == [.error, .warning])
}
@Test("An attachment failure ranks below other one-shots even when it is newer")
func attachmentFailuresRankLast() {
let attachment = OneShotBanner(
@@ -400,40 +294,14 @@ struct BannerCenterLifecycleTests {
#expect(center.losses.count == 1, "a loss survives everything except its own dismissal")
}
@Test("A git failure dismisses individually and is untimed — the one-shot's lifecycle exactly")
func gitFailuresDismissByIDAndNeverExpire() throws {
let center = BannerCenter()
center.postGitFailure(.undo, reason: "the repository is locked")
center.postGitFailure(.branchSwitch, reason: "your local changes would be overwritten")
#expect(center.gitFailures.count == 2)
#expect(center.gitFailures.map(\.operation) == [.branchSwitch, .undo], "newest first on insertion")
let doomed = try #require(center.gitFailures.first)
center.dismiss(doomed.id)
#expect(center.gitFailures.count == 1)
#expect(center.gitFailures.first?.id != doomed.id, "dismissing one must not take its neighbour")
// No timer, no auto-expiry: an error never evaporates unread, whichever vocabulary raised it.
let id = center.beginOperation(label: "Switching to 'main'…", cancel: nil)
center.endOperation(id)
center.suspendHistory(reason: "disk full")
center.clearHistorySuspension()
#expect(center.gitFailures.count == 1, "a failure survives everything except its own dismissal")
// And it is a *failure*, so nothing about it lands in the loss class.
#expect(center.losses.isEmpty)
#expect(center.oneShots.isEmpty)
}
@Test("Dismissing all dismissable rows clears losses along with both failure shapes and signposts")
@Test("Dismissing all dismissable rows clears losses along with the failures and signposts")
func dismissAllClearsLosses() {
let center = BannerCenter()
center.postLoss("Pasted 'Fix login' without its 3 attachments")
center.postGitFailure(.redo, reason: "the repository is locked")
center.postSignpost("This card changed on the remote")
center.dismissAllDismissableRows()
#expect(center.losses.isEmpty)
#expect(center.gitFailures.isEmpty)
#expect(center.signposts.isEmpty)
}
@Test("postSkippedFolders no-ops when nothing was skipped")
@@ -471,51 +339,6 @@ struct BannerCenterLifecycleTests {
).isEmpty)
}
/// **The corrupt-`.git` loud failure** (06-history-undo.md Rules, ruled 2026-07-31): the row
/// is raised at detection, stands with no dismiss, and *heals* "the banner clears when a later
/// open or reload finds the repo readable".
@Test("The unreadable repository is a standing condition that heals, never a dismissable row")
func theUnreadableRepositoryIsAHealingCondition() throws {
let center = BannerCenter()
#expect(!center.isRepositoryUnreadable)
center.raiseRepositoryUnreadable()
#expect(center.isRepositoryUnreadable)
let rows = BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
repositoryUnreadable: center.isRepositoryUnreadable
)
#expect(rows.count == 1)
#expect(rows[0].tone == .error)
#expect(rows[0].dismissID == nil, "a condition is never dismissed while it is still true")
// 06's own sentence, verbatim the three clauses being what is wrong, what it costs, and
// the promise that makes waiting safe.
#expect(rows[0].headline
== "This board's git repository can't be read — history is paused; Lanework leaves the repository untouched")
#expect(rows[0].headline == BannerCenter.repositoryUnreadableMessage)
center.clearRepositoryUnreadable()
#expect(!center.isRepositoryUnreadable)
#expect(BannerCenter.rows(
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
repositoryUnreadable: center.isRepositoryUnreadable
).isEmpty)
}
@Test("Raising and clearing are idempotent — a re-read that confirms the condition changes nothing")
func raisingTheUnreadableRepositoryIsIdempotent() {
let center = BannerCenter()
center.raiseRepositoryUnreadable()
center.raiseRepositoryUnreadable()
#expect(center.isRepositoryUnreadable)
center.clearRepositoryUnreadable()
center.clearRepositoryUnreadable()
#expect(!center.isRepositoryUnreadable)
}
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
func resuspendingKeepsTheClock() throws {
let center = BannerCenter()
@@ -544,7 +367,7 @@ struct BannerCenterLifecycleTests {
Issue.record("expected an in-progress row")
return
}
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
#expect(!operation.isCancelable, "an operation that cannot be abandoned gets no Cancel — settled")
center.endOperation(id)
rows = BannerCenter.rows(
@@ -651,9 +474,9 @@ struct BannerRowControlsTests {
#expect(cancelled.value)
}
@Test("A git bracket's row offers no control at all — no Cancel, nothing to dismiss")
@Test("An uncancelable bracket's row offers no control at all — no Cancel, nothing to dismiss")
func uncancelableInProgressRowsOfferNothing() {
let row = BannerRow.inProgress(InProgressOperation(label: "Pulling…"))
let row = BannerRow.inProgress(InProgressOperation(label: "Rebuilding…"))
#expect(row.controls.isEmpty)
}
@@ -664,11 +487,8 @@ struct BannerRowControlsTests {
let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")
let gitFailure = GitFailureBanner(operation: .undo, reason: "the repository is locked")
for (row, id) in [
(BannerRow.oneShot(banner), banner.id),
(BannerRow.gitFailure(gitFailure), gitFailure.id),
(BannerRow.loss(loss), loss.id),
(BannerRow.signpost(signpost), signpost.id),
] {
@@ -688,8 +508,7 @@ struct BannerRowControlsTests {
let rows: [BannerRow] = [
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.repositoryUnreadable,
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
.historySuspended(HistorySuspension(reason: "the volume is full")),
]
for row in rows {
@@ -706,10 +525,9 @@ struct BannerRowControlsTests {
.readOnlyLock(.vanishedRoot),
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
.historySuspended(HistorySuspension(reason: "disk full")),
.inProgress(InProgressOperation(label: "Pulling…")),
.inProgress(InProgressOperation(label: "Duplicating…")),
.signpost(InfoSignpost(message: "This card changed on the remote")),
]
@@ -797,53 +615,6 @@ struct BannerCenterPhrasingTests {
#expect(uneditable.contains("frontmatter"))
}
@Test("Every git operation names itself in the user's words, with the error as the tail")
func everyGitOperationSaysSomethingDistinct() {
// The vocabulary is closed and the sentences are here, not at the call sites (02 § The
// banner surface, settled 2026-07-31: "the operation named in the user's words plus the
// underlying error, phrasing still BannerCenter's"). `CaseIterable` is what keeps this test
// honest when pro-m2 adds pull and push.
let headlines = GitOperation.allCases.map {
BannerCenter.headline(for: GitFailureBanner(operation: $0, reason: "the repository is locked"))
}
for (operation, headline) in zip(GitOperation.allCases, headlines) {
#expect(!headline.isEmpty, "\(operation) has no headline")
#expect(headline.hasSuffix(" — the repository is locked"), "\(operation) drops the underlying error")
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
}
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
}
@Test("The git failure's sentences are the ruling's own")
func gitFailureSentencesArePinned() {
// Pinned as literals, unlike most phrasing here, because 02 wrote these two shapes by hand
// and the third is their mirror: the undo pair names the command the user pressed, the
// switch names the control they used.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: "the repository is locked"))
== "Undo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .redo, reason: "the repository is locked"))
== "Redo failed — the repository is locked")
#expect(BannerCenter.headline(for: GitFailureBanner(
operation: .branchSwitch,
reason: "your local changes would be overwritten"
)) == "Couldn't switch branches — your local changes would be overwritten")
// The tail is trimmed like every other diagnostic tail, and an absent one leaves the action
// clause alone rather than trailing a dash into nothing.
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " the disk is full. "))
== "Undo failed — the disk is full")
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " ")) == "Undo failed")
}
@Test("The restore pair maps from the direction the provider crossed in")
func restoreOperationsMapFromDirection() {
// The provider knows which key was pressed and nothing else about banners; this is the whole
// of the translation, kept in one place so no wiring can get it backwards.
#expect(GitOperation.restore(.undo) == .undo)
#expect(GitOperation.restore(.redo) == .redo)
}
@Test("Every lock reason says what is wrong and that the view is still the last good one")
func lockHeadlinesReassure() {
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot]
@@ -1004,52 +775,19 @@ struct BannerCenterStoreTests {
let store = try BoardStore(rootURL: fixture.root)
store.enterUnwritableLock(.permissionDenied)
store.banners.postGitFailure(.undo, reason: "the working tree is locked")
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
store.banners.postLoss("Pasted 'Fix login' without its 3 attachments")
store.banners.suspendHistory(reason: "the disk is full")
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
store.banners.postSignpost("This card changed on the remote")
// The git failure posts before the write failure, so recency (and the tie rule alike)
// puts the write one-shot first within the shared failure rank.
#expect(store.bannerRows.map(\.id) == [
"operation:\(store.banners.operations[0].id.uuidString)",
"read-only-lock",
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
"git-failure:\(store.banners.gitFailures[0].id.uuidString)",
"loss:\(store.banners.losses[0].id.uuidString)",
"history-suspension",
"signpost:\(store.banners.signposts[0].id.uuidString)",
])
}
/// **The row the git state raises, through the store** (06-history-undo.md Rules, the
/// corrupt-`.git` loud failure, ruled 2026-07-31) raised and healed by
/// `noteRepositoryUnreadable(_:)`, which is the seam `AppModel.beginSession` wires the
/// committer's pause transitions to, and **announced** both ways per 10-accessibility.md.
@Test("The unreadable repository stands on the strip and is spoken when it appears and clears")
func theUnreadableRepositoryRowIsRaisedAndSpoken() throws {
let fixture = try makeBoardWithUneditableLane()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
var spoken: [String] = []
store.announce = { if let line = $0 { spoken.append(line) } }
store.noteRepositoryUnreadable(true)
#expect(store.bannerRows.map(\.id) == ["repository-unreadable"])
#expect(store.bannerRows[0].headline == BannerCenter.repositoryUnreadableMessage)
#expect(spoken == ["Error: \(BannerCenter.repositoryUnreadableMessage)"],
"a standing banner is announced when it appears — the row's own sentence, tone first")
// The 15 s re-read confirming what is already standing must not say it again.
store.noteRepositoryUnreadable(true)
#expect(spoken.count == 1)
store.noteRepositoryUnreadable(false)
#expect(store.bannerRows.isEmpty)
#expect(spoken.last == "History is recording again")
}
}
-44
View File
@@ -441,50 +441,6 @@ struct BoardAnnouncerSpeechTests {
)
}
/// **The corrupt-`.git` loud failure, spoken** (06-history-undo.md Rules, ruled 2026-07-31:
/// "announced per 10-accessibility.md"). It ranks last of the raised conditions, matching the
/// strip's own precedence: the two above it describe the board's files, this one the history
/// over them.
@Test("The unreadable repository announces on arrival, under the conditions about the files")
func raisedRepositoryUnreadable() {
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableAfter = true
#expect(
BoardAnnouncer.speech(for: facts)
== AccessibilityPhrases.bannerLabel(
tone: .error,
headline: BannerCenter.repositoryUnreadableMessage
)
)
// A breakage standing beside it leads: the board on screen not being the board on disk is
// the more consequential of the two.
facts.breakageAfter = breakage()
#expect(
BoardAnnouncer.speech(for: facts)
== AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage()))
)
}
@Test("A repository that heals is announced too, as the regained capability")
func clearedRepositoryUnreadable() {
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableBefore = true
facts.repositoryUnreadableAfter = false
#expect(BoardAnnouncer.speech(for: facts) == "History is recording again")
}
@Test("A standing unreadable repository is not repeated on every re-read")
func standingRepositoryUnreadableIsNotRepeated() {
var facts = BoardAnnouncer.ReloadFacts()
facts.repositoryUnreadableBefore = true
facts.repositoryUnreadableAfter = true
#expect(BoardAnnouncer.speech(for: facts) == nil, "the 15 s re-read confirms; it does not narrate")
}
@Test("A cleared lock is announced — the banner speaks when it clears, not only when it appears")
func clearedLock() {
var facts = BoardAnnouncer.ReloadFacts()
-115
View File
@@ -1,5 +1,4 @@
import Foundation
import SwiftGitX
import Testing
@testable import Kanban
@@ -653,117 +652,3 @@ struct BoardDecisionSurfaceAttendanceTests {
#expect(second.access == nil)
}
}
// MARK: - The Pro repair commit
/// HEAD's first-parent ancestry, newest first read through SwiftGitX, never through the committer
/// that made the commits (`AutoCommitTests`' rule, kept: nothing here shells out to `git`).
private func repairHistory(at boardRoot: URL, limit: Int = 8) throws -> [(subject: String, authorName: String, authorEmail: String, committerName: String)] {
let repository = try Repository.open(at: boardRoot)
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
var records: [(String, String, String, String)] = []
var current: Commit? = tip
while let commit = current, records.count < limit {
records.append((commit.summary, commit.author.name, commit.author.email, commit.committer.name))
current = (try? commit.parents)?.first
}
return records
}
@MainActor
@Suite("Decision surface ▸ the Pro repair commit")
struct BoardDecisionSurfaceRepairCommitTests {
/// An `AppModel` whose app-side state lives in temp rather than in the app's real Application
/// Support home, and which reads as Pro `AutoCommitCompositionRootTests`' fixture.
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("DecisionRepairCommit-\(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) })
}
/// **A repaired Pro board's first flush is one heal commit, authored by the integrity identity**
/// (01-storage-format.md § Malformed input: "On Pro boards the repairs drop heal-marked receipts
/// and commit separately as one repair commit, never folded into anyone else's work";
/// 06-history-undo.md Commit messages Healing mutations commit separately).
///
/// The whole chain is exercised end to end, because every link in it can fail silently and the
/// symptom is identical each time a commit blaming the outside world for the app's own repair:
///
/// 1. The surface's default resolution is the minted repair.
/// 2. `BoardRepairRun` writes it store-lessly and marks every receipt in its own ledger.
/// 3. The store built by the following walk **adopts** that ledger (`EchoLedger.adopt`)
/// before `beginSession`, which is where Pro's committer is composed and started.
/// 4. `GitAutoCommitter.start()` harvests, so the debounce it arms can see receipts that were
/// dropped before any write bracket of this session existed.
/// 5. `CommitAttribution.split` sorts the repaired path into the heal class, and the heal class
/// is authored `Lanework Integrity <integrity@lanework.invalid>` with the user as committer.
@Test("A repaired board under Pro + git produces a separate heal commit")
func aRepairCommitsAsTheIntegrityIdentity() async throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// A board whose root is missing `schema` the minted stamp's own case. The lane is here so
// the repository has an ordinary tree around the file being repaired.
try fixture.item("", "---\ntitle: Needs A Stamp\ncreated: 2026-01-01T09:00:00Z\n---\nBoard.\n")
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n")
let (model, tearDown) = try makeModel()
defer { tearDown() }
// The repository, with everything as it stands committed including the broken root, which is
// what makes the repair a real change rather than a fresh file.
let git = HistoryStore.compose(boardRoot: fixture.root, ledger: EchoLedger())
#expect(await git.addGit())
let commitsBefore = try repairHistory(at: fixture.root).count
git.stopAutoCommit()
// The surface, exactly as the window builds it.
let surface = BoardDecisionSurfaceModel(failure: try failure(of: fixture), boardRoot: fixture.root)
#expect(surface.canRepairAndOpen, "a lone missing root schema is a minted repair, preselected")
// Repair and Open's write half.
let outcome = BoardRepairRun.apply(surface.plannedRepairs, boardRoot: fixture.root)
#expect(outcome.failure == nil)
// The walk that follows, and the store it builds. Built directly rather than through the
// registry so this case is about the repair's commit and not about the open's *other* heals
// (the agent guide, the `.gitignore` seed), which the registry's acquire also fires.
let result = try BoardLoader.load(boardRoot: fixture.root, skipping: surface.skipSet)
let store = BoardStore(rootURL: fixture.root, loaded: result, skipping: surface.skipSet)
// The adoption, before the session composes the committer.
store.echoes.adopt(outcome.ledger)
let ref = BoardWindowRef(url: fixture.root)
let recordID = model.boardRegistry.recordOpen(of: fixture.root)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
let committer = try #require(model.session(for: ref)?.git?.committer)
// The debounce `start()` armed is not what this asserts; the explicit flush is.
committer.stop()
committer.debounceInterval = .seconds(30)
committer.coveringSnapshotDeadline = .milliseconds(50)
committer.coveringSnapshotPollInterval = .milliseconds(5)
await committer.flushNow()
let log = try repairHistory(at: fixture.root)
#expect(log.count == commitsBefore + 1, "one repair commit, never folded and never split further")
let head = try #require(log.first)
#expect(head.authorName == CommitAttribution.integrityAuthorName)
#expect(head.authorEmail == CommitAttribution.integrityAuthorEmail)
#expect(
head.authorEmail != CommitAttribution.externalAuthorEmail,
"the app's own repair must never be blamed on the outside world"
)
// "the committer stays the user (the recorded-by convention)".
#expect(head.committerName == GitCommitOperation.userIdentity(at: fixture.root).name)
#expect(GitCommitOperation.changedPaths(at: fixture.root).isEmpty, "the flush leaves nothing dirty")
}
}
+3 -18
View File
@@ -803,32 +803,17 @@ struct BoardStoreReloadMemoTests {
#expect(store.landedReloads == 2)
}
@Test("A value-equal reload still lands for the auto-commit seam and the covering gate")
@Test("A value-equal reload still lands for the covering gate")
func aValueEqualReloadStillLands() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let landings = LandingCount()
store.commitSeam = HistoryCommitSeam(
willWrite: {},
writeBracketDidClose: {},
reloadDidLand: { _ in landings.value += 1 }
)
_ = await reload(store)
// The covering gate counts landings, not assignments (`GitAutoCommitter.landedReloads`), and
// the commit seam is armed by every landing whether or not the snapshot moved "a landing
// that finds nothing to commit is the silent no-op, not a wasted trip".
#expect(landings.value == 1)
// The covering gate counts landings, not assignments: a walk covers what it walked whether or
// not the tree turned out to differ.
#expect(store.landedReloads == 1)
#expect(store.snapshotGeneration == 0)
}
}
/// What the reload path told the history seam a box, because `HistoryCommitSeam` is a struct of
/// closures and a captured `var` cannot be read back after the reload has landed.
@MainActor
private final class LandingCount {
var value = 0
}
-570
View File
@@ -1,570 +0,0 @@
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 = HistoryStore.compose(boardRoot: fixture.root, 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 = HistoryStore.compose(boardRoot: fixture.root)
#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)")
}
}
+6 -7
View File
@@ -587,7 +587,7 @@ struct BoardSessionHistoryTests {
defer { tearDown() }
let bound = FakeHistoryProvider()
model.makeHistoryProvider = { _, _ in bound }
model.makeHistoryProvider = { _ in bound }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
@@ -605,14 +605,13 @@ struct BoardSessionHistoryTests {
defer { tearDown() }
// 12-editions.md The entitlement: the tier is read at composition, once, and recorded.
// **PIVOT 2026-08-07**: it is no longer *handed* anywhere. This test used to pin the argument
// arriving at `makeHistoryProvider` (`{ _, tier, _ in }`); the parameter is gone, so what is
// pinned now is the pair of facts that replaced it the seam takes a store and a git state
// and nothing else, and the session still carries the tier for the base/Pro split yet to be
// ruled. The substrate matrix itself is `GitUndoBindingTests`'.
// **PIVOT 2026-08-07**: it is no longer *handed* anywhere, and the 2026-08-08 excision took
// the last argument beside it the seam takes a store and nothing else. What is pinned here
// is what replaced the old matrix: one call per board, and a session still carrying the tier
// for the base/Pro split yet to be ruled.
var seen = 0
model.currentTier = { .pro }
model.makeHistoryProvider = { _, _ in
model.makeHistoryProvider = { _ in
seen += 1
return NativeHistoryProvider()
}
-93
View File
@@ -623,96 +623,3 @@ struct GitPathHistoryTests {
#expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
}
}
// MARK: - Session composition
@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("HistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
return (model, { try? FileManager.default.removeItem(at: folder) })
}
@MainActor
@discardableResult
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return ref
}
@MainActor
@Suite("Board sessions ▸ the git state they compose")
struct BoardSessionGitTests {
@Test("A free-tier session composes a git state too, and detects the repository it finds")
func freeSessionsComposeAGitStateAsWell() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let (model, tearDown) = try makeModel()
defer { tearDown() }
// **PIVOT 2026-08-07** (12-editions.md). The inverse of what this test used to pin: the free
// tier carried no git state at all, reported mode `none` on this very board, and injected
// nothing into the loader. Git left the paywall, so the session composes exactly what a Pro
// session composes same call, no tier in it.
model.currentTier = { .free }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.git != nil)
#expect(session.gitMode == .git, "the mode is the disk's answer, not the tier's")
#expect(session.store.makeIdentityHistoryRanker != nil, "and the loader's rung is wired")
#expect(session.tier == .free, "the tier is still recorded — it just decides nothing here")
}
@Test("A session on a git board composes git mode and wires the loader's ranker")
func sessionsCarryTheDetectedMode() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
// A real repository, so the ranker has something to read.
let seed = HistoryStore.compose(boardRoot: fixture.root)
#expect(await seed.addGit())
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.gitMode == .git)
let provider = try #require(session.store.makeIdentityHistoryRanker)
let ranker = try #require(provider())
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
// The provider binding arrived with the undo/redo card: a session on a git board binds the
// git substrate over exactly this mode (12-editions.md The provider seam).
#expect(session.history is GitHistoryProvider)
}
@Test("A session on a plain board is mode none and injects nothing")
func sessionsOnPlainBoardsInjectNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.gitMode == .none)
let provider = try #require(session.store.makeIdentityHistoryRanker, "the wiring is there")
#expect(provider() == nil, "and it answers nothing on a board with no repository")
}
}