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:
@@ -78,6 +78,43 @@ public final class HistoryStore {
|
||||
/// a test, a storeless consumer — therefore has a committer that never runs.
|
||||
public private(set) var committer: GitAutoCommitter?
|
||||
|
||||
/// **The branch controls** (06-history-undo.md ▸ Branch switching), or `nil` on a board there is
|
||||
/// no repository to switch branches in.
|
||||
///
|
||||
/// Its existence is exactly `mode == .git`, the committer's rule for the committer's reason — and
|
||||
/// like the committer it is composed inert: the seams that make it a *sequence* (the settle step,
|
||||
/// the store's bracket, the undo reseed, the banner strip) arrive from the session, and a
|
||||
/// `HistoryStore` built without one has a switcher that can list branches and nothing else.
|
||||
public private(set) var switcher: GitBranchSwitcher?
|
||||
|
||||
// MARK: - Commit identity
|
||||
|
||||
/// **What repo-local `.git/config` says right now** — the popover's two fields, as values rather
|
||||
/// than as a resolved identity (06 ▸ 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").
|
||||
///
|
||||
/// Empty means the file names no such key, which is what an empty field means: the derived default
|
||||
/// applies, shown as the field's *placeholder*. Filling the field in with the derived value would
|
||||
/// be the app writing its own guess into the user's repository the first time they edited anything
|
||||
/// else in the popover — the exact thing 06 rules out.
|
||||
public private(set) var identityName = ""
|
||||
|
||||
public private(set) var identityEmail = ""
|
||||
|
||||
/// **The derived default**, for the placeholders — `nil` until `refreshIdentity()` has run.
|
||||
///
|
||||
/// Deliberately not computed at composition: `GitIdentity.derivedDefault()` reads
|
||||
/// `ProcessInfo.hostName`, which can block on a machine whose name resolution is slow, and the
|
||||
/// board-open path is where 02-architecture.md's hang-avoidance doctrine is strictest. It is read
|
||||
/// off the main actor with the config, when the popover asks.
|
||||
public private(set) var derivedIdentity: GitIdentity?
|
||||
|
||||
/// The last identity-write failure, surfaced as an inline caption in the popover beside the fields
|
||||
/// — 06's popover-anchored posture ("the user asked from a form still under their eye"), which is
|
||||
/// exactly where `lastFailure` above already puts add-git's.
|
||||
public private(set) var identityFailure: GitOperationFailure?
|
||||
|
||||
/// The board's write-provenance ledger, held so an add-git flip can build a committer over the
|
||||
/// same one the session's store owns.
|
||||
@ObservationIgnored
|
||||
@@ -108,6 +145,7 @@ public final class HistoryStore {
|
||||
self.ledger = ledger
|
||||
if mode == .git {
|
||||
committer = GitAutoCommitter(boardRoot: boardRoot, ledger: ledger)
|
||||
switcher = GitBranchSwitcher(boardRoot: boardRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +239,9 @@ public final class HistoryStore {
|
||||
self.committer = committer
|
||||
autoCommitWiring?(committer)
|
||||
committer.start()
|
||||
// The branch controls appear with the repository they switch branches in — and before
|
||||
// `didAddGit`, which is what wires their seams (`AppModel.wireGitUndo`).
|
||||
switcher = GitBranchSwitcher(boardRoot: root)
|
||||
// Last, after the mode and the committer: the undo binding reads both.
|
||||
didAddGit?()
|
||||
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
|
||||
@@ -222,6 +263,47 @@ public final class HistoryStore {
|
||||
}.value
|
||||
}
|
||||
|
||||
// MARK: - Commit identity
|
||||
|
||||
/// Reads repo-local config and the derived default into the popover's fields. A no-op outside git
|
||||
/// mode, `refreshBranch()`'s rule.
|
||||
///
|
||||
/// Both reads run off the main actor: one opens a repository, the other asks the system for the
|
||||
/// account and host names.
|
||||
public func refreshIdentity() async {
|
||||
guard mode == .git else { return }
|
||||
let root = boardRoot
|
||||
let read = await Task.detached(priority: .userInitiated) {
|
||||
(
|
||||
repoLocal: GitCommitOperation.repoLocalIdentity(at: root),
|
||||
derived: GitIdentity.derivedDefault()
|
||||
)
|
||||
}.value
|
||||
identityName = read.repoLocal.name ?? ""
|
||||
identityEmail = read.repoLocal.email ?? ""
|
||||
derivedIdentity = read.derived
|
||||
}
|
||||
|
||||
/// **Writes the fields into repo-local `.git/config`** — "the setting *is* the file".
|
||||
///
|
||||
/// An empty value clears its key rather than writing an empty string, which is what the
|
||||
/// placeholder promises: an empty field means the derived default applies. The read afterwards is
|
||||
/// not ceremony — it is how the fields end up showing what the file says rather than what was
|
||||
/// typed at it, which is the only version that survives a foreign edit landing in between.
|
||||
public func writeIdentity(name: String, email: String) async {
|
||||
guard mode == .git else { return }
|
||||
let root = boardRoot
|
||||
identityFailure = nil
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
GitCommitOperation.writeRepoLocalIdentity(name: name, email: email, at: root)
|
||||
}.value
|
||||
if case let .failure(failure) = outcome {
|
||||
identityFailure = failure
|
||||
Self.logger.error("identity write failed: \(failure.description, privacy: .public)")
|
||||
}
|
||||
await refreshIdentity()
|
||||
}
|
||||
|
||||
// MARK: - The loader's history seam
|
||||
|
||||
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md ▸ Fractal layout ▸ Rules;
|
||||
|
||||
Reference in New Issue
Block a user