Files
lanework/Kanban/Git/GitBranchSwitcher.swift
T
rzen 1f7d84bf64 Build branch switching and the popover git surface
GitBranchSwitcher holds 06's sequence as one object: settle editors
explicitly (SessionSettleGate — Save All applies raw buffers with
validation and a refused buffer cancels the whole switch; Discard
reverts buffers AND reconciles the session folders against HEAD;
never silent), flush the pending auto-commit, stamp intent in the
per-board registry, bracketed safe checkout (git_checkout_tree
GIT_CHECKOUT_SAFE + set_head — no path passes FORCE, abort
included), one reload via the async wholesale bracket (failed final
reload engages the existing read-only lock), reseed undo/redo from
the new HEAD with redo empty, clear the stamp. Create-and-switch
keeps the full sequence — the tree-cannot-change proof fails under
concurrent writers. Lock contention shows the 02 in-progress row's
waiting state ("waiting for another writer's git lock"), bounded at
30s then failing cleanly naming the lock path.

GitOperationStamp + GitOperationRecovery: the own-leftovers rule as
a pure conjunction — pause state AND matching stamp = the app's own
interrupted operation, aborted to the pre-operation state with a
banner, stamp cleared on success only; either alone defers to the
pause-and-defer stance. Checked where the committer starts.

BoardGitControls replaces the read-only branch line: branch picker,
inline create-and-switch, the abnormal-state pause note in 06's own
words with controls dimmed, and commit-identity fields that read and
write repo-local .git/config (derived default as placeholder, never
value; unfocused resync, focused keystrokes kept; 2s poll while
visible — .git is watcher-filtered by design).

Also fixes a shipped bug from the undo card: plan(reconciling:)
matched card ids as path prefixes, so the reconcile branch was inert
on every board (<lane>/<card> never matches a bare id) — a session
file the restore diff couldn't name (attachment, comment, draft)
survived Discard and landed in the next flush's commit. One shared
component-exact folder-name resolver now serves both Discard paths;
noteDiscarded takes cardFolderName; regression test verified failing
against the pre-fix code.

41 branch tests + the regression; 2374 tests / 409 suites green;
InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 16:41:14 -04:00

476 lines
22 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 the popover's inline caption, deliberately, and 06 draws the line: the
/// popover-anchored answer is for operations that answer *at the form* (add-git, verify-remote),
/// 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.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
@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)
}
}