Realign code with the 2026-07-31 findings-resolution rulings
The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -458,6 +458,108 @@ struct GitUndoForwardTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Restore subjects compose the inverse
|
||||
|
||||
/// **"Subjects don't nest either — crossing a restore composes the inverse"** (06-history-undo.md ▸
|
||||
/// Commit messages, settled 2026-07-31). The composer is a pure function of the crossed subject and
|
||||
/// the direction (`GitHistoryProvider.restoreSubject(_:crossing:)`), so most of this suite needs no
|
||||
/// repository at all — and the one test that does is the case the rule exists for: the relaunch that
|
||||
/// turns yesterday's restore commit into an ordinary step.
|
||||
@MainActor
|
||||
@Suite("Git undo ▸ restore subjects compose the inverse")
|
||||
struct GitUndoRestoreSubjectTests {
|
||||
|
||||
@Test("An ordinary subject takes one prefix, per direction")
|
||||
func anOrdinarySubjectNestsOnce() {
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Move card 'Fix login' to Doing")
|
||||
== "Undo: Move card 'Fix login' to Doing")
|
||||
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Move card 'Fix login' to Doing")
|
||||
== "Redo: Move card 'Fix login' to Doing")
|
||||
}
|
||||
|
||||
@Test("Undoing across a restore emits the inverse label, not a second prefix")
|
||||
func undoingARestoreInverts() {
|
||||
// 06's own two examples: "crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
|
||||
// 'Undo: S'" — because an undo restores the crossed commit's *parent*, the state that
|
||||
// commit took away.
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Move card 'X'")
|
||||
== "Redo: Move card 'X'")
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Move card 'X'")
|
||||
== "Undo: Move card 'X'")
|
||||
}
|
||||
|
||||
@Test("Redoing across a restore restates it — the mirror of the undo rule, not a copy of it")
|
||||
func redoingARestoreRestates() {
|
||||
// A redo restores the target commit *itself*, so the label the new commit carries is that
|
||||
// commit's own reading: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out.
|
||||
// Emitting "Redo: S" there — the label the ⌘Z that crossed it already used, for the opposite
|
||||
// tree — would be the euphemism 06 rules out.
|
||||
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Move card 'X'")
|
||||
== "Undo: Move card 'X'")
|
||||
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Redo: Move card 'X'")
|
||||
== "Redo: Move card 'X'")
|
||||
}
|
||||
|
||||
@Test("A legacy double prefix reads as two flips, and comes out carrying one")
|
||||
func theLegacyDoublePrefixReadsAsTwoFlips() {
|
||||
// **The honest reading of a commit the shipped nesting build made.** "Undo: Undo: S" undid
|
||||
// the commit that undid S, so its tree is the one where S is *in*. Undoing across it puts S
|
||||
// back out — "Undo: S" — which is what 06's "the truer label, not a euphemism" asks for;
|
||||
// "Redo: S" would claim the opposite tree, and "Redo: Undo: S" would keep the nesting the
|
||||
// ruling caps at one ("it caps prefixes at one across any number of relaunches").
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Undo: Move card 'X'")
|
||||
== "Undo: Move card 'X'")
|
||||
#expect(GitHistoryProvider.restoreSubject(.redo, crossing: "Undo: Undo: Move card 'X'")
|
||||
== "Redo: Move card 'X'")
|
||||
// The legacy redo's shape reads the same way: "Redo:" restates whatever follows it.
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Redo: Undo: Move card 'X'")
|
||||
== "Redo: Move card 'X'")
|
||||
// And any depth caps at one, which is the property the ruling actually claims — the reading
|
||||
// is the parity of the "Undo:"s (two here, so the tree has the move in it) and never the
|
||||
// depth of the stack.
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: Redo: Undo: Move card 'X'")
|
||||
== "Undo: Move card 'X'")
|
||||
}
|
||||
|
||||
@Test("A prefix with nothing after it is somebody's subject, not a label")
|
||||
func aBarePrefixIsASubject() {
|
||||
// The sniff is on the subject string (06), and a subject that is *only* a prefix has no base
|
||||
// to talk about — stripping it would compose "Undo: " with nothing, naming no change at all.
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "Undo: ") == "Undo: Undo: ")
|
||||
// Foreign subjects that merely look like prefixes are unaffected — the match is exact.
|
||||
#expect(GitHistoryProvider.restoreSubject(.undo, crossing: "undo: fix the build")
|
||||
== "Undo: undo: fix the build")
|
||||
}
|
||||
|
||||
@Test("After a relaunch, ⌘Z over yesterday's restore commits the inverse — the trail never nests")
|
||||
func theRelaunchCaseLandsTheInverse() async throws {
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
let provider = await makeProvider(fixture, git, committer: committer)
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed"))
|
||||
committer.noteReloadLanded(sawForeignChange: true)
|
||||
await commitAndSettle(committer, provider)
|
||||
|
||||
await provider.cross(.undo)
|
||||
#expect(try subjects(at: fixture.root).first == "Undo: Rename card 'First' → 'Renamed'")
|
||||
|
||||
// **The relaunch**, which is what `reseed()` is: the stack starts again at HEAD with an empty
|
||||
// redo, so the restore commit above is now an ordinary step the pointer sits on.
|
||||
await provider.reseed()
|
||||
#expect(provider.undoActionName == "Undo: Rename card 'First' → 'Renamed'",
|
||||
"the menu label is still the crossed commit's own subject — labels never nested")
|
||||
|
||||
await provider.cross(.undo)
|
||||
|
||||
let subjects = try subjects(at: fixture.root)
|
||||
#expect(subjects.first == "Redo: Rename card 'First' → 'Renamed'",
|
||||
"the trail says what the restore did: the rename is back")
|
||||
#expect(title(ofCard: "\(Ident.lane1)/\(Ident.card1)", at: fixture.root) == "Renamed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Heal transparency
|
||||
|
||||
@MainActor
|
||||
@@ -1121,6 +1223,85 @@ struct GitUndoBindingTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Failures reach the strip as failures
|
||||
|
||||
/// **The one-shot failure class's second shape, wired** (02-architecture.md ▸ The banner surface,
|
||||
/// settled 2026-07-31): "a failed undo restore, branch switch, or (pro-m2) pull/push is an action
|
||||
/// that didn't happen: it presents in the error tone at the failure rank, never as a warning-tone
|
||||
/// loss row (the shipped loss-row compromise is retired)".
|
||||
///
|
||||
/// These are wiring tests: what the session hands each seam, and which class of row comes out the
|
||||
/// other side. The sentences themselves are `BannerCenterTests`' subject, and the precedence is
|
||||
/// `BannerCenter.rows(...)`'.
|
||||
@MainActor
|
||||
@Suite("Git undo ▸ a failed git operation is a failure row")
|
||||
struct GitOperationFailureBannerTests {
|
||||
|
||||
@Test("A failed restore posts the git failure shape, named by the key that was pressed")
|
||||
func aFailedRestorePostsAFailureRow() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await seed.addGit())
|
||||
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
model.currentTier = { .pro }
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
let provider = try #require(session.history as? GitHistoryProvider)
|
||||
|
||||
// What `restore(_:to:message:)` hands the seam when libgit2 refuses: the direction, and the
|
||||
// library's own message. The `operation` string on the failure is developer-facing and is
|
||||
// deliberately not what the user reads.
|
||||
provider.reportFailure?(.undo, GitOperationFailure(
|
||||
operation: GitRestoreOperation.operationName,
|
||||
message: "could not write to 'index.md': Permission denied"
|
||||
))
|
||||
|
||||
let banners = session.store.banners
|
||||
#expect(banners.gitFailures.count == 1)
|
||||
#expect(banners.gitFailures.first?.operation == .undo)
|
||||
#expect(banners.gitFailures.first?.reason == "could not write to 'index.md': Permission denied")
|
||||
#expect(banners.losses.isEmpty, "the loss-row compromise is retired — this is a failure")
|
||||
#expect(banners.oneShots.isEmpty, "and it stays off the closed WriteOperation vocabulary")
|
||||
|
||||
// ⇧⌘Z's mirror, from the same seam and the same closure.
|
||||
provider.reportFailure?(.redo, GitOperationFailure(
|
||||
operation: GitRestoreOperation.operationName,
|
||||
message: "the repository is locked"
|
||||
))
|
||||
#expect(banners.gitFailures.map(\.operation) == [.redo, .undo], "newest first, like every one-shot")
|
||||
}
|
||||
|
||||
@Test("A failed branch switch posts the same shape; the interruption recovery stays a loss row")
|
||||
func theSwitcherReportsFailureAndRecoveryDifferently() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await seed.addGit())
|
||||
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
model.currentTier = { .pro }
|
||||
let ref = try openBoard(model, at: fixture.root)
|
||||
let session = try #require(model.session(for: ref))
|
||||
let switcher = try #require(session.git?.switcher)
|
||||
|
||||
switcher.reportFailure?(GitOperationFailure(
|
||||
operation: GitBranchOperation.operationName,
|
||||
message: "your local changes would be overwritten"
|
||||
))
|
||||
// **The recovery is a success report** — "a branch switch was interrupted — the previous
|
||||
// state is restored" — so it keeps the warning tone the ruling leaves it (02).
|
||||
switcher.reportRecovery?(GitOperationStamp.interruptionMessage)
|
||||
|
||||
let banners = session.store.banners
|
||||
#expect(banners.gitFailures.map(\.operation) == [.branchSwitch])
|
||||
#expect(banners.losses.map(\.message) == [GitOperationStamp.interruptionMessage])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Routing
|
||||
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user