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
+30 -2
View File
@@ -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