Files
lanework/Kanban/Git/GitBranchSwitcher.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

486 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import os
// MARK: - GitBranchSwitcher
/// **The branch switch, in the order 06-history-undo.md ▸ Branch switching fixes it** — settle the
/// editors, flush the pending commit, stamp the intent, check out, reseed undo — with every step that
/// needs a window, a store, or a banner arriving as a seam.
///
/// ### Why the sequence is an object rather than a method
///
/// Because five of its six steps belong to somebody else. Settling editors is the card windows'
/// (`SessionSettleGate`), flushing is the committer's, bracketing is the store's, reseeding is the
/// undo provider's, and the in-progress row is the banner strip's — and 06 fixes the *order* they run
/// in, which is the one thing none of them can hold. `GitHistoryProvider` is the same shape for the
/// same reason, and its seams are wired from the same place (`AppModel.wireGitUndo`).
///
/// A `nil` seam is always the honest degenerate case rather than a disabled feature: a board with no
/// card windows has nothing to settle, a repository-level test has no store to bracket with, and a
/// board whose popover is closed has no spinner to update. The sequence runs the same way through all
/// of them.
///
/// ### What it deliberately does not do
///
/// Nothing remote. Tracking, ahead/behind, Pull, Push and push-on-commit follow the current branch
/// (06 ▸ Branch switching) and are 07-sync-collab.md's own card; this object moves HEAD and tells the
/// undo stack, and the remote half will join by reading the same `didSwitch` seam.
@MainActor
@Observable
public final class GitBranchSwitcher {
/// The board this switches branches on — in git mode, the repository's working-tree root.
public let boardRoot: URL
// MARK: - Seams
/// **The save-or-discard step, over every open session** (06 ▸ Branch switching: "if any open card
/// window has one … the switch presents a save-or-discard step").
///
/// Unlike the undo restore's, this gate is **not** narrowed by a diff. A restore materializes only
/// the paths it changes, so a session the diff never touches is genuinely unaffected; a branch
/// switch moves the whole tree out from under every session at once, and the raw-source hazard 06
/// names — "its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new
/// branch's card" — does not care whether the checkout touched that card at all. So the seam takes
/// no paths, and `SessionSettleGate.settleAll()` is what production passes.
@ObservationIgnored
public var settleSessions: (@MainActor () async -> SessionSettleOutcome)?
/// The pending auto-commit, flushed once the sessions are settled — "with sessions settled, the
/// pending auto-commit flushes (flush-before-overwrite) and checkout runs on a truly settled tree:
/// it cannot fail dirty".
@ObservationIgnored
public var flushPendingCommit: (@MainActor () async -> Void)?
/// Stops and restarts the auto-commit debounce around the checkout, so a timer cannot fire
/// mid-materialization. `GitHistoryProvider`'s pair, for its reason.
@ObservationIgnored
public var suspendCommitting: (@MainActor () -> Void)?
@ObservationIgnored
public var resumeCommitting: (@MainActor () -> Void)?
/// **The undo/redo reseed** (06 ▸ 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") — `GitHistoryProvider.reseed`, which is already exactly that.
@ObservationIgnored
public var reseedUndo: (@MainActor () async -> Void)?
/// The store's wholesale bracket: watcher suspended, one full reload at the end, the board locked
/// read-only if that reload fails (02-architecture.md; `BoardStore.performWholesale(announcing:awaiting:)`).
@ObservationIgnored
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// The in-progress banner row: begin, relabel (the lock's waiting state), end.
///
/// Three seams rather than one object because the banner is the *store's*, and this type is
/// composed on boards that have none. Relabelling is its own call because a held lock must change
/// what the row says without replacing the row: "contention outlasting the brief retry surfaces as
/// a *waiting* state in the operation's in-progress banner row" — the same operation, still
/// running, now explaining itself.
@ObservationIgnored
public var beginProgress: (@MainActor (String) -> UUID)?
@ObservationIgnored
public var updateProgress: (@MainActor (UUID, String) -> Void)?
@ObservationIgnored
public var endProgress: (@MainActor (UUID) -> Void)?
/// A clean failure — "surfaces as a one-shot banner failure naming the operation and the error,
/// the tree left as it was" (06 ▸ Interaction with external writers).
///
/// The banner rather than an inline caption, deliberately, and 06 draws the line: the
/// form-anchored answer is for operations that answer *at the form* (add-git, verify-remote —
/// forms that live in the board settings sheet since the 2026-07-31 popover/sheet split),
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
/// one surface" — which a branch switch is by construction, since its bracket locks the board and
/// its completion is announced.
///
/// **Which row that is, settled 2026-07-31** (02-architecture.md ▸ The banner surface): the
/// one-shot failure class's message-carrying git shape — error tone, failure rank, dismissable
/// and untimed. What travels is the operation and the underlying message; the sentence
/// ("Couldn't switch branches — …") is `BannerCenter`'s, which is why nothing here composes one.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
///
/// **A warning-tone loss row, not a failure** (02 ▸ The banner surface, settled 2026-07-31):
/// "recovery notices report a success, not a failure, and stay warning-tone" — the abort put the
/// previous state back, and the row exists so the user learns that it happened.
@ObservationIgnored
public var reportRecovery: (@MainActor (String) -> Void)?
/// The per-board registry's stamp — read at open, written before the repository is touched, and
/// cleared when the operation is over (`GitOperationStamp`).
@ObservationIgnored
public var readStamp: (@MainActor () -> GitOperationStamp?)?
@ObservationIgnored
public var writeStamp: (@MainActor (GitOperationStamp?) -> Void)?
/// Whether the git surface is held (`GitAutoCommitter.pause != nil`). The controls disable on it,
/// and this is the pre-flight that keeps a click that raced the render from presenting a modal
/// step for an operation the repository is about to refuse.
@ObservationIgnored
public var isHeld: (@MainActor () -> Bool)?
/// HEAD moved — what refreshes the popover's branch line (`HistoryStore.refreshBranch`). Called
/// after a successful switch and after a successful abort, and by nothing else.
@ObservationIgnored
public var didSwitch: (@MainActor () async -> Void)?
// MARK: - Observable state
/// Every local branch, as of the last refresh — the picker's contents.
public private(set) var branches: [String] = []
/// Whether a switch is in flight: the controls' disabled state, and the guard that keeps a second
/// click from starting a second checkout.
public private(set) var isSwitching = false
/// The last clean failure, or `nil`. Held beside the banner it is also posted to, so the popover
/// can show what happened while it was open without the banner having to be its only witness.
public private(set) var lastFailure: GitOperationFailure?
/// Folders whose card session the settle step's **Discard** branch just abandoned — reverted to
/// HEAD before anything else happens (see `perform`). Filled through `noteDiscarded(cardFolderName:)`,
/// which is how `AppModel`'s gate reports each one.
@ObservationIgnored
private var discardedFolders: Set<String> = []
/// **A settle step discarded this card's session.** "Discard reverts buffers and uncommitted saves
/// to HEAD" — the window reverted the buffer, and this is the switch remembering to revert the
/// saves.
public func noteDiscarded(cardFolderName: String) {
discardedFolders.insert(cardFolderName)
}
// MARK: - Tunables
/// The brief, silent backoff: "pull, push, branch switch, and undo restore meeting a held lock
/// wait and retry briefly, silently" (06 ▸ Interaction with external writers). The committer's own
/// numbers, for the committer's reason.
@ObservationIgnored
public var lockRetryDelay: Duration = .milliseconds(120)
@ObservationIgnored
public var lockRetryAttempts = 3
/// The cadence the waiting state retries on, once the brief backoff is spent.
@ObservationIgnored
public var lockWaitInterval: Duration = .seconds(1)
/// How long a wait runs before the row names the lock path — "a wait that persists implausibly
/// long names the lock path (a crashed writer's leftover is the user's to clear)".
@ObservationIgnored
public var lockPathNamingDelay: Duration = .seconds(5)
/// **The bound on the wait, recorded as a judgment call.** 06 describes a waiting state that
/// retries on its cadence and never becomes an error dialog; it does not say when — or whether —
/// it gives up. An unbounded wait would hold the board's wholesale bracket, and with it the
/// read-only lock, for as long as a crashed writer's `index.lock` sits on disk, with no way out
/// but quitting. So the wait ends, generously, at a clean failure that names the lock path — the
/// tree untouched, the branch unchanged, the banner explaining exactly what to clear. Never a
/// dialog, never a hammer, and never a board wedged by another process's litter.
@ObservationIgnored
public var lockWaitLimit: Duration = .seconds(30)
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
public init(boardRoot: URL) {
self.boardRoot = boardRoot
}
// MARK: - Phrases
/// The in-progress row while the checkout runs (02-architecture.md ▸ The banner surface:
/// "Switching to 'main'…").
public static func progressLabel(target: String) -> String {
"Switching to '\(target)'…"
}
/// The bracket's completion announcement (10-accessibility.md ▸ Live board announcements:
/// "bracketed operations announce once, at completion").
public static func completionAnnouncement(target: String) -> String {
"Switched to branch '\(target)'"
}
/// The waiting state, and the same sentence once the wait is long enough to name what is holding
/// the lock.
public static let waitingLabel = "Waiting for another writer's git lock"
public static func waitingLabel(path: String) -> String {
"\(waitingLabel) (\(path))"
}
// MARK: - Reads
/// Reloads the branch list — what the popover's `.task` calls when it appears, and what every
/// completed operation calls for itself.
public func refreshBranches() async {
let root = boardRoot
branches = await Task.detached(priority: .userInitiated) {
GitBranchOperation.localBranches(at: root)
}.value
}
// MARK: - The two operations
/// **Switches to an existing local branch.** Answers whether HEAD actually moved.
@discardableResult
public func switchTo(_ branch: String) async -> Bool {
await perform(target: branch, creating: false)
}
/// **Creates a branch at the current HEAD and switches to it.**
///
/// The full sequence runs — settle step included — and that is a judgment call, recorded. The card
/// this was built for allows skipping the settle "only if you can prove the tree cannot change",
/// and the proof does not hold: the new branch is created at whatever HEAD is *at that moment*,
/// and this app shares its repository with self-committing agents by design (06 ▸ "Two writers,
/// one repository"), so a commit landing between the flush and the create leaves a working tree
/// that the new branch does not describe. Two smaller reasons point the same way — the flush puts
/// pending work on the branch it was made on rather than on the branch that did not exist when it
/// was made, and one sequence is one thing to reason about. The step costs nothing when nothing is
/// dirty: the gate never appears unless a session is actually holding unsaved state.
@discardableResult
public func createAndSwitch(to branch: String) async -> Bool {
await perform(target: branch.trimmingCharacters(in: .whitespacesAndNewlines), creating: true)
}
private func perform(target: String, creating: Bool) async -> Bool {
guard !isSwitching, !target.isEmpty else { return false }
// A held repository disables the controls; this is the click that raced the render.
guard isHeld?() != true else { return false }
isSwitching = true
defer { isSwitching = false }
lastFailure = nil
discardedFolders = []
// **a. Settle the editors first — explicitly, never silently** (06 ▸ Branch switching). Before
// the bracket, because the step is modal and a modal inside a suspended watcher would hold the
// board read-only for as long as the user took to read it.
if let settleSessions {
switch await settleSessions() {
case .cancelled, .failed:
// "Cancel keeps the current branch and the sessions", and a raw buffer that will not
// validate "cancels the whole switch with focus on the offending window, nothing
// half-switched".
discardedFolders = []
return false
case .proceed:
break
}
}
let root = boardRoot
// **a. Discard's second half**: the windows reverted their buffers, and the *uncommitted
// saves* those sessions left on disk go back to HEAD here — before the flush, which would
// otherwise commit them the instant the ended session stopped being staged around
// (`GitRestoreOperation.revertToHead`).
let discarded = discardedFolders
discardedFolders = []
if !discarded.isEmpty {
let reverted = await Task.detached(priority: .userInitiated) {
GitRestoreOperation.revertToHead(folderNames: discarded, at: root)
}.value
guard reverted else {
fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "this board's repository could not be read"
))
return false
}
}
// **b. Flush the pending auto-commit** — the tree is settled from here on.
await flushPendingCommit?()
// **c. Stamp the intent, before the repository is touched** (06 ▸ Rules ▸ Abnormal repo
// states). Everything above this line is app-side; everything below can be interrupted.
let head = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.headOID(at: root)
}.value
let current = await Task.detached(priority: .userInitiated) {
GitRepository.branchName(at: root)
}.value
writeStamp?(GitOperationStamp(
fromBranch: current ?? "",
toBranch: target,
headOID: head
))
// **d. The checkout, bracketed** — watcher suspended, one full reload at the end, the board
// locked read-only if that reload fails.
let progress = beginProgress?(Self.progressLabel(target: target))
var landed = false
let work: @MainActor () async -> Void = { [weak self] in
guard let self else { return }
self.suspendCommitting?()
defer { self.resumeCommitting?() }
switch await self.runWaitingOutLocks(target: target, creating: creating, progress: progress) {
case .switched:
landed = true
// **e. Reseed undo/redo from the new HEAD**, inside the bracket: the stack must never
// be readable in a state where it describes the branch that is no longer checked out.
await self.reseedUndo?()
case let .failed(failure):
self.fail(failure)
case let .held(pause):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: pause.explanation
))
case let .locked(path):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "another program is still using this repository's index (\(path))"
))
}
}
if let runBracketed {
await runBracketed(Self.completionAnnouncement(target: target), work)
} else {
await work()
}
// The operation is over, whichever way it went: a clean failure left the tree exactly as it
// was, so there is nothing for a later open to abort.
writeStamp?(nil)
if let progress { endProgress?(progress) }
await didSwitch?()
await refreshBranches()
return landed
}
/// The checkout, with 06's lock posture around it: brief silent retries, then a waiting state in
/// the operation's own row, then — at `lockWaitLimit` — a clean failure naming the lock path.
private func runWaitingOutLocks(
target: String,
creating: Bool,
progress: UUID?
) async -> GitBranchOutcome {
let root = boardRoot
let startedWaiting = ContinuousClock.now
var attempt = 0
var announced = false
var named = false
while true {
let outcome = await Task.detached(priority: .userInitiated) {
creating
? GitBranchOperation.createAndSwitch(target, at: root)
: GitBranchOperation.checkout(target, at: root)
}.value
guard case let .locked(path) = outcome else { return outcome }
attempt += 1
if attempt <= max(0, lockRetryAttempts) {
// Brief and silent: "a held lock is another writer doing its job".
try? await Task.sleep(for: lockRetryDelay)
continue
}
let waited = ContinuousClock.now - startedWaiting
guard waited < lockWaitLimit else {
return .locked(path: path)
}
if !announced, let progress {
updateProgress?(progress, Self.waitingLabel)
announced = true
}
if !named, waited >= lockPathNamingDelay, let progress {
updateProgress?(progress, Self.waitingLabel(path: path))
named = true
}
try? await Task.sleep(for: lockWaitInterval)
}
}
// MARK: - The app's own leftovers
/// **Recovers an interrupted app-run switch, at board open** (06 ▸ Rules ▸ Abnormal repo states).
///
/// Called once per session, beside the committer's start — which is where the pause it is looking
/// for is first knowable, and before any of it reaches a user. Three outcomes, all of
/// `GitOperationRecovery`'s: nothing to do, a stale stamp dropped silently, or the app's own
/// leftover aborted with a banner.
///
/// **A failed abort keeps the stamp**, which is this file's second judgment call. 06 says the app
/// "aborts its own unfinished operation … then clears the stamp"; that sentence describes the
/// abort that worked. An abort refused by a conflicting working tree has restored nothing, and
/// clearing the stamp would demote the leftover to somebody else's on the next open — the app
/// would then defer forever to an operation only it ever started. So the stamp stands, the failure
/// is surfaced, and the next open tries again.
public func recoverInterruptedOperation() async {
guard let stamp = readStamp?() else { return }
let root = boardRoot
let pause = await Task.detached(priority: .userInitiated) {
GitCommitOperation.reading(at: root).pause
}.value
switch GitOperationRecovery.decide(stamp: stamp, pause: pause) {
case .nothingToDo:
return
case .clearStamp:
writeStamp?(nil)
case let .abort(stamp):
Self.logger.notice("aborting this app's own interrupted branch switch")
var restored = 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) {
GitBranchOperation.abort(stamp, at: root)
}.value
switch outcome {
case .switched:
restored = true
await self.reseedUndo?()
case let .failed(failure):
self.fail(failure)
case let .held(pause):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: pause.explanation
))
case let .locked(path):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "another program is using this repository's index (\(path))"
))
}
}
if let runBracketed {
await runBracketed(GitOperationStamp.interruptionMessage, work)
} else {
await work()
}
guard restored else { return }
writeStamp?(nil)
reportRecovery?(GitOperationStamp.interruptionMessage)
await didSwitch?()
}
await refreshBranches()
}
// MARK: - Failure
private func fail(_ failure: GitOperationFailure) {
lastFailure = failure
Self.logger.error("branch operation failed: \(failure.description, privacy: .public)")
reportFailure?(failure)
}
}