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
This commit is contained in:
2026-07-31 16:41:14 -04:00
parent 142c6e75fe
commit 1f7d84bf64
18 changed files with 3045 additions and 75 deletions
+475
View File
@@ -0,0 +1,475 @@
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)
}
}