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
+354
View File
@@ -0,0 +1,354 @@
import SwiftUI
// MARK: - The git-mode section's state
/// **What the popover's git section shows on a git-mode board** a pure function of four facts, so
/// the surface 06-history-undo.md describes is assertable without a popover on screen.
///
/// It exists for the same reason `BoardGitSection.resolve` does one level up: the *posture* is the
/// part worth pinning, and the SwiftUI that renders it is not. Three rules live here
///
/// - **A paused repository names its state and disables the controls** (06 Rules Abnormal repo
/// states: "Undo/Redo and the branch controls disable the popover's git section names the state
/// plainly and says resolving it belongs to the tool that created it").
/// - **A read-only board disables them too** (02-architecture.md The lock's scope, which names "the
/// popover's git controls" outright).
/// - **A switch in flight disables them**, so a second click cannot start a second checkout.
struct BoardGitBranchSurface: Equatable {
/// What the branch line reads the branch name, the short hash on a detached HEAD
/// (`GitRepository.branchName` decides which), or the placeholder while the first read is in
/// flight.
let branchLabel: String
/// Whether that label is the placeholder rather than an answer.
let isReadingBranch: Bool
/// The pause's own sentence (`GitRepositoryPause.explanation`), or `nil` when the surface is live.
let pauseExplanation: String?
/// Whether the branch picker and the create action accept a click.
let controlsEnabled: Bool
/// The line the branch display is read as by VoiceOver.
var accessibilityLabel: String {
isReadingBranch ? "Reading branch" : "Branch \(branchLabel)"
}
static let placeholder = ""
/// The second half of the paused sentence 06's "says resolving it belongs to the tool that
/// created it", said in the app's own voice and paired with the promise that makes it safe to
/// wait: the app is not going to touch the repository behind the user's back.
static let pauseCaption =
"Finishing it belongs to the tool that started it; Lanework leaves the repository untouched."
static func resolve(
branch: String?,
pause: GitRepositoryPause?,
isSwitching: Bool,
isWritable: Bool
) -> BoardGitBranchSurface {
BoardGitBranchSurface(
branchLabel: branch ?? placeholder,
isReadingBranch: branch == nil,
pauseExplanation: pause?.explanation,
controlsEnabled: pause == nil && isWritable && !isSwitching
)
}
}
// MARK: - The git-mode section
/// **The popover's git section on a board that has a repository** (03-board-ui.md Board popover;
/// 06-history-undo.md Branch switching, Interaction with external writers).
///
/// Three surfaces, in the order the design lists them: the branch display with switching and
/// creation, the pause explanation when there is one, and the commit-identity fields.
///
/// **Shaped for the half that is not here yet.** Remote tracking, Pull/Push, push-on-commit and the
/// authentication surface are 07-sync-collab.md's own cards, and this section is arranged so they
/// join as one more block between the branch controls and the identity fields nothing here is
/// nested inside anything they would have to be pulled out of, and nothing about the branch controls
/// assumes there is no upstream to show beside them.
struct BoardGitControls: View {
let git: HistoryStore
/// The read-only lock's reach (02-architecture.md The lock's scope): a board that refuses writes
/// refuses a checkout most of all it rewrites the tree the lock exists to stop describing.
let isEnabled: Bool
@State private var isNaming = false
@State private var draftBranch = ""
private var surface: BoardGitBranchSurface {
BoardGitBranchSurface.resolve(
branch: git.branch,
pause: git.committer?.pause,
isSwitching: git.switcher?.isSwitching ?? false,
isWritable: isEnabled
)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
branchRow
if isNaming {
newBranchField
}
if let explanation = surface.pauseExplanation {
pauseNote(explanation)
}
if let failure = git.switcher?.lastFailure {
caption(failure.message, tone: .red)
}
BoardGitIdentityFields(git: git, isEnabled: isEnabled)
}
// Every read the section needs, taken when it appears rather than held live: the popover is
// built fresh on each open (`BoardInfoWidget`), and none of these is a fact the board's
// watcher could deliver `.git` is filtered out of the watch by design.
.task {
await git.refreshBranch()
await git.committer?.refreshPause()
await git.switcher?.refreshBranches()
}
}
// MARK: The branch line
/// The branch display and the switch, as one control: the line *is* the picker, which is what
/// makes "branch/source display, branch switching and creation" one affordance rather than a label
/// with a button beside it.
private var branchRow: some View {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.branch")
.imageScale(.small)
.foregroundStyle(.secondary)
Menu {
ForEach(otherBranches, id: \.self) { name in
Button(name) {
Task { await git.switcher?.switchTo(name) }
}
}
if !otherBranches.isEmpty {
Divider()
}
Button("New Branch…") {
draftBranch = ""
isNaming = true
}
} label: {
Text(surface.branchLabel)
.font(.callout)
.foregroundStyle(surface.isReadingBranch ? .secondary : .primary)
}
.menuStyle(.borderlessButton)
.fixedSize()
.disabled(!surface.controlsEnabled)
.accessibilityLabel(surface.accessibilityLabel)
.accessibilityHint("Switch branches or create a branch")
if git.switcher?.isSwitching == true {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Switching branches")
}
}
}
/// Every local branch except the one already checked out a picker offering the current branch
/// would be offering a no-op, and the switch refuses one anyway.
private var otherBranches: [String] {
(git.switcher?.branches ?? []).filter { $0 != git.branch }
}
// MARK: Create and switch
/// Named inline rather than in a sheet: the popover is where the operation was asked for, and a
/// sheet over a transient popover would dismiss the surface it came from.
private var newBranchField: some View {
HStack(spacing: 6) {
TextField("New branch name", text: $draftBranch)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.onSubmit { create() }
// Escape steps outward one layer (04-interactions.md Grammar): it abandons the
// naming rather than dismissing the popover under it.
.onKeyPress(.escape) {
isNaming = false
draftBranch = ""
return .handled
}
Button("Create", action: create)
.disabled(trimmedDraft.isEmpty)
}
.disabled(!surface.controlsEnabled)
}
private var trimmedDraft: String {
draftBranch.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func create() {
let name = trimmedDraft
guard !name.isEmpty else { return }
isNaming = false
draftBranch = ""
Task { await git.switcher?.createAndSwitch(to: name) }
}
// MARK: The pause
/// **The abnormal-state surface** (06 Rules Abnormal repo states) deferred here from the
/// auto-commit card, which built the hold this explains.
///
/// Two sentences, both of them the design's: what the repository is doing, and whose job it is to
/// finish. Never a Repair button "the app never mutates repo state it didn't create".
private func pauseNote(_ explanation: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(explanation)
.font(.caption)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Text(BoardGitBranchSurface.pauseCaption)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.accessibilityElement(children: .combine)
}
private func caption(_ text: String, tone: Color) -> some View {
Text(text)
.font(.caption)
.foregroundStyle(tone)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Commit identity
/// **The name and email that repo-local `.git/config` carries** (06-history-undo.md Interaction
/// with external writers: "The board popover's git section exposes name/email fields that write that
/// repo-local config the setting *is* the file, portable to any git client, per-board by nature").
///
/// ### The placeholder is the whole of the identity rule made visible
///
/// An empty field shows the **derived default** the macOS account's full name and
/// `shortname@hostname` as a placeholder, never as a value. That is the difference between "this
/// repository says nothing, so the app signs commits with a sensible guess" and "this repository says
/// this", and the file is where the difference lives: 06 forbids the app writing its own derived
/// value into config, because it would then outrank the user's global `~/.gitconfig` for their own
/// terminal commits in that board. A field pre-filled with the derived value would write it on the
/// first focus loss.
///
/// ### The dirty-buffer courtesy, copied from `BoardRenameField`
///
/// A foreign config edit landing while the popover is open updates an *unfocused* field and never a
/// focused one: "a focused field keeps the user's keystrokes" (03-board-ui.md Board popover). The
/// trigger is a poll rather than a reload, and that is honest rather than lazy: `FolderWatcher`
/// filters `.git` out of the watch by design, so no board event can ever carry a config change, and
/// the alternative to a small periodic read is a field that is stale for as long as the popover
/// stays open. The poll lives and dies with this view.
private struct BoardGitIdentityFields: View {
let git: HistoryStore
let isEnabled: Bool
@State private var name = ""
@State private var email = ""
@FocusState private var focused: Field?
private enum Field: Hashable {
case name
case email
}
/// How often an open popover re-reads the config file. Slow enough to be free, fast enough that a
/// terminal `git config user.email ` shows up while the user is still looking at the popover.
private static let pollInterval: Duration = .seconds(2)
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Divider()
Text("Commit Identity")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
field("Name", text: $name, placeholder: git.derivedIdentity?.name ?? "", tag: .name)
field("Email", text: $email, placeholder: git.derivedIdentity?.email ?? "", tag: .email)
if let failure = git.identityFailure {
Text(failure.message)
.font(.caption)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
.task {
// The first read, then the courtesy poll. Cancellation is the view's disappearance, which
// is the popover closing.
while !Task.isCancelled {
await git.refreshIdentity()
try? await Task.sleep(for: Self.pollInterval)
}
}
.onAppear {
name = git.identityName
email = git.identityEmail
}
.onChange(of: git.identityName) { _, value in
guard focused != .name else { return }
name = value
}
.onChange(of: git.identityEmail) { _, value in
guard focused != .email else { return }
email = value
}
// A dismissal is a commit like any other click-away `BoardRenameField`'s rule, and the same
// idempotence makes the overlap harmless.
.onDisappear { commit() }
}
private func field(
_ label: String,
text: Binding<String>,
placeholder: String,
tag: Field
) -> some View {
HStack(spacing: 6) {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .leading)
TextField(placeholder, text: text)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($focused, equals: tag)
.onSubmit { commit() }
.disabled(!isEnabled)
.accessibilityLabel("Commit \(label.lowercased())")
}
.onChange(of: focused) { previous, _ in
// Focus leaving *this* field is this field's commit the inline editors' exit, applied
// to a form where Tab moves between two of them.
guard previous == tag else { return }
commit()
}
}
/// Writes both fields, and only when one of them differs from what the file says an unchanged
/// value must not rewrite `.git/config` every time the popover closes.
private func commit() {
guard isEnabled else { return }
guard name != git.identityName || email != git.identityEmail else { return }
Task { await git.writeIdentity(name: name, email: email) }
}
}