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:
@@ -880,9 +880,90 @@ public final class AppModel {
|
||||
}
|
||||
provider.settleSessions = { [weak self, weak provider] paths in
|
||||
guard let self, let provider else { return .proceed }
|
||||
return await self.settleGate(for: ref, provider: provider).settle(touching: paths)
|
||||
let gate = self.settleGate(for: ref) { [weak provider] folder in
|
||||
// The card's uncommitted on-disk saves are reverted by the restore itself, which
|
||||
// compares this folder against the working tree rather than against HEAD — see
|
||||
// `GitRestoreOperation.plan`.
|
||||
provider?.noteDiscarded(cardFolderName: folder)
|
||||
}
|
||||
return await gate.settle(touching: paths)
|
||||
}
|
||||
provider.seed()
|
||||
|
||||
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
|
||||
}
|
||||
|
||||
/// **The branch controls' seams** (06-history-undo.md ▸ Branch switching) — the five things the
|
||||
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
|
||||
/// interrupted switch recognizable as this app's.
|
||||
///
|
||||
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
|
||||
/// same board's git session seen from two sides — and because both must be re-wired on exactly the
|
||||
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
|
||||
private func wireBranchSwitching(
|
||||
git: HistoryStore,
|
||||
store: BoardStore,
|
||||
provider: GitHistoryProvider,
|
||||
ref: BoardWindowRef
|
||||
) {
|
||||
guard let switcher = git.switcher else { return }
|
||||
let recordID = sessions[ref]?.recordID
|
||||
|
||||
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
|
||||
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
|
||||
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
|
||||
switcher.resumeCommitting = { [weak git] in git?.committer?.start() }
|
||||
// **The undo/redo reseed** — the provider's own API, which is the relaunch reseed by
|
||||
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry … redo
|
||||
// starts empty".
|
||||
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
|
||||
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
|
||||
switcher.runBracketed = { [weak store] announcement, work in
|
||||
guard let store else { return await work() }
|
||||
try? await store.performWholesale(announcing: announcement) { await work() }
|
||||
}
|
||||
switcher.beginProgress = { [weak store] label in
|
||||
store?.banners.beginOperation(label: label) ?? UUID()
|
||||
}
|
||||
switcher.updateProgress = { [weak store] id, label in
|
||||
store?.banners.updateOperation(id, label: label)
|
||||
}
|
||||
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
|
||||
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise — see it for why
|
||||
// a git failure cannot be a `OneShotBanner` today.
|
||||
switcher.reportFailure = { [weak store] failure in
|
||||
store?.banners.postLoss(failure.description)
|
||||
}
|
||||
switcher.reportRecovery = { [weak store] message in
|
||||
store?.banners.postLoss(message)
|
||||
}
|
||||
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
|
||||
// record — a store-level test — simply carries no stamp, and recovery then has nothing to
|
||||
// recognize, which is the honest answer for a board the app has no state for.
|
||||
switcher.readStamp = { [weak self] in
|
||||
guard let self, let recordID else { return nil }
|
||||
return self.boardRegistry.gitOperationStamp(id: recordID)
|
||||
}
|
||||
switcher.writeStamp = { [weak self] stamp in
|
||||
guard let self, let recordID else { return }
|
||||
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
|
||||
}
|
||||
switcher.settleSessions = { [weak self, weak switcher] in
|
||||
guard let self, let switcher else { return .proceed }
|
||||
let gate = self.settleGate(
|
||||
for: ref,
|
||||
message: SessionSettleStep.branchSwitchMessage
|
||||
) { [weak switcher] folder in
|
||||
switcher?.noteDiscarded(cardFolderName: folder)
|
||||
}
|
||||
// Every open session, not the ones a diff reaches — see `SessionSettleGate.settleAll`.
|
||||
return await gate.settleAll()
|
||||
}
|
||||
|
||||
// **The own-leftovers check, at open** (06 ▸ Rules ▸ Abnormal repo states). Beside the
|
||||
// committer's start, which is where a pause first becomes knowable, and before anything the
|
||||
// user does can land on top of a half-finished checkout.
|
||||
Task { await switcher.recoverInterruptedOperation() }
|
||||
}
|
||||
|
||||
/// **The save-or-discard step for one board**, built from its open card windows
|
||||
@@ -890,7 +971,19 @@ public final class AppModel {
|
||||
///
|
||||
/// Built per ask rather than stored, because its whole content is "which card windows are open
|
||||
/// right now" — a set that changes under any operation slow enough to need the step at all.
|
||||
func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate {
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - message: what the step says it is about. The two callers describe different consequences —
|
||||
/// a restore changes the cards being edited, a switch replaces them — and 06 gives the step to
|
||||
/// both without giving either the other's wording.
|
||||
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
|
||||
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
|
||||
/// into its plan; the switch reverts before it flushes).
|
||||
func settleGate(
|
||||
for ref: BoardWindowRef,
|
||||
message: String = SessionSettleStep.message,
|
||||
didDiscard: @escaping (String) -> Void = { _ in }
|
||||
) -> SessionSettleGate {
|
||||
SessionSettleGate(
|
||||
sessions: { [weak self] in
|
||||
guard let self, let session = self.sessions[ref] else { return [] }
|
||||
@@ -902,17 +995,14 @@ public final class AppModel {
|
||||
cardFolderName: cardRef.cardID,
|
||||
needsSettling: settlement.needsSettling,
|
||||
saveAll: settlement.saveAll,
|
||||
discard: { [weak provider] in
|
||||
discard: {
|
||||
settlement.discard()
|
||||
// The card's uncommitted on-disk saves are reverted by the restore
|
||||
// itself, which compares this folder against the working tree rather
|
||||
// than against HEAD — see `GitRestoreOperation.plan`.
|
||||
provider?.noteDiscarded(cardFolderPath: cardRef.cardID)
|
||||
didDiscard(cardRef.cardID)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
ask: { await SessionSettleStep.ask() },
|
||||
ask: { await SessionSettleStep.ask(message: message) },
|
||||
focus: { [weak self] id in
|
||||
guard let self, let session = self.sessions[ref] else { return }
|
||||
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
|
||||
|
||||
@@ -171,7 +171,26 @@ public struct SessionSettleGate {
|
||||
///
|
||||
/// - Parameter paths: board-root-relative paths the operation would write.
|
||||
public func settle(touching paths: Set<String>) async -> SessionSettleOutcome {
|
||||
let candidates = Self.reached(by: paths, among: sessions()).filter { $0.needsSettling() }
|
||||
await decide(over: Self.reached(by: paths, among: sessions()))
|
||||
}
|
||||
|
||||
/// **Settles every open session, whatever the operation writes** — the branch switch's gate
|
||||
/// (06 ▸ Branch switching).
|
||||
///
|
||||
/// The path filter above is the *restore's* narrowing and belongs to it alone: "a restore
|
||||
/// materializes only the diff between the current tree and the target state, so a card whose open
|
||||
/// Edit session the diff doesn't touch is simply unaffected". A branch switch has no such
|
||||
/// property. It moves the whole tree at once, and the raw-source hazard 06 singles out — "an
|
||||
/// unsettled raw buffer … its Apply later writes the *entire* pre-switch `index.md` byte-for-byte
|
||||
/// onto the new branch's card" — is about the buffer belonging to the old branch, not about
|
||||
/// whether the checkout happened to rewrite that card. So this asks about every session that is
|
||||
/// holding something, and about no path at all.
|
||||
public func settleAll() async -> SessionSettleOutcome {
|
||||
await decide(over: sessions())
|
||||
}
|
||||
|
||||
private func decide(over candidates: [SettleableSession]) async -> SessionSettleOutcome {
|
||||
let candidates = candidates.filter { $0.needsSettling() }
|
||||
guard !candidates.isEmpty else { return .proceed }
|
||||
|
||||
switch await ask() {
|
||||
@@ -232,8 +251,17 @@ public enum SessionSettleStep {
|
||||
Save them, discard the changes, or cancel.
|
||||
"""
|
||||
|
||||
/// The same three buttons, asked for the other caller. **One sentence differs, deliberately**: the
|
||||
/// consequence a user is deciding about is not the same one — a restore would change the cards
|
||||
/// being edited, while a switch takes every card to a different branch — and a step that described
|
||||
/// the wrong operation would be a worse modal than no wording at all.
|
||||
public static let branchSwitchMessage = """
|
||||
Switching branches would replace the cards you are editing. \
|
||||
Save them, discard the changes, or cancel.
|
||||
"""
|
||||
|
||||
@MainActor
|
||||
public static func ask() async -> SessionSettleChoice {
|
||||
public static func ask(message: String = message) async -> SessionSettleChoice {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = title
|
||||
|
||||
Reference in New Issue
Block a user