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:
@@ -93,8 +93,14 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
|
||||
|
||||
/// A genuine restore failure — surfaced as 02's one-shot banner by whoever wires it.
|
||||
///
|
||||
/// **The direction travels with the failure** (02-architecture.md ▸ The banner surface, settled
|
||||
/// 2026-07-31): the one-shot failure class's second shape names the operation in the user's
|
||||
/// words — "Undo failed", "Redo failed" — and this object is the only one that knows which key
|
||||
/// was pressed. Everything past that boundary is the banner's: the closure receives the
|
||||
/// direction and libgit2's own message, never a sentence composed here.
|
||||
@ObservationIgnored
|
||||
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
|
||||
public var reportFailure: (@MainActor (HistoryDirection, GitOperationFailure) -> Void)?
|
||||
|
||||
// MARK: - The cached stack
|
||||
|
||||
@@ -326,7 +332,11 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
guard let index = crossableIndex() else { return }
|
||||
let crossed = ancestry[index]
|
||||
guard let target = crossed.parentOID else { return }
|
||||
let landed = await restore(to: target, message: "Undo: \(crossed.subject)")
|
||||
let landed = await restore(
|
||||
.undo,
|
||||
to: target,
|
||||
message: Self.restoreSubject(.undo, crossing: crossed.subject)
|
||||
)
|
||||
guard landed else { return }
|
||||
redoCommits.append(crossed)
|
||||
pointerOID = target
|
||||
@@ -334,7 +344,11 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
|
||||
case .redo:
|
||||
guard let target = redoCommits.last else { return }
|
||||
let landed = await restore(to: target.oid, message: "Redo: \(target.subject)")
|
||||
let landed = await restore(
|
||||
.redo,
|
||||
to: target.oid,
|
||||
message: Self.restoreSubject(.redo, crossing: target.subject)
|
||||
)
|
||||
guard landed else { return }
|
||||
redoCommits.removeLast()
|
||||
// The commit just restored *to* is the one the next ⌘Z crosses again — the classic dance,
|
||||
@@ -349,7 +363,11 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
/// `message` is both the commit's subject and the bracket's completion announcement
|
||||
/// (10-accessibility.md ▸ Live board announcements: "bracketed operations announce once, at
|
||||
/// completion") — one sentence, so the trail and the speech cannot disagree about what happened.
|
||||
private func restore(to target: String, message: String) async -> Bool {
|
||||
///
|
||||
/// `direction` is carried for one reason: a failure here is the banner's git-operation shape,
|
||||
/// and it is named by the key the user pressed rather than by the subject the restore would have
|
||||
/// carried (`reportFailure`).
|
||||
private func restore(_ direction: HistoryDirection, to target: String, message: String) async -> Bool {
|
||||
let root = boardRoot
|
||||
let excluded = healPaths
|
||||
|
||||
@@ -358,7 +376,7 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
|
||||
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
|
||||
}).value else {
|
||||
report("this board's repository could not be read")
|
||||
report(direction, "this board's repository could not be read")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -429,7 +447,7 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
case let .held(pause):
|
||||
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
|
||||
case let .failed(failure):
|
||||
self.reportFailure?(failure)
|
||||
self.reportFailure?(direction, failure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,10 +496,111 @@ public final class GitHistoryProvider: HistoryProviding {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func report(_ message: String) {
|
||||
reportFailure?(GitOperationFailure(
|
||||
private func report(_ direction: HistoryDirection, _ message: String) {
|
||||
reportFailure?(direction, GitOperationFailure(
|
||||
operation: GitRestoreOperation.operationName,
|
||||
message: message
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - The restore subject
|
||||
|
||||
/// **What a restore commit is called** — a pure function of the crossed subject and the
|
||||
/// direction, so the rule can be read (and pinned) without a repository.
|
||||
///
|
||||
/// The base rule is 06's oldest: a crossing commits the state it restored as "Undo: ⟨subject⟩"
|
||||
/// or "Redo: ⟨subject⟩". **Subjects don't nest** (06 ▸ Commit messages, settled 2026-07-31):
|
||||
/// when the crossed subject already carries a restore prefix — the post-relaunch case, where the
|
||||
/// reseed has made old restore commits ordinary steps — the composer "emits the *inverse* label
|
||||
/// instead of stacking: crossing 'Undo: S' yields 'Redo: S', crossing 'Redo: S' yields
|
||||
/// 'Undo: S'", which "caps prefixes at one across any number of relaunches".
|
||||
///
|
||||
/// ### Why the two directions read the crossed subject differently
|
||||
///
|
||||
/// The label states what the new commit's tree *does* to the base subject S: "Undo: S" is the
|
||||
/// state where S is out, "Redo: S" the state where S is in. An undo restores the crossed
|
||||
/// commit's **parent** — the state before it — so it emits that commit's inverse; a redo
|
||||
/// restores the target commit **itself**, so it emits that commit's own reading. That is what
|
||||
/// makes 06's sentence true ("undoing the restore that undid a move *re-applies* the move") and
|
||||
/// its mirror true with it: ⇧⌘Z back across an "Undo: S" step lands on the tree where S is out,
|
||||
/// and says "Undo: S" — the truer label, rather than the "Redo: S" the ⌘Z that crossed it
|
||||
/// already used for the opposite tree.
|
||||
///
|
||||
/// ### The legacy double prefix
|
||||
///
|
||||
/// "Undo: Undo: S" exists in the wild — the shipped nesting build made them — and the honest
|
||||
/// reading is this same one applied twice: the inner "Undo:" took S out, the outer one took
|
||||
/// *that* back, so the commit's tree is the one where S is in. Undoing across it therefore emits
|
||||
/// **"Undo: S"** — the tree it restores is the one without S, and saying "Redo: S" there would be
|
||||
/// exactly the euphemism 06 rules out ("This is the truer label, not a euphemism"), while
|
||||
/// "Redo: Undo: S" would keep the nesting the ruling caps at one. So each "Undo: " prefix flips
|
||||
/// the reading, each "Redo: " prefix leaves it, and what comes out carries exactly one.
|
||||
///
|
||||
/// The sniff is on the subject string, deliberately (06), so "a foreign commit that happens to
|
||||
/// open with a prefix gets the inverse label too; that's cosmetic — the restore itself is
|
||||
/// unaffected".
|
||||
public nonisolated static func restoreSubject(
|
||||
_ direction: HistoryDirection,
|
||||
crossing subject: String
|
||||
) -> String {
|
||||
let reading = RestoreSubjectReading(of: subject)
|
||||
let emitted = switch direction {
|
||||
case .undo: reading.polarity.inverse
|
||||
case .redo: reading.polarity
|
||||
}
|
||||
return "\(emitted.label): \(reading.base)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// What a subject says about its own base subject: is that change *in* the tree the subject
|
||||
/// describes, or has it been taken back out? Every restore label is one of these two readings, which
|
||||
/// is why the composer can invert rather than stack (`GitHistoryProvider.restoreSubject(_:crossing:)`).
|
||||
private enum RestorePolarity {
|
||||
/// The base subject's change is in the tree — every ordinary commit, and every "Redo: S".
|
||||
case applied
|
||||
/// The base subject's change has been taken back out — "Undo: S".
|
||||
case reverted
|
||||
|
||||
var inverse: RestorePolarity { self == .applied ? .reverted : .applied }
|
||||
|
||||
/// The word that states this reading in a subject.
|
||||
var label: String { self == .applied ? "Redo" : "Undo" }
|
||||
|
||||
/// The same word as a prefix — the only two this composer emits, and the only two it reads, so
|
||||
/// that reading and writing can never drift apart.
|
||||
var prefix: String { "\(label): " }
|
||||
}
|
||||
|
||||
/// One subject read as "a base subject, plus what its restore prefixes say about it".
|
||||
///
|
||||
/// Stripping is greedy because the legacy nesting build's subjects are (`restoreSubject`), and a
|
||||
/// prefix only counts while something is left for it to be *about*: a bare "Undo: " is somebody's
|
||||
/// subject, not a label with nothing after it.
|
||||
private struct RestoreSubjectReading {
|
||||
let base: String
|
||||
let polarity: RestorePolarity
|
||||
|
||||
init(of subject: String) {
|
||||
var base = subject
|
||||
var polarity = RestorePolarity.applied
|
||||
while true {
|
||||
let read: RestorePolarity
|
||||
if base.hasPrefix(RestorePolarity.reverted.prefix) {
|
||||
read = .reverted
|
||||
} else if base.hasPrefix(RestorePolarity.applied.prefix) {
|
||||
read = .applied
|
||||
} else {
|
||||
break
|
||||
}
|
||||
let rest = String(base.dropFirst(read.prefix.count))
|
||||
guard !rest.isEmpty else { break }
|
||||
base = rest
|
||||
// "Undo: " flips what the rest of the subject was saying; "Redo: " restates it.
|
||||
if read == .reverted { polarity = polarity.inverse }
|
||||
}
|
||||
self.base = base
|
||||
self.polarity = polarity
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user