Files
lanework/Kanban/Git/GitHistoryProvider.swift
T
rzen 274ccd9ff5 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 by 16ef377:

- 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
2026-08-01 07:43:45 -04:00

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
}
}