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
+36 -1
View File
@@ -158,6 +158,16 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
/// `UserDefaults`.
public var remoteLocationWarned: Bool
/// **A bracketed git operation this app started and has not finished** (06-history-undo.md Rules
/// Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board
/// registry) before touching the repo").
///
/// `nil` for every board that is not mid-operation, which is every board almost all of the time:
/// the stamp is written immediately before the repository is touched and cleared as soon as the
/// operation is over, so finding one at open means the app died in between. See
/// `GitOperationStamp` for why it lives here rather than in the board folder or under `.git`.
public var gitOperationStamp: GitOperationStamp?
public init(
id: UUID = UUID(),
bookmark: Data,
@@ -172,7 +182,8 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
pushOnCommit: Bool = false,
remoteLocationWarned: Bool = false,
icon: String? = nil,
iconColor: String? = nil
iconColor: String? = nil,
gitOperationStamp: GitOperationStamp? = nil
) {
self.id = id
self.bookmark = bookmark
@@ -188,6 +199,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
self.remoteLocationWarned = remoteLocationWarned
self.icon = icon
self.iconColor = iconColor
self.gitOperationStamp = gitOperationStamp
}
// MARK: Codable
@@ -215,6 +227,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
case iconColor
case pushOnCommit
case remoteLocationWarned
case gitOperationStamp
}
public init(from decoder: any Decoder) throws {
@@ -233,6 +246,11 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
iconColor = try container.decodeIfPresent(String.self, forKey: .iconColor)
pushOnCommit = try container.decodeIfPresent(Bool.self, forKey: .pushOnCommit) ?? false
remoteLocationWarned = try container.decodeIfPresent(Bool.self, forKey: .remoteLocationWarned) ?? false
// Tolerant twice over: absent on every record written before this key existed, and absent
// again rather than fatal if a future build's stamp `Kind` is one this build cannot name.
// A stamp that cannot be read is a stamp that cannot recover anything, which degrades to the
// pause-and-defer stance rather than to a quarantined registry.
gitOperationStamp = try? container.decodeIfPresent(GitOperationStamp.self, forKey: .gitOperationStamp)
}
public func encode(to encoder: any Encoder) throws {
@@ -251,6 +269,7 @@ public struct BoardRecord: Codable, Sendable, Equatable, Identifiable {
try container.encodeIfPresent(iconColor, forKey: .iconColor)
try container.encode(pushOnCommit, forKey: .pushOnCommit)
try container.encode(remoteLocationWarned, forKey: .remoteLocationWarned)
try container.encodeIfPresent(gitOperationStamp, forKey: .gitOperationStamp)
// An unknown key is dropped, exactly as the synthesized conformance dropped it: the file's
// forward tolerance is a decoding property, and nothing here preserves what it cannot read.
}
@@ -580,6 +599,22 @@ public final class BoardRegistry {
update(id) { $0.remoteLocationWarned = true }
}
/// **Records or clears the bracketed git operation this app is about to run**
/// (`GitOperationStamp`).
///
/// It saves the file synchronously like every other setter here, and that is load-bearing rather
/// than incidental: the whole value of the stamp is that it is on disk *before* the repository is
/// touched, so a crash a millisecond later is still recognizable as this app's. `save()` writes
/// atomically, so the file a next launch reads is either the old one or this one.
public func setGitOperationStamp(id: UUID, _ stamp: GitOperationStamp?) {
update(id) { $0.gitOperationStamp = stamp }
}
/// The stamp this board is carrying, if any read once per session, at open.
public func gitOperationStamp(id: UUID) -> GitOperationStamp? {
record(id: id)?.gitOperationStamp
}
// MARK: - Reading
/// Every known board, most recently opened first, each classified by whether its bookmark