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 = [] /// **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) } }