Realign undo with the evening rulings — repo-nested and identity anchors
Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -32,10 +32,19 @@ import os
|
||||
/// Beside the two closures, each registration below hands the funnel two `[HistoryExpectation]`
|
||||
/// lists: what the board must look like for the undo to be safe (the state the *forward* write left),
|
||||
/// and what it must look like for the redo to be safe (the state the *undo* leaves). One entry per
|
||||
/// item the gesture touched, naming that item's folder, whether it should be there and live, and the
|
||||
/// fields the write actually set — "extend the registration to carry whatever the predicate needs, no
|
||||
/// more". A field this gesture never wrote is never listed, which is what makes a foreign edit
|
||||
/// elsewhere — another card, another field of the same card — leave the step alone.
|
||||
/// item the gesture touched, naming that item, whether it should be there and live, and the fields
|
||||
/// the write actually set — "extend the registration to carry whatever the predicate needs, no more".
|
||||
/// A field this gesture never wrote is never listed, which is what makes a foreign edit elsewhere —
|
||||
/// another card, another field of the same card — leave the step alone.
|
||||
///
|
||||
/// ### Board gestures name a folder; session steps name a card
|
||||
///
|
||||
/// The one split in that sentence (`HistoryAnchor`, ruled 2026-07-31): a board gesture's expectation
|
||||
/// carries the path it wrote to, because where the item sits is what the gesture is *about*; a card
|
||||
/// window session's carries the card's **identity**, and the folder is resolved at apply time by the
|
||||
/// same snapshot walk the window resolves its own card with. So a lane move stales a move step (it
|
||||
/// should) and no longer stales the body edit that happens to have been typed into the same card.
|
||||
/// `folder(for:)` below is the one resolution both halves of a crossing go through.
|
||||
@MainActor
|
||||
extension BoardStore {
|
||||
|
||||
@@ -88,7 +97,9 @@ extension BoardStore {
|
||||
// call site has, and a store-wide "current window" would be a second answer able to be wrong
|
||||
// for exactly one gesture (the board styling a card whose window happens to be open).
|
||||
guard let sink: any HistoryProviding = window?.stack ?? history else {
|
||||
// No substrate at all — a repo-nested board (06 ▸ Rules), or a store with no session.
|
||||
// No substrate at all — a store with no session, or a test's substrate-less board. No
|
||||
// board the app composes lands here any more (`AppModel.makeHistoryProvider`, re-ruled
|
||||
// 2026-07-31: repo-nested boards bind the native stack like every other gitless board).
|
||||
// Nothing records the step, so nothing can ever retire it: the work is owed now.
|
||||
retirement?.run()
|
||||
return
|
||||
@@ -126,13 +137,18 @@ extension BoardStore {
|
||||
/// Everything about *what* the step does is `CardWindowUndo.netEffect()`'s; everything about
|
||||
/// whether there is a board to register it on is this method's:
|
||||
///
|
||||
/// - **A vanished card registers nothing.** 05-card-window.md ▸ Deletion & lifecycle dismisses the
|
||||
/// window when its card leaves the board — into the trash, with its lane, to another board — and
|
||||
/// the card's own departure is already a board step of its own (`deleteCard`). A session step
|
||||
/// naming folders that have moved could only be a step that skips, so the honest answer is not
|
||||
/// to register one: the window's fine stack dies with the window, as 13's session-only rule has
|
||||
/// it. (A trashed card keeps its `comments/.trash/` too — "a trashed card carries its
|
||||
/// `comments/`", 01-storage-format.md — and the residue sweeps at the next open of that card.)
|
||||
/// - **A card that resolves nowhere registers nothing** — purged, or moved out of the board.
|
||||
/// There is no folder for the step's components to be about, so a step registered here could
|
||||
/// only be a step that skips, and the honest answer is not to register one: the window's fine
|
||||
/// stack dies with the window, as 13's session-only rule has it.
|
||||
/// - **A card in the trash still registers**, and that is the ruling of 2026-07-31 read at the
|
||||
/// coarse step: the resolution below is `cardBodyTarget`'s, spanning both containers exactly as
|
||||
/// `writeCardBody`'s does, so "a trash move" is one of the tracked relocations that "never
|
||||
/// stales the step". 05-card-window.md ▸ Deletion & lifecycle dismisses the window when its card
|
||||
/// is deleted, and the session it was in the middle of is still the user's to walk back — into
|
||||
/// the trash folder the card now sits in, whose subtree the delete moved intact. (A trashed card
|
||||
/// carries its `comments/` — 01-storage-format.md — which is what makes that true of the deleted
|
||||
/// comments too.)
|
||||
/// - **A session with no net change registers nothing**, which is `netEffect()`'s `nil`.
|
||||
///
|
||||
/// - Parameter retiring: the deferred `comments/.trash/` purge (13 ▸ Interaction with the trash).
|
||||
@@ -146,11 +162,11 @@ extension BoardStore {
|
||||
inCard cardID: ItemID,
|
||||
retiring: @escaping @MainActor () -> Void
|
||||
) -> Bool {
|
||||
guard let item = Self.boardItem(cardID, in: snapshot), item.cardID != nil else { return false }
|
||||
guard let target = Self.cardBodyTarget(cardID, in: snapshot) else { return false }
|
||||
guard let net = window.netEffect() else { return false }
|
||||
registerStep(
|
||||
HistoryPhrase.cardSession,
|
||||
subject: item.title,
|
||||
subject: Self.cardTitle(at: target, in: snapshot),
|
||||
retiring: retiring,
|
||||
undoExpects: net.undoExpects,
|
||||
redoExpects: net.redoExpects,
|
||||
@@ -178,7 +194,7 @@ extension BoardStore {
|
||||
// gone is not one to pop: the stack is about to be cleared with the session anyway.
|
||||
guard let store else { return .failed }
|
||||
|
||||
guard HistoryStaleness.isCurrent(expectations) else {
|
||||
guard HistoryStaleness.isCurrent(expectations, resolvedBy: store.folder(for:)) else {
|
||||
store.banners.postSkippedStep(direction, subject: subject)
|
||||
return .skipped
|
||||
}
|
||||
@@ -206,6 +222,39 @@ extension BoardStore {
|
||||
|
||||
private static let historyLogger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "history")
|
||||
|
||||
// MARK: Resolving an anchor at apply time
|
||||
|
||||
/// **Where a step's anchor points on this board right now** — the one resolver, read by the
|
||||
/// staleness predicate and by every card-anchored inverse below (13 ▸ Rules, ruled 2026-07-31:
|
||||
/// "apply-time validation resolves the card's *current* folder exactly the way the window itself
|
||||
/// always resolves its card").
|
||||
///
|
||||
/// One method rather than two so validation and the write it guards can never disagree about
|
||||
/// where a step is aimed: `cross` checks with this and the inverse writes with it, in that order,
|
||||
/// against the same snapshot and the same `rootURL` — which a mid-session folder rename may have
|
||||
/// moved (`HistoryAnchor.folder(under:in:)`).
|
||||
func folder(for anchor: HistoryAnchor) -> URL? {
|
||||
anchor.folder(under: rootURL, in: snapshot)
|
||||
}
|
||||
|
||||
/// The same, as a write's precondition rather than a question.
|
||||
///
|
||||
/// Unreachable in the ordinary crossing — `cross` has already validated every anchor, and an
|
||||
/// unresolvable one skipped the whole step before any of this ran — so the throw exists to keep
|
||||
/// the impossible case honest rather than to be caught: an inverse that could not find its card
|
||||
/// must not silently write nothing and report success. It reads as an ordinary write failure,
|
||||
/// because that is what it would be.
|
||||
func requiredFolder(for anchor: HistoryAnchor, _ operation: WriteOperation) throws(BoardWriteError) -> URL {
|
||||
guard let folder = folder(for: anchor) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: rootURL.path,
|
||||
reason: .staleTarget(message: "the card this step was registered against is no longer on the board")
|
||||
)
|
||||
}
|
||||
return folder
|
||||
}
|
||||
|
||||
// MARK: Creates
|
||||
|
||||
/// One item a gesture brought into being: where it landed, the bytes it landed with, and any
|
||||
|
||||
Reference in New Issue
Block a user