The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - 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
486 lines
23 KiB
Swift
486 lines
23 KiB
Swift
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)
|
||
}
|
||
}
|