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