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
+118 -28
View File
@@ -61,12 +61,13 @@ public struct GitRestorePlan: Sendable, Equatable {
/// - **`excluding`** the heal-transparency rule's second half (06 Rules Heal commits are
/// transparent to undo): "a restore materializing an older target **excludes paths whose divergence
/// is heal work**, so a Z run never reverts a repair and never summons the scheduler."
/// - **`reconciling`** the folders of card sessions the user chose to **Discard** at the
/// save-or-discard step (06 Branch switching: "Discard reverts buffers and uncommitted saves to
/// HEAD"). Those folders are compared against the **working tree** rather than against HEAD,
/// because their uncommitted on-disk saves are precisely the state HEAD does not have one pass
/// that both drops the discarded saves and applies the restore, instead of a revert followed by a
/// restore that would have to agree with it.
/// - **`reconciling`** the card sessions the user chose to **Discard** at the save-or-discard step
/// (06 Branch switching: "Discard reverts buffers and uncommitted saves to HEAD"). Those folders
/// are compared against the **working tree** rather than against HEAD, because their uncommitted
/// on-disk saves are precisely the state HEAD does not have one pass that both drops the
/// discarded saves and applies the restore, instead of a revert followed by a restore that would
/// have to agree with it. They arrive as folder **names**, not paths; see `folderPaths(named:at:)`
/// for why that distinction is the difference between the rule working and silently not.
///
/// ### Isolation
///
@@ -96,18 +97,26 @@ enum GitRestoreOperation {
/// - Parameters:
/// - target: the oid of the commit whose state is being restored.
/// - excluding: board-root-relative paths whose divergence is heal work never materialized.
/// - reconciling: board-root-relative folders compared against the working tree rather than
/// against HEAD (the Discard branch of the save-or-discard step).
/// - reconciling: card **folder names** the ids `SettleableSession.cardFolderName` carries
/// whose folders are compared against the working tree rather than against HEAD (the Discard
/// branch of the save-or-discard step). Resolved to real paths here, once, by the resolver
/// both callers share.
nonisolated static func plan(
at boardRoot: URL,
target: String,
excluding: Set<String> = [],
reconciling: Set<String> = []
reconciling folderNames: Set<String> = []
) -> GitRestorePlan? {
_ = startUp
guard let repository = open(boardRoot) else { return nil }
defer { git_repository_free(repository) }
// **Names in, paths out the one resolution both Discard paths take** (the undo restore's,
// and the branch switch's `revertToHead`). A card's folder name is its id; its *path* is
// `<lane>/<id>`, and every live card has a lane above it, so treating the name as a path
// matched nothing at all and made the whole Discard branch silently inert.
let reconciling = folderPaths(named: folderNames, at: boardRoot)
guard let targetTree = tree(of: target, in: repository) else { return nil }
defer { git_tree_free(targetTree) }
var wanted: [String: git_oid] = [:]
@@ -165,25 +174,8 @@ enum GitRestoreOperation {
_ = startUp
guard !plan.isEmpty else { return .nothingToCommit }
let manager = FileManager.default
for change in plan.changes {
let url = boardRoot.appendingPathComponent(change.path)
guard let contents = change.contents else {
try? manager.removeItem(at: url)
pruneEmptyFolders(above: url, upTo: boardRoot)
continue
}
let folder = url.deletingLastPathComponent()
do {
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
try contents.write(to: url, options: .atomic)
} catch {
logger.error("restore could not write \(change.path, privacy: .public)")
return .failed(GitOperationFailure(
operation: operationName,
message: (error as NSError).localizedDescription
))
}
if let failure = materialize(plan, at: boardRoot) {
return .failed(failure)
}
let identity = GitCommitOperation.userIdentity(at: boardRoot)
@@ -200,6 +192,104 @@ enum GitRestoreOperation {
)
}
/// **The writes, without the commit** the plan materialized onto disk. `nil` means every change
/// landed.
///
/// Split out of `apply` for the branch switch's Discard branch (`revertToHead(folders:at:)`),
/// which needs the bytes moved and emphatically does *not* want a commit attempted over them.
nonisolated static func materialize(_ plan: GitRestorePlan, at boardRoot: URL) -> GitOperationFailure? {
let manager = FileManager.default
for change in plan.changes {
let url = boardRoot.appendingPathComponent(change.path)
guard let contents = change.contents else {
try? manager.removeItem(at: url)
pruneEmptyFolders(above: url, upTo: boardRoot)
continue
}
let folder = url.deletingLastPathComponent()
do {
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
try contents.write(to: url, options: .atomic)
} catch {
logger.error("restore could not write \(change.path, privacy: .public)")
return GitOperationFailure(
operation: operationName,
message: (error as NSError).localizedDescription
)
}
}
return nil
}
/// **"Discard reverts buffers and uncommitted saves to HEAD"** (06-history-undo.md Branch
/// switching) the *uncommitted saves* half, for the operation that has no restore plan to fold
/// it into.
///
/// An undo restore reconciles a discarded card's folder inside its own plan, because it is
/// materializing a target state anyway and one pass that does both cannot disagree with itself. A
/// branch switch materializes nothing libgit2's checkout does the moving so the discard has to
/// be its own step, and it has to run **before** the pending auto-commit is flushed: `discard`
/// ends the Edit session, which un-stages-around the card's folder, so a flush over a folder still
/// holding those saves would commit exactly the text the user just asked to lose.
///
/// It is expressed as a restore *to HEAD* with the folders reconciled against the working tree,
/// which is the same machinery under a different target: every path outside those folders compares
/// HEAD against HEAD and produces nothing, and inside them the working tree's own files are what
/// the plan replaces. Nothing is committed by construction there is nothing new to commit, since
/// the tree afterwards is HEAD's.
///
/// Answers whether the revert ran cleanly; `false` is a repository that could not be read, which
/// the caller reports as its operation's clean failure.
///
/// - Parameter folderNames: card **folder names** the ids `SettleableSession.cardFolderName`
/// carries, not paths. Resolved against the tree here for that property's own reason: "a card's
/// own folder component never changes, only the lane above it", so a session that began before a
/// lane move is still matched afterwards.
nonisolated static func revertToHead(folderNames: Set<String>, at boardRoot: URL) -> Bool {
_ = startUp
guard !folderNames.isEmpty else { return true }
guard let head = GitHistoryWalk.headOID(at: boardRoot) else { return false }
// A card whose folder is not on disk resolves to nothing, plans nothing, and writes nothing:
// it was deleted, or it never existed, and either way there are no uncommitted saves to
// revert.
guard let plan = plan(at: boardRoot, target: head, reconciling: folderNames) else { return false }
return materialize(plan, at: boardRoot) == nil
}
/// **Board-root-relative paths of every folder whose last component is one of `names`** the one
/// place a card id becomes a place on disk.
///
/// Component-exact, which is the same match `SessionSettleGate` uses to decide *which* sessions an
/// operation reaches (`GitHistoryWalk.path(_:isInsideFolderNamed:)`) and it is chosen for that
/// rule's own reason: "a card's own folder component never changes, only the lane above it", so a
/// session that began before a lane move is still found afterwards. Matching a name as a path
/// prefix instead is what made the Discard branch inert a bug this resolver exists to make
/// unrepeatable, since both callers now go through it.
///
/// `.git` is never walked it is not part of any board's tree, and nothing here may write into
/// it.
private static func folderPaths(named names: Set<String>, at boardRoot: URL) -> Set<String> {
guard let walker = FileManager.default.enumerator(
at: boardRoot,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsPackageDescendants]
) else { return [] }
var found: Set<String> = []
for case let url as URL in walker {
let name = url.lastPathComponent
if name == ".git" {
walker.skipDescendants()
continue
}
guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true,
names.contains(name),
let relative = relativePath(of: url, under: boardRoot) else { continue }
found.insert(relative)
}
return found
}
// MARK: - Private plumbing
private static func open(_ boardRoot: URL) -> OpaquePointer? {