The seams unbind — the provider is always native, and the git stack compiles dead
Step 4 of strategy/01-git-excision.md, the entangled one: AppModel's makeHistoryProvider collapses to the native provider (the seam stays injectable per the reversibility posture), the session's git state and its wiring go (wireGitUndo, wireBranchSwitching, the card-session staging threading), BoardStore sheds commitSeam and the identity-history ranker (the loader's nil-safe rung now tops out at birth date — today's no-git behavior), SessionSettleGate keeps the gate and inherits the path utility it borrowed, BoardRegistry drops the persisted operation stamp (decode-safe), and the git banner family leaves BannerCenter with its announcer and accessibility phrases. One missed harvest tie severed (the narrator's root subject is its own now). Nothing outside Kanban/Git/ references the stack — proven by sweep. 2,855 tests green. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -85,15 +85,8 @@ struct BannerCenterOrderingTests {
|
||||
message: "Pasted 'Fix login' without its 3 attachments",
|
||||
occurredAt: Date(timeIntervalSince1970: 150)
|
||||
)
|
||||
let operation = InProgressOperation(label: "Pulling…")
|
||||
let operation = InProgressOperation(label: "Duplicating…")
|
||||
let signpost = InfoSignpost(message: "This card changed on the remote")
|
||||
// The failure class's second shape (settled 2026-07-31) — newer than the failed move, so it
|
||||
// leads the rank the two of them share.
|
||||
let restore = GitFailureBanner(
|
||||
operation: .undo,
|
||||
reason: "could not write to 'index.md': Permission denied",
|
||||
occurredAt: Date(timeIntervalSince1970: 120)
|
||||
)
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: .vanishedRoot,
|
||||
@@ -102,126 +95,27 @@ struct BannerCenterOrderingTests {
|
||||
losses: [loss],
|
||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||
operations: [operation],
|
||||
signposts: [signpost],
|
||||
gitFailures: [restore],
|
||||
repositoryUnreadable: true
|
||||
signposts: [signpost]
|
||||
)
|
||||
|
||||
// in-progress (pinned) > read-only lock > reload breakage > **the unreadable repository** >
|
||||
// one-shot failures, both shapes > loss rows > commit and attachment failures > passive info
|
||||
// rows. The two info classes sit at opposite ends of the strip, and the breakage class holds
|
||||
// two rows now (06-history-undo.md ▸ Rules, ruled 2026-07-31): the reload breakage first,
|
||||
// because it is the one saying the board on screen is not the board on disk.
|
||||
// in-progress (pinned) > read-only lock > reload breakage > one-shot failures > loss rows >
|
||||
// commit and attachment failures > passive info rows. The two info classes sit at opposite
|
||||
// ends of the strip.
|
||||
#expect(rows.map(\.id) == [
|
||||
"operation:\(operation.id.uuidString)",
|
||||
"read-only-lock",
|
||||
"reload-breakage",
|
||||
"repository-unreadable",
|
||||
"git-failure:\(restore.id.uuidString)",
|
||||
"one-shot:\(move.id.uuidString)",
|
||||
"loss:\(loss.id.uuidString)",
|
||||
"history-suspension",
|
||||
"one-shot:\(attachment.id.uuidString)",
|
||||
"signpost:\(signpost.id.uuidString)",
|
||||
])
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .error, .error, .warning, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false, false, false],
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false],
|
||||
"a spinner may never hide behind '+N more' — nothing else is pinned")
|
||||
}
|
||||
|
||||
/// **The corrupt-`.git` loud failure's row** (06-history-undo.md ▸ Rules, ruled 2026-07-31) —
|
||||
/// it stands with the breakage class and above every one-shot, which is what "breakage-class"
|
||||
/// buys it: a failed move posted a second ago never pushes it down the strip.
|
||||
@Test("The unreadable repository outranks every failure, and only the breakage class outranks it")
|
||||
func theUnreadableRepositoryStandsInTheBreakageClass() {
|
||||
let move = OneShotBanner(error: error(.move(title: "Fix login")))
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [move],
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: [],
|
||||
repositoryUnreadable: true
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id) == ["repository-unreadable", "one-shot:\(move.id.uuidString)"])
|
||||
#expect(rows.first?.tone == .error, "the ruling's word is breakage, and breakage is an error")
|
||||
}
|
||||
|
||||
@Test("A readable repository contributes no row at all")
|
||||
func aReadableRepositoryIsSilent() {
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [],
|
||||
losses: [],
|
||||
suspension: nil,
|
||||
operations: [],
|
||||
repositoryUnreadable: false
|
||||
)
|
||||
|
||||
#expect(rows.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Both failure shapes share one rank, interleaved by recency")
|
||||
func theFailureRankHoldsBothShapes() {
|
||||
// "Failures rank by what they are, not by which error vocabulary threw them" (02 § The
|
||||
// banner surface, settled 2026-07-31): the two shapes are one precedence class, so recency
|
||||
// — not vocabulary — decides which of them a user reads first.
|
||||
let oldMove = OneShotBanner(error: error(.move(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||
let newMove = OneShotBanner(error: error(.move(title: "New")), occurredAt: Date(timeIntervalSince1970: 3))
|
||||
let oldSwitch = GitFailureBanner(
|
||||
operation: .branchSwitch,
|
||||
reason: "your local changes would be overwritten",
|
||||
occurredAt: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
let newUndo = GitFailureBanner(
|
||||
operation: .undo,
|
||||
reason: "the repository is locked",
|
||||
occurredAt: Date(timeIntervalSince1970: 4)
|
||||
)
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [oldMove, newMove],
|
||||
losses: [LossBanner(message: "Folders can't be attached — 1 skipped")],
|
||||
suspension: nil,
|
||||
operations: [],
|
||||
gitFailures: [oldSwitch, newUndo]
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id).prefix(4) == [
|
||||
"git-failure:\(newUndo.id.uuidString)",
|
||||
"one-shot:\(newMove.id.uuidString)",
|
||||
"git-failure:\(oldSwitch.id.uuidString)",
|
||||
"one-shot:\(oldMove.id.uuidString)",
|
||||
])
|
||||
#expect(rows.map(\.tone) == [.error, .error, .error, .error, .warning],
|
||||
"and every one of them is a failure, above the warning-tone loss row")
|
||||
}
|
||||
|
||||
@Test("A git failure outranks a loss row however much older it is — the compromise is retired")
|
||||
func aGitFailureOutranksALossRow() {
|
||||
// The shipped build posted these as loss rows, which put a failed ⌘Z *below* a folder-drop
|
||||
// notice and painted it warning-tone. Both halves of that are retired (settled 2026-07-31).
|
||||
let ancient = GitFailureBanner(
|
||||
operation: .redo,
|
||||
reason: "the repository is locked",
|
||||
occurredAt: Date(timeIntervalSince1970: 1)
|
||||
)
|
||||
let fresh = LossBanner(message: "Folders can't be attached — 2 skipped", occurredAt: Date(timeIntervalSince1970: 900))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: [fresh], suspension: nil, operations: [],
|
||||
gitFailures: [ancient]
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id) == ["git-failure:\(ancient.id.uuidString)", "loss:\(fresh.id.uuidString)"])
|
||||
#expect(rows.map(\.tone) == [.error, .warning])
|
||||
}
|
||||
|
||||
@Test("An attachment failure ranks below other one-shots even when it is newer")
|
||||
func attachmentFailuresRankLast() {
|
||||
let attachment = OneShotBanner(
|
||||
@@ -400,40 +294,14 @@ struct BannerCenterLifecycleTests {
|
||||
#expect(center.losses.count == 1, "a loss survives everything except its own dismissal")
|
||||
}
|
||||
|
||||
@Test("A git failure dismisses individually and is untimed — the one-shot's lifecycle exactly")
|
||||
func gitFailuresDismissByIDAndNeverExpire() throws {
|
||||
let center = BannerCenter()
|
||||
center.postGitFailure(.undo, reason: "the repository is locked")
|
||||
center.postGitFailure(.branchSwitch, reason: "your local changes would be overwritten")
|
||||
#expect(center.gitFailures.count == 2)
|
||||
#expect(center.gitFailures.map(\.operation) == [.branchSwitch, .undo], "newest first on insertion")
|
||||
|
||||
let doomed = try #require(center.gitFailures.first)
|
||||
center.dismiss(doomed.id)
|
||||
|
||||
#expect(center.gitFailures.count == 1)
|
||||
#expect(center.gitFailures.first?.id != doomed.id, "dismissing one must not take its neighbour")
|
||||
|
||||
// No timer, no auto-expiry: an error never evaporates unread, whichever vocabulary raised it.
|
||||
let id = center.beginOperation(label: "Switching to 'main'…", cancel: nil)
|
||||
center.endOperation(id)
|
||||
center.suspendHistory(reason: "disk full")
|
||||
center.clearHistorySuspension()
|
||||
#expect(center.gitFailures.count == 1, "a failure survives everything except its own dismissal")
|
||||
|
||||
// And it is a *failure*, so nothing about it lands in the loss class.
|
||||
#expect(center.losses.isEmpty)
|
||||
#expect(center.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Dismissing all dismissable rows clears losses along with both failure shapes and signposts")
|
||||
@Test("Dismissing all dismissable rows clears losses along with the failures and signposts")
|
||||
func dismissAllClearsLosses() {
|
||||
let center = BannerCenter()
|
||||
center.postLoss("Pasted 'Fix login' without its 3 attachments")
|
||||
center.postGitFailure(.redo, reason: "the repository is locked")
|
||||
center.postSignpost("This card changed on the remote")
|
||||
center.dismissAllDismissableRows()
|
||||
#expect(center.losses.isEmpty)
|
||||
#expect(center.gitFailures.isEmpty)
|
||||
#expect(center.signposts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("postSkippedFolders no-ops when nothing was skipped")
|
||||
@@ -471,51 +339,6 @@ struct BannerCenterLifecycleTests {
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
/// **The corrupt-`.git` loud failure** (06-history-undo.md ▸ Rules, ruled 2026-07-31): the row
|
||||
/// is raised at detection, stands with no dismiss, and *heals* — "the banner clears when a later
|
||||
/// open or reload finds the repo readable".
|
||||
@Test("The unreadable repository is a standing condition that heals, never a dismissable row")
|
||||
func theUnreadableRepositoryIsAHealingCondition() throws {
|
||||
let center = BannerCenter()
|
||||
#expect(!center.isRepositoryUnreadable)
|
||||
|
||||
center.raiseRepositoryUnreadable()
|
||||
#expect(center.isRepositoryUnreadable)
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
|
||||
repositoryUnreadable: center.isRepositoryUnreadable
|
||||
)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .error)
|
||||
#expect(rows[0].dismissID == nil, "a condition is never dismissed while it is still true")
|
||||
|
||||
// 06's own sentence, verbatim — the three clauses being what is wrong, what it costs, and
|
||||
// the promise that makes waiting safe.
|
||||
#expect(rows[0].headline
|
||||
== "This board's git repository can't be read — history is paused; Lanework leaves the repository untouched")
|
||||
#expect(rows[0].headline == BannerCenter.repositoryUnreadableMessage)
|
||||
|
||||
center.clearRepositoryUnreadable()
|
||||
#expect(!center.isRepositoryUnreadable)
|
||||
#expect(BannerCenter.rows(
|
||||
lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: [],
|
||||
repositoryUnreadable: center.isRepositoryUnreadable
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Raising and clearing are idempotent — a re-read that confirms the condition changes nothing")
|
||||
func raisingTheUnreadableRepositoryIsIdempotent() {
|
||||
let center = BannerCenter()
|
||||
center.raiseRepositoryUnreadable()
|
||||
center.raiseRepositoryUnreadable()
|
||||
#expect(center.isRepositoryUnreadable)
|
||||
|
||||
center.clearRepositoryUnreadable()
|
||||
center.clearRepositoryUnreadable()
|
||||
#expect(!center.isRepositoryUnreadable)
|
||||
}
|
||||
|
||||
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
|
||||
func resuspendingKeepsTheClock() throws {
|
||||
let center = BannerCenter()
|
||||
@@ -544,7 +367,7 @@ struct BannerCenterLifecycleTests {
|
||||
Issue.record("expected an in-progress row")
|
||||
return
|
||||
}
|
||||
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
|
||||
#expect(!operation.isCancelable, "an operation that cannot be abandoned gets no Cancel — settled")
|
||||
|
||||
center.endOperation(id)
|
||||
rows = BannerCenter.rows(
|
||||
@@ -651,9 +474,9 @@ struct BannerRowControlsTests {
|
||||
#expect(cancelled.value)
|
||||
}
|
||||
|
||||
@Test("A git bracket's row offers no control at all — no Cancel, nothing to dismiss")
|
||||
@Test("An uncancelable bracket's row offers no control at all — no Cancel, nothing to dismiss")
|
||||
func uncancelableInProgressRowsOfferNothing() {
|
||||
let row = BannerRow.inProgress(InProgressOperation(label: "Pulling…"))
|
||||
let row = BannerRow.inProgress(InProgressOperation(label: "Rebuilding…"))
|
||||
|
||||
#expect(row.controls.isEmpty)
|
||||
}
|
||||
@@ -664,11 +487,8 @@ struct BannerRowControlsTests {
|
||||
let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
|
||||
let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")
|
||||
|
||||
let gitFailure = GitFailureBanner(operation: .undo, reason: "the repository is locked")
|
||||
|
||||
for (row, id) in [
|
||||
(BannerRow.oneShot(banner), banner.id),
|
||||
(BannerRow.gitFailure(gitFailure), gitFailure.id),
|
||||
(BannerRow.loss(loss), loss.id),
|
||||
(BannerRow.signpost(signpost), signpost.id),
|
||||
] {
|
||||
@@ -688,8 +508,7 @@ struct BannerRowControlsTests {
|
||||
let rows: [BannerRow] = [
|
||||
.readOnlyLock(.vanishedRoot),
|
||||
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
|
||||
.repositoryUnreadable,
|
||||
.historySuspended(HistorySuspension(reason: "the repository is corrupt")),
|
||||
.historySuspended(HistorySuspension(reason: "the volume is full")),
|
||||
]
|
||||
|
||||
for row in rows {
|
||||
@@ -706,10 +525,9 @@ struct BannerRowControlsTests {
|
||||
.readOnlyLock(.vanishedRoot),
|
||||
.reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
|
||||
.oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
|
||||
.gitFailure(GitFailureBanner(operation: .branchSwitch, reason: "the repository is locked")),
|
||||
.loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
|
||||
.historySuspended(HistorySuspension(reason: "disk full")),
|
||||
.inProgress(InProgressOperation(label: "Pulling…")),
|
||||
.inProgress(InProgressOperation(label: "Duplicating…")),
|
||||
.signpost(InfoSignpost(message: "This card changed on the remote")),
|
||||
]
|
||||
|
||||
@@ -797,53 +615,6 @@ struct BannerCenterPhrasingTests {
|
||||
#expect(uneditable.contains("frontmatter"))
|
||||
}
|
||||
|
||||
@Test("Every git operation names itself in the user's words, with the error as the tail")
|
||||
func everyGitOperationSaysSomethingDistinct() {
|
||||
// The vocabulary is closed and the sentences are here, not at the call sites (02 § The
|
||||
// banner surface, settled 2026-07-31: "the operation named in the user's words plus the
|
||||
// underlying error, phrasing still BannerCenter's"). `CaseIterable` is what keeps this test
|
||||
// honest when pro-m2 adds pull and push.
|
||||
let headlines = GitOperation.allCases.map {
|
||||
BannerCenter.headline(for: GitFailureBanner(operation: $0, reason: "the repository is locked"))
|
||||
}
|
||||
|
||||
for (operation, headline) in zip(GitOperation.allCases, headlines) {
|
||||
#expect(!headline.isEmpty, "\(operation) has no headline")
|
||||
#expect(headline.hasSuffix(" — the repository is locked"), "\(operation) drops the underlying error")
|
||||
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
|
||||
}
|
||||
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
|
||||
}
|
||||
|
||||
@Test("The git failure's sentences are the ruling's own")
|
||||
func gitFailureSentencesArePinned() {
|
||||
// Pinned as literals, unlike most phrasing here, because 02 wrote these two shapes by hand
|
||||
// and the third is their mirror: the undo pair names the command the user pressed, the
|
||||
// switch names the control they used.
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: "the repository is locked"))
|
||||
== "Undo failed — the repository is locked")
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .redo, reason: "the repository is locked"))
|
||||
== "Redo failed — the repository is locked")
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(
|
||||
operation: .branchSwitch,
|
||||
reason: "your local changes would be overwritten"
|
||||
)) == "Couldn't switch branches — your local changes would be overwritten")
|
||||
|
||||
// The tail is trimmed like every other diagnostic tail, and an absent one leaves the action
|
||||
// clause alone rather than trailing a dash into nothing.
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " the disk is full. "))
|
||||
== "Undo failed — the disk is full")
|
||||
#expect(BannerCenter.headline(for: GitFailureBanner(operation: .undo, reason: " ")) == "Undo failed")
|
||||
}
|
||||
|
||||
@Test("The restore pair maps from the direction the provider crossed in")
|
||||
func restoreOperationsMapFromDirection() {
|
||||
// The provider knows which key was pressed and nothing else about banners; this is the whole
|
||||
// of the translation, kept in one place so no wiring can get it backwards.
|
||||
#expect(GitOperation.restore(.undo) == .undo)
|
||||
#expect(GitOperation.restore(.redo) == .redo)
|
||||
}
|
||||
|
||||
@Test("Every lock reason says what is wrong and that the view is still the last good one")
|
||||
func lockHeadlinesReassure() {
|
||||
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot]
|
||||
@@ -1004,52 +775,19 @@ struct BannerCenterStoreTests {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.enterUnwritableLock(.permissionDenied)
|
||||
store.banners.postGitFailure(.undo, reason: "the working tree is locked")
|
||||
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
|
||||
store.banners.postLoss("Pasted 'Fix login' without its 3 attachments")
|
||||
store.banners.suspendHistory(reason: "the disk is full")
|
||||
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
|
||||
store.banners.postSignpost("This card changed on the remote")
|
||||
|
||||
// The git failure posts before the write failure, so recency (and the tie rule alike)
|
||||
// puts the write one-shot first within the shared failure rank.
|
||||
#expect(store.bannerRows.map(\.id) == [
|
||||
"operation:\(store.banners.operations[0].id.uuidString)",
|
||||
"read-only-lock",
|
||||
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
||||
"git-failure:\(store.banners.gitFailures[0].id.uuidString)",
|
||||
"loss:\(store.banners.losses[0].id.uuidString)",
|
||||
"history-suspension",
|
||||
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
||||
])
|
||||
}
|
||||
|
||||
/// **The row the git state raises, through the store** (06-history-undo.md ▸ Rules, the
|
||||
/// corrupt-`.git` loud failure, ruled 2026-07-31) — raised and healed by
|
||||
/// `noteRepositoryUnreadable(_:)`, which is the seam `AppModel.beginSession` wires the
|
||||
/// committer's pause transitions to, and **announced** both ways per 10-accessibility.md.
|
||||
@Test("The unreadable repository stands on the strip and is spoken when it appears and clears")
|
||||
func theUnreadableRepositoryRowIsRaisedAndSpoken() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
var spoken: [String] = []
|
||||
store.announce = { if let line = $0 { spoken.append(line) } }
|
||||
|
||||
store.noteRepositoryUnreadable(true)
|
||||
|
||||
#expect(store.bannerRows.map(\.id) == ["repository-unreadable"])
|
||||
#expect(store.bannerRows[0].headline == BannerCenter.repositoryUnreadableMessage)
|
||||
#expect(spoken == ["Error: \(BannerCenter.repositoryUnreadableMessage)"],
|
||||
"a standing banner is announced when it appears — the row's own sentence, tone first")
|
||||
|
||||
// The 15 s re-read confirming what is already standing must not say it again.
|
||||
store.noteRepositoryUnreadable(true)
|
||||
#expect(spoken.count == 1)
|
||||
|
||||
store.noteRepositoryUnreadable(false)
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
#expect(spoken.last == "History is recording again")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user