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
607 lines
29 KiB
Swift
607 lines
29 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
// MARK: - GitHistoryProvider
|
|
|
|
/// **Pro's undo substrate: the commit trail itself** (06-history-undo.md; 12-editions.md ▸ The
|
|
/// provider seam) — the second implementation of `HistoryProviding`, and the one the seam was
|
|
/// designed around.
|
|
///
|
|
/// ### The stack is not a stack
|
|
///
|
|
/// "The stack **is** HEAD's first-parent ancestry, live" (06 ▸ Rules). Nothing here records a step
|
|
/// when the board is written to; `register(_:)` is a deliberate no-op, because on a git board an undo
|
|
/// step is a *commit* and commits are made by the auto-committer, by an agent, or by a terminal. What
|
|
/// this object holds is a **pointer into that ancestry** — which commit ⌘Z would cross next — plus a
|
|
/// redo list of the commits already crossed in this session. Both are caches over a repository that
|
|
/// remains the only truth, which is what makes "no sidecar state, nothing ever lost" (14 ▸ C8) a
|
|
/// property of the shape rather than a discipline.
|
|
///
|
|
/// ### Four rules, and where each one lives
|
|
///
|
|
/// - **Forward only.** A crossing writes an older state as a *new commit* — `GitRestoreOperation`,
|
|
/// which cannot reset because it never resolves a reset symbol. Old commits stay reachable; refs
|
|
/// only move forward.
|
|
/// - **Exactly one commit per ⌘Z.** The pre-flight sync (`syncToHEAD`) re-reads HEAD before every
|
|
/// crossing, so agents' self-commits landed since the last operation become the new top and ⌘Z
|
|
/// steps back over *them* rather than silently reverting twenty minutes of their work.
|
|
/// - **Any arrival clears redo.** From the pre-flight sync for commits made outside the app, and from
|
|
/// `noteLanded(_:)` for the ones this app's committer made — with the one exception the heal rule
|
|
/// requires (below).
|
|
/// - **In-session and post-relaunch are one rule.** `reseed()` is the same ancestry walk from
|
|
/// scratch, so a relaunch, a branch switch and a foreign arrival all take the same path.
|
|
///
|
|
/// ### Heal transparency, and its honest limit
|
|
///
|
|
/// Heal-class commits never become steps: the pointer passes over them, and a restore excludes the
|
|
/// paths whose divergence is heal work — so a ⌘Z run never reverts a repair and never re-arms the
|
|
/// healer (06 ▸ Rules ▸ Heal commits are transparent to undo, in-session). Both halves are learned
|
|
/// from `GitAutoCommitter.reportLanded`, which fires while the Writer's heal-marked receipts still
|
|
/// exist. **In-session is the whole of it, deliberately**: the reseed is sidecar-free, so after a
|
|
/// relaunch old heal commits reappear as ordinary steps — the accepted one-bounce residual, named in
|
|
/// 06 and not worked around here.
|
|
///
|
|
/// ### Asynchrony
|
|
///
|
|
/// `HistoryProviding.undo()` is synchronous because a menu item is; a restore is a settle step, a
|
|
/// libgit2 diff, a set of writes and a commit. So the protocol methods start a `Task` and return, and
|
|
/// `cross(_:)` is the awaitable one a test drives. Enablement never waits on any of it: `canUndo`,
|
|
/// `canRedo` and both action names answer from the cached ancestry, so menu validation costs nothing.
|
|
@MainActor
|
|
@Observable
|
|
public final class GitHistoryProvider: HistoryProviding {
|
|
|
|
// MARK: - Identity
|
|
|
|
/// The board this is the history of — in git mode, the repository's working-tree root.
|
|
public let boardRoot: URL
|
|
|
|
// MARK: - Seams
|
|
|
|
/// **The pending auto-commit, flushed before a restore commits** (06 ▸ Rules ▸ Flush-before-
|
|
/// overwrite, applied here by the card's own rule: settled tree first, then one more commit).
|
|
///
|
|
/// Without it a ⌘Z would commit an older state on top of edits that never got a commit of their
|
|
/// own — the forward trail would be missing the very version the undo is stepping back from.
|
|
@ObservationIgnored
|
|
public var flushPendingCommit: (@MainActor () async -> Void)?
|
|
|
|
/// Whether the git surface is **held** — a detached HEAD or an in-progress merge/rebase
|
|
/// (06 ▸ Rules ▸ Abnormal repo states: "Undo/Redo and the branch controls disable"). Reads
|
|
/// `GitAutoCommitter.pause`, which is in-memory state, so enablement stays free.
|
|
@ObservationIgnored
|
|
public var isHeld: (@MainActor () -> Bool)?
|
|
|
|
/// Stops and restarts the auto-commit debounce around a restore, so its own writes cannot be
|
|
/// half-committed by a timer that fires mid-materialization.
|
|
@ObservationIgnored
|
|
public var suspendCommitting: (@MainActor () -> Void)?
|
|
|
|
@ObservationIgnored
|
|
public var resumeCommitting: (@MainActor () -> Void)?
|
|
|
|
/// **The save-or-discard step** (06 ▸ Rules ▸ Undo restore vs open Edit sessions). `nil` is a
|
|
/// board with no card windows to settle — every storeless test, and a session composed before any
|
|
/// window opened.
|
|
@ObservationIgnored
|
|
public var settleSessions: (@MainActor (Set<String>) async -> SessionSettleOutcome)?
|
|
|
|
/// Runs the restore inside the store's wholesale bracket — watcher suspended, one full reload at
|
|
/// the end, the board locked if that reload fails (02-architecture.md; `BoardStore.performWholesale`).
|
|
/// `nil` runs the work bare, which is what a repository-level test wants.
|
|
@ObservationIgnored
|
|
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 (HistoryDirection, GitOperationFailure) -> Void)?
|
|
|
|
// MARK: - The cached stack
|
|
|
|
/// HEAD's first-parent ancestry as of the last sync, newest first. The *stack*, cached.
|
|
public private(set) var ancestry: [GitCommitRecord] = []
|
|
|
|
/// The oid of the commit ⌘Z would cross next, or `nil` before the first seed. Not always
|
|
/// `ancestry.first`: after an undo the pointer sits below the restore commit the undo just made,
|
|
/// which is the whole mechanism behind "the undo-menu labels are the *crossed* commit's subject,
|
|
/// so labels never nest" (06 ▸ Commit messages).
|
|
public private(set) var pointerOID: String?
|
|
|
|
/// Commits crossed by ⌘Z in this session, oldest crossed first — ⇧⌘Z restores the state *at* the
|
|
/// last of them. Empty on every seed: "redo starts empty" (06 ▸ Rules ▸ Undo survives relaunch).
|
|
public private(set) var redoCommits: [GitCommitRecord] = []
|
|
|
|
/// The HEAD this cache was built against — the pre-flight sync's comparison.
|
|
private var knownHead: String?
|
|
|
|
/// Heal-class commits landed **in this session**, which the pointer passes over.
|
|
private var healOIDs: Set<String> = []
|
|
|
|
/// Paths committed as heal work in this session, which a restore never materializes.
|
|
private var healPaths: Set<String> = []
|
|
|
|
/// Whether a crossing is in flight — a second ⌘Z during a restore must not start a second one.
|
|
public private(set) var isCrossing = false
|
|
|
|
/// How many restores this provider has landed — the trail's own testimony, so a test need not
|
|
/// infer a crossing from a commit walk.
|
|
public private(set) var restoreCount = 0
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
|
|
|
|
public init(boardRoot: URL) {
|
|
self.boardRoot = boardRoot
|
|
}
|
|
|
|
// MARK: - Seeding
|
|
|
|
/// **Reseeds the stack from HEAD's first-parent ancestry, with an empty redo** — the API a board
|
|
/// open, a relaunch, and a **branch switch** all enter through (06 ▸ Rules ▸ Undo survives
|
|
/// relaunch; ▸ Branch switching: "The undo/redo stack does not survive a switch. It is discarded
|
|
/// and reseeded from the new HEAD's first-parent ancestry … redo starts empty").
|
|
///
|
|
/// Synchronous and off the main actor is not an option — the walk is libgit2 — so this is the
|
|
/// awaitable seed and `seed()` is the fire-and-forget one an open path can call.
|
|
public func reseed() async {
|
|
let root = boardRoot
|
|
let records = await Task.detached(priority: .userInitiated) {
|
|
GitHistoryWalk.ancestry(at: root)
|
|
}.value
|
|
adopt(records)
|
|
}
|
|
|
|
/// `reseed()` without waiting — what a board open and an add-git flip call.
|
|
public func seed() {
|
|
Task { await reseed() }
|
|
}
|
|
|
|
/// The reseed's main-actor half, split out so the sync path can reuse it.
|
|
private func adopt(_ records: [GitCommitRecord]) {
|
|
ancestry = records
|
|
knownHead = records.first?.oid
|
|
pointerOID = records.first?.oid
|
|
redoCommits = []
|
|
}
|
|
|
|
/// **The pre-flight sync** (06 ▸ Rules ▸ The stack is HEAD's first-parent ancestry, live):
|
|
/// "The stack re-syncs its top to HEAD before every undo/redo (self-commits move HEAD outside the
|
|
/// app's committer; the pre-flight sync is how the stack learns), so ⌘Z always steps back exactly
|
|
/// **one** commit."
|
|
///
|
|
/// One reference read when nothing moved, a full reseed when something did. A reseed here is the
|
|
/// same reseed a relaunch does, which is the point: "In-session and post-relaunch behavior are
|
|
/// thereby one rule."
|
|
private func syncToHEAD() async {
|
|
let root = boardRoot
|
|
let head = await Task.detached(priority: .userInitiated) {
|
|
GitHistoryWalk.headOID(at: root)
|
|
}.value
|
|
guard head != knownHead else { return }
|
|
Self.logger.debug("undo stack re-syncing: HEAD moved outside the stack's knowledge")
|
|
await reseed()
|
|
}
|
|
|
|
// MARK: - What the committer tells it
|
|
|
|
/// **A flush landed** — `GitAutoCommitter.reportLanded`.
|
|
///
|
|
/// Two behaviours, and the split between them is the heal rule:
|
|
///
|
|
/// - A window of **nothing but heal commits** leaves the pointer and the redo list exactly where
|
|
/// they were, and only records what was healed. That is what keeps an undo run from being
|
|
/// trapped on an ever-renewing top: "the fresh heal commit is in-session, transparent, and the
|
|
/// undo run continues past it" (06).
|
|
/// - **Anything else is an arrival**, and "any commit arriving from anywhere clears the redo
|
|
/// stack (classic behavior)" — with the new commit becoming the top of the stack, so the next
|
|
/// ⌘Z crosses what just happened.
|
|
public func noteLanded(_ window: GitLandedWindow) {
|
|
healOIDs.formUnion(window.healOIDs)
|
|
healPaths.formUnion(window.healPaths)
|
|
guard !window.commits.isEmpty else { return }
|
|
|
|
// The walk is libgit2 work and the committer reports from a synchronous outcome handler, so
|
|
// the cache catches up on its own turn. `settled()` is how anything that must not race it
|
|
// waits — `cross(_:)` first of all.
|
|
refresh = Task { [weak self] in
|
|
guard let self else { return }
|
|
if window.isEntirelyHeal {
|
|
// The ancestry gained a commit the pointer must be able to walk past; the pointer and
|
|
// the redo list are untouched.
|
|
await self.refreshAncestryKeepingPointer()
|
|
} else {
|
|
await self.reseed()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The cache catch-up started by the last `noteLanded(_:)`, if it is still running.
|
|
@ObservationIgnored
|
|
private var refresh: Task<Void, Never>?
|
|
|
|
/// **Waits for the cache to have heard about the last commit** — so "⌘Z now crosses what just
|
|
/// landed" is a fact to await rather than a race.
|
|
///
|
|
/// Every crossing awaits it, which is the production caller; a test awaits it to assert on
|
|
/// enablement the instant a flush returns, where a menu would simply be validated a turn later.
|
|
public func settled() async {
|
|
await refresh?.value
|
|
refresh = nil
|
|
}
|
|
|
|
/// Re-reads the ancestry without disturbing the pointer or the redo list — the heal window's
|
|
/// path, and the one every successful restore takes.
|
|
private func refreshAncestryKeepingPointer() async {
|
|
let root = boardRoot
|
|
let records = await Task.detached(priority: .userInitiated) {
|
|
GitHistoryWalk.ancestry(at: root)
|
|
}.value
|
|
ancestry = records
|
|
knownHead = records.first?.oid
|
|
if let pointerOID, !records.contains(where: { $0.oid == pointerOID }) {
|
|
// The pointer's commit is no longer in HEAD's first-parent ancestry — a rebase remapped
|
|
// it (07-sync-collab.md's pull). The honest answer is the seed's: start again from the
|
|
// top, redo empty.
|
|
adopt(records)
|
|
}
|
|
}
|
|
|
|
// MARK: - HistoryProviding
|
|
|
|
/// **Deliberately nothing — except the one thing a dropped step is owed.** On a git board an undo
|
|
/// step is a commit, and the Writer boundary's inverse operations are the *free* tier's substrate
|
|
/// (13-native-undo.md). `BoardStore` registers against whatever provider the session bound, and
|
|
/// this one has a repository to read instead — so the registrations arrive and are dropped, which
|
|
/// is exactly what "the commit trail itself is the substrate" (14 ▸ C1) means in code.
|
|
///
|
|
/// Dropping a step means **retiring** it (`HistoryStep.Retirement`), and that is what keeps the
|
|
/// tier split in 13's purge rule structural rather than conditional: "on Pro the substrate is
|
|
/// history: the close commit nets delete-plus-purge to a removal, revert restores it, so purge
|
|
/// rides the close flush there as before" (13 ▸ Interaction with the trash). A card window's close
|
|
/// step registered here is retired on arrival, so its deferred `comments/.trash/` purge runs
|
|
/// immediately — at the close flush, exactly where it ran before this milestone — with no call
|
|
/// site anywhere asking which substrate it is talking to.
|
|
public func register(_ step: HistoryStep) {
|
|
step.retirement?.run()
|
|
}
|
|
|
|
public var canUndo: Bool {
|
|
guard !isCrossing, isHeld?() != true else { return false }
|
|
return crossableIndex() != nil
|
|
}
|
|
|
|
public var canRedo: Bool {
|
|
guard !isCrossing, isHeld?() != true else { return false }
|
|
return !redoCommits.isEmpty
|
|
}
|
|
|
|
/// **The crossed commit's own subject** (06 ▸ Commit messages: "the undo-menu labels are the
|
|
/// *crossed* commit's subject, so labels never nest") — so the Edit menu reads "Undo Move card
|
|
/// 'Fix login' to Doing", never "Undo Undo: …" for a restore this session made.
|
|
public var undoActionName: String? {
|
|
guard canUndo, let index = crossableIndex() else { return nil }
|
|
return ancestry[index].subject
|
|
}
|
|
|
|
public var redoActionName: String? {
|
|
guard canRedo else { return nil }
|
|
return redoCommits.last?.subject
|
|
}
|
|
|
|
public func undo() {
|
|
Task { await cross(.undo) }
|
|
}
|
|
|
|
public func redo() {
|
|
Task { await cross(.redo) }
|
|
}
|
|
|
|
/// Drops the cache. The session's teardown, and nothing else — the *repository* is untouched, so
|
|
/// a board reopened a second later has exactly the same trail.
|
|
public func clear() {
|
|
ancestry = []
|
|
pointerOID = nil
|
|
redoCommits = []
|
|
knownHead = nil
|
|
healOIDs = []
|
|
healPaths = []
|
|
}
|
|
|
|
// MARK: - The crossing
|
|
|
|
/// One ⌘Z or ⇧⌘Z, awaitable — the whole restore, in the order the rules fix it.
|
|
public func cross(_ direction: HistoryDirection) async {
|
|
guard !isCrossing, isHeld?() != true else { return }
|
|
isCrossing = true
|
|
defer { isCrossing = false }
|
|
|
|
// **The settled tree first** (06 ▸ Rules ▸ Flush-before-overwrite, and this card's own rule):
|
|
// whatever the debounce is still holding becomes a commit of its own before a restore lands
|
|
// on top of it, so both states exist in the trail.
|
|
await flushPendingCommit?()
|
|
await settled()
|
|
await syncToHEAD()
|
|
|
|
switch direction {
|
|
case .undo:
|
|
guard let index = crossableIndex() else { return }
|
|
let crossed = ancestry[index]
|
|
guard let target = crossed.parentOID else { return }
|
|
let landed = await restore(
|
|
.undo,
|
|
to: target,
|
|
message: Self.restoreSubject(.undo, crossing: crossed.subject)
|
|
)
|
|
guard landed else { return }
|
|
redoCommits.append(crossed)
|
|
pointerOID = target
|
|
await refreshAncestryKeepingPointer()
|
|
|
|
case .redo:
|
|
guard let target = redoCommits.last else { return }
|
|
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,
|
|
// with the pointer where the undo found it.
|
|
pointerOID = target.oid
|
|
await refreshAncestryKeepingPointer()
|
|
}
|
|
}
|
|
|
|
/// Materializes one target state as a new commit. Answers whether the crossing may advance.
|
|
///
|
|
/// `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.
|
|
///
|
|
/// `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
|
|
|
|
// The **preliminary** plan: what the restore would write, which is the only thing that can
|
|
// say whether any open session is in its way.
|
|
guard let preliminary = await Task.detached(priority: .userInitiated, operation: {
|
|
GitRestoreOperation.plan(at: root, target: target, excluding: excluded)
|
|
}).value else {
|
|
report(direction, "this board's repository could not be read")
|
|
return false
|
|
}
|
|
|
|
var reconciling: Set<String> = []
|
|
if !preliminary.isEmpty, let settleSessions {
|
|
switch await settleSessions(Set(preliminary.paths)) {
|
|
case .cancelled, .failed:
|
|
// "Cancel keeps everything" — and a raw buffer that would not validate cancels the
|
|
// whole restore, focused on the offender (06 ▸ Branch switching).
|
|
return false
|
|
case .proceed:
|
|
reconciling = discardedFolders
|
|
discardedFolders = []
|
|
}
|
|
if reconciling.isEmpty {
|
|
// **Save All ended sessions, which commits them**: the tree moved, so the plan is
|
|
// recomputed below against the HEAD that now exists rather than the one it was
|
|
// drafted against.
|
|
//
|
|
// **Discard deliberately does not flush.** Ending a session un-stages-around its
|
|
// folder, so a flush here would commit exactly the uncommitted saves the user just
|
|
// asked to lose — a Discard that wrote them into history forever. Nothing is left
|
|
// behind by skipping it: everything else pending was already flushed at the top of
|
|
// the crossing, and the discarded folder is reconciled against the working tree by
|
|
// the plan itself.
|
|
await flushPendingCommit?()
|
|
await settled()
|
|
}
|
|
}
|
|
|
|
// Bound before the closure that crosses actors reads it — the settle step is over, and what
|
|
// it decided is a value from here on.
|
|
let folders = reconciling
|
|
var landed = false
|
|
let work: @MainActor () async -> Void = { [weak self] in
|
|
guard let self else { return }
|
|
self.suspendCommitting?()
|
|
defer { self.resumeCommitting?() }
|
|
let outcome = await Task.detached(priority: .userInitiated, operation: {
|
|
guard let plan = GitRestoreOperation.plan(
|
|
at: root,
|
|
target: target,
|
|
excluding: excluded,
|
|
reconciling: folders
|
|
) else {
|
|
return GitCommitOutcome.failed(GitOperationFailure(
|
|
operation: GitRestoreOperation.operationName,
|
|
message: "this board's repository could not be read"
|
|
))
|
|
}
|
|
return GitRestoreOperation.apply(plan, at: root, message: message)
|
|
}).value
|
|
|
|
switch outcome {
|
|
case .committed:
|
|
self.restoreCount += 1
|
|
landed = true
|
|
case .nothingToCommit:
|
|
// The step was crossed and needed no bytes — every path its diff would have written
|
|
// was heal work, or the two states are byte-identical. The pointer still advances:
|
|
// a step that changed nothing is still a step the user asked to walk past.
|
|
landed = true
|
|
case .locked:
|
|
// "Contention outlasting the brief retry surfaces as a *waiting* state" (06); the
|
|
// in-progress banner row is the branch card's surface. Here the honest answer is to
|
|
// leave the stack where it is so ⌘Z can simply be pressed again.
|
|
Self.logger.debug("restore found index.lock held — the stack is unchanged")
|
|
case let .held(pause):
|
|
Self.logger.notice("restore held: \(pause.rawValue, privacy: .public)")
|
|
case let .failed(failure):
|
|
self.reportFailure?(direction, failure)
|
|
}
|
|
}
|
|
|
|
if let runBracketed {
|
|
await runBracketed(message, work)
|
|
} else {
|
|
await work()
|
|
}
|
|
return landed
|
|
}
|
|
|
|
/// Folders the settle step's Discard branch left for the plan to reconcile against the working
|
|
/// tree. Filled by the gate's wiring through `noteDiscarded(_:)`.
|
|
private var discardedFolders: Set<String> = []
|
|
|
|
/// **A settle step discarded this card's session** — its folder is compared against the working
|
|
/// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
|
|
/// reverted by the restore itself rather than by a second pass that could disagree with it
|
|
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
|
|
public func noteDiscarded(cardFolderName: String) {
|
|
discardedFolders.insert(cardFolderName)
|
|
}
|
|
|
|
// MARK: - The pointer
|
|
|
|
/// The index in `ancestry` of the commit ⌘Z would cross, or `nil` when there is none.
|
|
///
|
|
/// Two commits are never steps:
|
|
///
|
|
/// - **Heal commits**, which the pointer passes over (06 ▸ Rules ▸ Heal commits are transparent).
|
|
/// - **The root commit** — a judgment call, recorded. It has no parent, so "the state before it"
|
|
/// is the empty tree: crossing it would delete every file the board has ever had, in one
|
|
/// keystroke, on a board whose entire history is that one commit. 06 says an unborn repository's
|
|
/// "undo trail simply starts empty"; a repository with exactly one commit is that repository one
|
|
/// commit later, and the honest reading is that the board's existence is not a step. (Nothing is
|
|
/// lost either way: the commit stays reachable in any git client.)
|
|
private func crossableIndex() -> Int? {
|
|
guard !ancestry.isEmpty else { return nil }
|
|
let start = pointerOID.flatMap { oid in ancestry.firstIndex { $0.oid == oid } } ?? 0
|
|
for index in start..<ancestry.count {
|
|
let commit = ancestry[index]
|
|
guard !healOIDs.contains(commit.oid) else { continue }
|
|
guard commit.parentOID != nil else { return nil }
|
|
return index
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|