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
+98 -8
View File
@@ -880,9 +880,90 @@ public final class AppModel {
}
provider.settleSessions = { [weak self, weak provider] paths in
guard let self, let provider else { return .proceed }
return await self.settleGate(for: ref, provider: provider).settle(touching: paths)
let gate = self.settleGate(for: ref) { [weak provider] folder in
// The card's uncommitted on-disk saves are reverted by the restore itself, which
// compares this folder against the working tree rather than against HEAD see
// `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderName: folder)
}
return await gate.settle(touching: paths)
}
provider.seed()
wireBranchSwitching(git: git, store: store, provider: provider, ref: ref)
}
/// **The branch controls' seams** (06-history-undo.md Branch switching) the five things the
/// switch's sequence needs that a repository cannot supply, plus the per-board stamp that makes an
/// interrupted switch recognizable as this app's.
///
/// Wired beside the undo provider's rather than in a place of its own, because the two are the
/// same board's git session seen from two sides and because both must be re-wired on exactly the
/// same event, add-git's commanded mid-session flip (`bindHistoryProvider(for:)`).
private func wireBranchSwitching(
git: HistoryStore,
store: BoardStore,
provider: GitHistoryProvider,
ref: BoardWindowRef
) {
guard let switcher = git.switcher else { return }
let recordID = sessions[ref]?.recordID
switcher.flushPendingCommit = { [weak git] in await git?.committer?.flushNow() }
switcher.isHeld = { [weak git] in git?.committer?.pause != nil }
switcher.suspendCommitting = { [weak git] in git?.committer?.stop() }
switcher.resumeCommitting = { [weak git] in git?.committer?.start() }
// **The undo/redo reseed** the provider's own API, which is the relaunch reseed by
// construction: "discarded and reseeded from the new HEAD's first-parent ancestry redo
// starts empty".
switcher.reseedUndo = { [weak provider] in await provider?.reseed() }
switcher.didSwitch = { [weak git] in await git?.refreshBranch() }
switcher.runBracketed = { [weak store] announcement, work in
guard let store else { return await work() }
try? await store.performWholesale(announcing: announcement) { await work() }
}
switcher.beginProgress = { [weak store] label in
store?.banners.beginOperation(label: label) ?? UUID()
}
switcher.updateProgress = { [weak store] id, label in
store?.banners.updateOperation(id, label: label)
}
switcher.endProgress = { [weak store] id in store?.banners.endOperation(id) }
// The loss row, on `GitHistoryProvider.reportFailure`'s recorded compromise see it for why
// a git failure cannot be a `OneShotBanner` today.
switcher.reportFailure = { [weak store] failure in
store?.banners.postLoss(failure.description)
}
switcher.reportRecovery = { [weak store] message in
store?.banners.postLoss(message)
}
// **The per-board registry is the stamp's home** (`GitOperationStamp`). A session with no
// record a store-level test simply carries no stamp, and recovery then has nothing to
// recognize, which is the honest answer for a board the app has no state for.
switcher.readStamp = { [weak self] in
guard let self, let recordID else { return nil }
return self.boardRegistry.gitOperationStamp(id: recordID)
}
switcher.writeStamp = { [weak self] stamp in
guard let self, let recordID else { return }
self.boardRegistry.setGitOperationStamp(id: recordID, stamp)
}
switcher.settleSessions = { [weak self, weak switcher] in
guard let self, let switcher else { return .proceed }
let gate = self.settleGate(
for: ref,
message: SessionSettleStep.branchSwitchMessage
) { [weak switcher] folder in
switcher?.noteDiscarded(cardFolderName: folder)
}
// Every open session, not the ones a diff reaches see `SessionSettleGate.settleAll`.
return await gate.settleAll()
}
// **The own-leftovers check, at open** (06 Rules Abnormal repo states). Beside the
// committer's start, which is where a pause first becomes knowable, and before anything the
// user does can land on top of a half-finished checkout.
Task { await switcher.recoverInterruptedOperation() }
}
/// **The save-or-discard step for one board**, built from its open card windows
@@ -890,7 +971,19 @@ public final class AppModel {
///
/// Built per ask rather than stored, because its whole content is "which card windows are open
/// right now" a set that changes under any operation slow enough to need the step at all.
func settleGate(for ref: BoardWindowRef, provider: GitHistoryProvider?) -> SessionSettleGate {
///
/// - Parameters:
/// - message: what the step says it is about. The two callers describe different consequences
/// a restore changes the cards being edited, a switch replaces them and 06 gives the step to
/// both without giving either the other's wording.
/// - didDiscard: told each card folder the Discard branch abandoned, so the operation behind the
/// gate can put that folder's uncommitted saves back to HEAD its own way (the restore folds it
/// into its plan; the switch reverts before it flushes).
func settleGate(
for ref: BoardWindowRef,
message: String = SessionSettleStep.message,
didDiscard: @escaping (String) -> Void = { _ in }
) -> SessionSettleGate {
SessionSettleGate(
sessions: { [weak self] in
guard let self, let session = self.sessions[ref] else { return [] }
@@ -902,17 +995,14 @@ public final class AppModel {
cardFolderName: cardRef.cardID,
needsSettling: settlement.needsSettling,
saveAll: settlement.saveAll,
discard: { [weak provider] in
discard: {
settlement.discard()
// The card's uncommitted on-disk saves are reverted by the restore
// itself, which compares this folder against the working tree rather
// than against HEAD see `GitRestoreOperation.plan`.
provider?.noteDiscarded(cardFolderPath: cardRef.cardID)
didDiscard(cardRef.cardID)
}
)
}
},
ask: { await SessionSettleStep.ask() },
ask: { await SessionSettleStep.ask(message: message) },
focus: { [weak self] id in
guard let self, let session = self.sessions[ref] else { return }
guard let cardRef = session.cardRefs.first(where: { $0.cardID == id }) else { return }
+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
+21
View File
@@ -334,6 +334,27 @@ public final class GitAutoCommitter {
arm()
}
// MARK: - The pause, asked for
/// **Re-reads the repository's state without attempting anything** what the popover's git
/// section calls when it appears (06 Rules Abnormal repo states: "the popover's git section
/// names the state plainly").
///
/// The engine learns about a pause by *trying to commit* and being held, which is the right
/// cadence for committing and the wrong one for a surface: a board opened into a detached HEAD
/// would show live branch controls for as long as the debounce takes to fire. This is the same
/// read the flush takes (`GitCommitOperation.reading`), asked by a surface instead of by a write,
/// and it changes nothing else no arming, no retry, no commit.
///
/// A flush landing while this is in flight wins, which is correct: it read the repository later
/// and it read it in order to write.
public func refreshPause() async {
let root = boardRoot
pause = await Task.detached(priority: .userInitiated) {
GitCommitOperation.reading(at: root).pause
}.value
}
/// Whether a card window's folder is currently staged around the stage-around rule, made
/// assertable without reaching into private state.
public var stagedAroundFolders: [URL] {
+342
View File
@@ -0,0 +1,342 @@
import Foundation
import libgit2
import os
// MARK: - Outcome
/// **How a branch operation ended** the four answers 06-history-undo.md gives every app-initiated
/// git operation, in the shape `GitCommitOutcome` already gives the committer's.
///
/// The kinship is deliberate: contention is never an error, a paused repository is a hold rather than
/// a failure, and everything else is a clean failure carrying libgit2's own message ("An operation
/// that fails *cleanly* disk error, refused checkout surfaces as a one-shot banner failure naming
/// the operation and the error, the tree left as it was").
public enum GitBranchOutcome: Sendable, Equatable {
/// HEAD now names this branch and the working tree is its state.
case switched(String)
/// `index.lock` was held. The payload is the lock file's path what an implausibly long wait
/// names (06 Interaction with external writers: "a wait that persists implausibly long names
/// the lock path").
case locked(path: String)
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Branch controls
/// disable in that state, so this is the race a terminal started a merge between the popover
/// rendering and the click landing.
case held(GitRepositoryPause)
/// A clean failure: a refused checkout, an unwritable object store, a name that is not a branch.
/// **The tree is untouched** libgit2's safe checkout either applies wholly or refuses.
case failed(GitOperationFailure)
}
// MARK: - GitBranchOperation
/// **Branch switching and create-and-switch, over the bundled libgit2** (06-history-undo.md Branch
/// switching) the repository half of the operation, with nothing in it that knows about editors,
/// banners, or the undo stack.
///
/// ### The checkout is `SAFE`, and that is the whole safety story
///
/// `git_checkout_tree` with `GIT_CHECKOUT_SAFE` "allows safe updates that cannot overwrite
/// uncommitted data": a working tree carrying changes that conflict with the target refuses the
/// checkout wholesale (`GIT_ECONFLICT`) and leaves every byte where it was. Nothing here ever passes
/// `GIT_CHECKOUT_FORCE` not on the switch, not on the create-and-switch, and not on the
/// own-leftovers abort, which is the one path that could plausibly want it. That is what makes "a
/// refused checkout is a clean one-shot failure, tree untouched" a property of the call rather than a
/// promise, and it is checkable by grepping this file for `FORCE`.
///
/// The caller's contract is the other half: the switch runs on a settled tree open Edit sessions
/// settled explicitly, the pending auto-commit flushed so in practice `SAFE` has nothing to refuse
/// ("checkout runs on a truly settled tree: it cannot fail dirty").
///
/// ### Isolation
///
/// `GitCommitOperation`'s rule, unchanged and for its reason: every function is `nonisolated`, opens
/// its own `git_repository`, and frees it in the same synchronous scope. No handle crosses an
/// `await`, a `Task`, or a stored property.
enum GitBranchOperation {
/// What a failure calls itself on the banner in the user's words, not libgit2's.
static let operationName = "Switching branches"
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// libgit2's global state `GitCommitOperation.startUp`'s twin, and for its reason.
private static let startUp: Bool = {
git_libgit2_init() >= 0
}()
// MARK: - Reads
/// **Every local branch**, sorted the way a menu should list them.
///
/// Local only: remote-tracking branches are 07-sync-collab.md's, and a picker that offered
/// `origin/main` would be offering a detached HEAD precisely the state 06 pauses the whole git
/// surface for.
///
/// An unborn HEAD answers with an empty list, which is honest: `git init` has created no branch
/// yet, only a symbolic ref naming the one the first commit will make.
nonisolated static func localBranches(at boardRoot: URL) -> [String] {
_ = startUp
guard let repository = open(boardRoot) else { return [] }
defer { git_repository_free(repository) }
var iterator: OpaquePointer?
guard git_branch_iterator_new(&iterator, repository, GIT_BRANCH_LOCAL) == 0, let iterator else {
return []
}
defer { git_branch_iterator_free(iterator) }
var names: [String] = []
var reference: OpaquePointer?
var kind = GIT_BRANCH_LOCAL
while git_branch_next(&reference, &kind, iterator) == 0 {
defer {
reference.map(git_reference_free)
reference = nil
}
var name: UnsafePointer<CChar>?
guard git_branch_name(&name, reference) == 0, let name else { continue }
names.append(String(cString: name))
}
return names.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// Whether libgit2 would accept `name` as a branch name `git check-ref-format --branch`'s
/// answer, asked before anything is created so the failure names the input rather than a ref.
nonisolated static func isValidBranchName(_ name: String) -> Bool {
_ = startUp
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
var valid: Int32 = 0
guard git_branch_name_is_valid(&valid, trimmed) == 0 else { return false }
return valid == 1
}
/// Whether a local branch by this name already exists the create path's own refusal, phrased
/// against the name the user typed instead of against libgit2's `GIT_EEXISTS`.
nonisolated static func branchExists(_ name: String, at boardRoot: URL) -> Bool {
localBranches(at: boardRoot).contains(name)
}
/// `.git/index.lock`'s path, for the waiting state that names it.
nonisolated static func indexLockPath(at boardRoot: URL) -> String {
_ = startUp
guard let repository = open(boardRoot) else {
return boardRoot.appendingPathComponent(".git/index.lock").path
}
defer { git_repository_free(repository) }
return gitDirectory(of: repository).appendingPathComponent("index.lock").path
}
// MARK: - The switch
/// **The checkout itself** (06 Branch switching): materialize the branch's tree with the safe
/// strategy, then move HEAD's symbolic ref onto it.
///
/// The order is libgit2's own recommended one and it matters: the checkout's baseline is the
/// *current* HEAD, so the tree is updated against what is actually checked out, and HEAD moves
/// only once the bytes are there. An interruption between the two leaves a tree that matches the
/// target under a HEAD that does not which is exactly the leftover `GitOperationStamp` exists to
/// recognize as the app's own.
///
/// - Parameter allowingPause: whether to proceed against a repository in a pause state. `false`
/// everywhere except the own-leftovers abort, which is 06's one exemption from "the app never
/// mutates repo state it didn't create" see `abort(_:at:)`.
nonisolated static func checkout(
_ branch: String,
at boardRoot: URL,
allowingPause: Bool = false
) -> GitBranchOutcome {
_ = startUp
// The state check runs immediately before the write, never from a caller's earlier read: 06's
// rule is that it runs "again before every flush", and a terminal can start a merge between a
// popover rendering and a click landing.
let reading = GitCommitOperation.reading(at: boardRoot)
if let pause = reading.pause, !allowingPause { return .held(pause) }
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
guard let repository = open(boardRoot) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "this board's repository could not be opened"
))
}
defer { git_repository_free(repository) }
let fullName = "refs/heads/" + branch
var reference: OpaquePointer?
guard git_reference_lookup(&reference, repository, fullName) == 0, let reference else {
return .failed(GitOperationFailure(
operation: operationName,
message: "there is no local branch named '\(branch)'"
))
}
defer { git_reference_free(reference) }
var target: OpaquePointer?
guard git_reference_peel(&target, reference, GIT_OBJECT_COMMIT) == 0, let target else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
defer { git_object_free(target) }
var options = git_checkout_options()
guard git_checkout_options_init(&options, UInt32(GIT_CHECKOUT_OPTIONS_VERSION)) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
// **`SAFE`, never `FORCE`** see the type's note. The value is libgit2's zero, spelled out
// rather than left implicit so the strategy is visible at the point it is chosen.
options.checkout_strategy = GIT_CHECKOUT_SAFE.rawValue
let checked = git_checkout_tree(repository, target, &options)
guard checked == 0 else { return classify(checked, at: boardRoot) }
guard git_repository_set_head(repository, fullName) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
logger.notice("checked out branch \(branch, privacy: .public)")
return .switched(branch)
}
/// **Create-and-switch** (06 Branch switching, and 03-board-ui.md Board popover: "branch
/// switching and creation"): a new branch at the current HEAD, then the ordinary switch onto it.
///
/// **The checkout is not skipped**, though the new branch's tree is HEAD's by construction and the
/// working tree therefore cannot change. The reason is a race the app shares its repository with
/// by design (06 "Two writers, one repository"): an agent's self-commit landing between the
/// branch's creation and the switch moves HEAD, and a `set_head` with no checkout would then leave
/// the working tree describing a commit the new branch does not point at. Running the same
/// checkout every switch runs costs one no-op index write in the ordinary case and is correct in
/// the racing one.
///
/// **An unborn HEAD creates nothing and only moves the symbolic ref** which is exactly what
/// `git checkout -b` does on a repository with no commits: there is no commit to branch from, and
/// the name HEAD points at is the branch the first commit will make (06 Rules Abnormal repo
/// states: "an unborn HEAD is normal git mode").
nonisolated static func createAndSwitch(_ branch: String, at boardRoot: URL) -> GitBranchOutcome {
_ = startUp
let name = branch.trimmingCharacters(in: .whitespacesAndNewlines)
guard isValidBranchName(name) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "'\(branch)' is not a valid branch name"
))
}
guard !branchExists(name, at: boardRoot) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "a branch named '\(name)' already exists"
))
}
let reading = GitCommitOperation.reading(at: boardRoot)
if let pause = reading.pause { return .held(pause) }
if reading.isIndexLocked { return .locked(path: indexLockPath(at: boardRoot)) }
guard let repository = open(boardRoot) else {
return .failed(GitOperationFailure(
operation: operationName,
message: "this board's repository could not be opened"
))
}
if reading.isUnborn {
defer { git_repository_free(repository) }
guard git_repository_set_head(repository, "refs/heads/" + name) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
return .switched(name)
}
var created: OpaquePointer?
let outcome: GitBranchOutcome? = {
defer { git_repository_free(repository) }
guard let head = headCommit(of: repository) else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
defer { git_commit_free(head) }
guard git_branch_create(&created, repository, name, head, 0) == 0 else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
created.map(git_reference_free)
return nil
}()
if let outcome { return outcome }
return checkout(name, at: boardRoot)
}
// MARK: - The app's own leftovers
/// **Aborts an interrupted app-run switch** (06 Rules Abnormal repo states: "The one exemption
/// is the app's own leftovers finding a pause state with a matching stamp, the app **aborts its
/// own unfinished operation** to restore the pre-operation state").
///
/// The abort *is* a checkout back to the branch the stamp recorded the interrupted operation ran
/// forwards, so undoing it is running the same operation backwards. It carries `allowingPause`
/// because the leftover it is clearing is precisely a state that would otherwise refuse; that
/// exemption is the stamp's whole purpose, and it is why nothing else in the app passes the flag.
///
/// **Still `SAFE`, still never `FORCE`.** An abort that overwrote uncommitted work to tidy up
/// would be the app losing the user's bytes on its own initiative and "abort discards nothing"
/// is the design's own promise about it. A refused abort therefore stays refused and says so.
///
/// It deliberately does **not** call `git_repository_state_cleanup`: a branch switch never creates
/// `MERGE_HEAD` or a rebase directory, so a leftover of *that* shape is not this operation's even
/// when a stamp is standing, and removing it would be the never-mutate rule broken in the one
/// place the exemption does not reach. (Recorded as a judgment call; the rebase that can leave one
/// is 07-sync-collab.md's pull, whose own abort will own it.)
nonisolated static func abort(_ stamp: GitOperationStamp, at boardRoot: URL) -> GitBranchOutcome {
guard !stamp.fromBranch.isEmpty else {
return .failed(GitOperationFailure(
operation: operationName,
message: "the interrupted operation recorded no branch to return to"
))
}
return checkout(stamp.fromBranch, at: boardRoot, allowingPause: true)
}
// MARK: - Private plumbing
private static func open(_ boardRoot: URL) -> OpaquePointer? {
guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
var repository: OpaquePointer?
guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
return repository
}
private static func gitDirectory(of repository: OpaquePointer) -> URL {
URL(fileURLWithPath: string(git_repository_path(repository)) ?? "", isDirectory: true)
}
private static func headCommit(of repository: OpaquePointer) -> OpaquePointer? {
var reference: OpaquePointer?
guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
defer { git_reference_free(reference) }
var object: OpaquePointer?
guard git_reference_peel(&object, reference, GIT_OBJECT_COMMIT) == 0 else { return nil }
return object
}
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
pointer.map { String(cString: $0) }
}
private static func lastErrorMessage() -> String {
guard let error = git_error_last(), let message = error.pointee.message else {
return "libgit2 reported no reason"
}
return String(cString: message)
}
/// Turns a libgit2 status into the outcome 06 gives it contention apart from failure, exactly as
/// `GitCommitOperation.classify` does for a commit.
private static func classify(_ status: Int32, at boardRoot: URL) -> GitBranchOutcome {
if status == GIT_ELOCKED.rawValue { return .locked(path: indexLockPath(at: boardRoot)) }
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
}
+475
View File
@@ -0,0 +1,475 @@
import Foundation
import os
// MARK: - GitBranchSwitcher
/// **The branch switch, in the order 06-history-undo.md Branch switching fixes it** settle the
/// editors, flush the pending commit, stamp the intent, check out, reseed undo with every step that
/// needs a window, a store, or a banner arriving as a seam.
///
/// ### Why the sequence is an object rather than a method
///
/// Because five of its six steps belong to somebody else. Settling editors is the card windows'
/// (`SessionSettleGate`), flushing is the committer's, bracketing is the store's, reseeding is the
/// undo provider's, and the in-progress row is the banner strip's and 06 fixes the *order* they run
/// in, which is the one thing none of them can hold. `GitHistoryProvider` is the same shape for the
/// same reason, and its seams are wired from the same place (`AppModel.wireGitUndo`).
///
/// A `nil` seam is always the honest degenerate case rather than a disabled feature: a board with no
/// card windows has nothing to settle, a repository-level test has no store to bracket with, and a
/// board whose popover is closed has no spinner to update. The sequence runs the same way through all
/// of them.
///
/// ### What it deliberately does not do
///
/// Nothing remote. Tracking, ahead/behind, Pull, Push and push-on-commit follow the current branch
/// (06 Branch switching) and are 07-sync-collab.md's own card; this object moves HEAD and tells the
/// undo stack, and the remote half will join by reading the same `didSwitch` seam.
@MainActor
@Observable
public final class GitBranchSwitcher {
/// The board this switches branches on in git mode, the repository's working-tree root.
public let boardRoot: URL
// MARK: - Seams
/// **The save-or-discard step, over every open session** (06 Branch switching: "if any open card
/// window has one the switch presents a save-or-discard step").
///
/// Unlike the undo restore's, this gate is **not** narrowed by a diff. A restore materializes only
/// the paths it changes, so a session the diff never touches is genuinely unaffected; a branch
/// switch moves the whole tree out from under every session at once, and the raw-source hazard 06
/// names "its Apply later writes the *entire* pre-switch `index.md` byte-for-byte onto the new
/// branch's card" does not care whether the checkout touched that card at all. So the seam takes
/// no paths, and `SessionSettleGate.settleAll()` is what production passes.
@ObservationIgnored
public var settleSessions: (@MainActor () async -> SessionSettleOutcome)?
/// The pending auto-commit, flushed once the sessions are settled "with sessions settled, the
/// pending auto-commit flushes (flush-before-overwrite) and checkout runs on a truly settled tree:
/// it cannot fail dirty".
@ObservationIgnored
public var flushPendingCommit: (@MainActor () async -> Void)?
/// Stops and restarts the auto-commit debounce around the checkout, so a timer cannot fire
/// mid-materialization. `GitHistoryProvider`'s pair, for its reason.
@ObservationIgnored
public var suspendCommitting: (@MainActor () -> Void)?
@ObservationIgnored
public var resumeCommitting: (@MainActor () -> Void)?
/// **The undo/redo reseed** (06 Branch switching: "The undo/redo stack does not survive a
/// switch. It is discarded and reseeded from the new HEAD's first-parent ancestry redo starts
/// empty") `GitHistoryProvider.reseed`, which is already exactly that.
@ObservationIgnored
public var reseedUndo: (@MainActor () async -> Void)?
/// The store's wholesale bracket: watcher suspended, one full reload at the end, the board locked
/// read-only if that reload fails (02-architecture.md; `BoardStore.performWholesale(announcing:awaiting:)`).
@ObservationIgnored
public var runBracketed: (@MainActor (_ announcing: String, _ work: @escaping () async -> Void) async -> Void)?
/// The in-progress banner row: begin, relabel (the lock's waiting state), end.
///
/// Three seams rather than one object because the banner is the *store's*, and this type is
/// composed on boards that have none. Relabelling is its own call because a held lock must change
/// what the row says without replacing the row: "contention outlasting the brief retry surfaces as
/// a *waiting* state in the operation's in-progress banner row" the same operation, still
/// running, now explaining itself.
@ObservationIgnored
public var beginProgress: (@MainActor (String) -> UUID)?
@ObservationIgnored
public var updateProgress: (@MainActor (UUID, String) -> Void)?
@ObservationIgnored
public var endProgress: (@MainActor (UUID) -> Void)?
/// A clean failure "surfaces as a one-shot banner failure naming the operation and the error,
/// the tree left as it was" (06 Interaction with external writers).
///
/// The banner rather than the popover's inline caption, deliberately, and 06 draws the line: the
/// popover-anchored answer is for operations that answer *at the form* (add-git, verify-remote),
/// while "the banner enumeration stays the posture for board-wholesale brackets that outlive any
/// one surface" which a branch switch is by construction, since its bracket locks the board and
/// its completion is announced.
@ObservationIgnored
public var reportFailure: (@MainActor (GitOperationFailure) -> Void)?
/// The own-leftovers recovery's banner (`GitOperationStamp.interruptionMessage`).
@ObservationIgnored
public var reportRecovery: (@MainActor (String) -> Void)?
/// The per-board registry's stamp read at open, written before the repository is touched, and
/// cleared when the operation is over (`GitOperationStamp`).
@ObservationIgnored
public var readStamp: (@MainActor () -> GitOperationStamp?)?
@ObservationIgnored
public var writeStamp: (@MainActor (GitOperationStamp?) -> Void)?
/// Whether the git surface is held (`GitAutoCommitter.pause != nil`). The controls disable on it,
/// and this is the pre-flight that keeps a click that raced the render from presenting a modal
/// step for an operation the repository is about to refuse.
@ObservationIgnored
public var isHeld: (@MainActor () -> Bool)?
/// HEAD moved what refreshes the popover's branch line (`HistoryStore.refreshBranch`). Called
/// after a successful switch and after a successful abort, and by nothing else.
@ObservationIgnored
public var didSwitch: (@MainActor () async -> Void)?
// MARK: - Observable state
/// Every local branch, as of the last refresh the picker's contents.
public private(set) var branches: [String] = []
/// Whether a switch is in flight: the controls' disabled state, and the guard that keeps a second
/// click from starting a second checkout.
public private(set) var isSwitching = false
/// The last clean failure, or `nil`. Held beside the banner it is also posted to, so the popover
/// can show what happened while it was open without the banner having to be its only witness.
public private(set) var lastFailure: GitOperationFailure?
/// Folders whose card session the settle step's **Discard** branch just abandoned reverted to
/// HEAD before anything else happens (see `perform`). Filled through `noteDiscarded(cardFolderName:)`,
/// which is how `AppModel`'s gate reports each one.
@ObservationIgnored
private var discardedFolders: Set<String> = []
/// **A settle step discarded this card's session.** "Discard reverts buffers and uncommitted saves
/// to HEAD" the window reverted the buffer, and this is the switch remembering to revert the
/// saves.
public func noteDiscarded(cardFolderName: String) {
discardedFolders.insert(cardFolderName)
}
// MARK: - Tunables
/// The brief, silent backoff: "pull, push, branch switch, and undo restore meeting a held lock
/// wait and retry briefly, silently" (06 Interaction with external writers). The committer's own
/// numbers, for the committer's reason.
@ObservationIgnored
public var lockRetryDelay: Duration = .milliseconds(120)
@ObservationIgnored
public var lockRetryAttempts = 3
/// The cadence the waiting state retries on, once the brief backoff is spent.
@ObservationIgnored
public var lockWaitInterval: Duration = .seconds(1)
/// How long a wait runs before the row names the lock path "a wait that persists implausibly
/// long names the lock path (a crashed writer's leftover is the user's to clear)".
@ObservationIgnored
public var lockPathNamingDelay: Duration = .seconds(5)
/// **The bound on the wait, recorded as a judgment call.** 06 describes a waiting state that
/// retries on its cadence and never becomes an error dialog; it does not say when or whether
/// it gives up. An unbounded wait would hold the board's wholesale bracket, and with it the
/// read-only lock, for as long as a crashed writer's `index.lock` sits on disk, with no way out
/// but quitting. So the wait ends, generously, at a clean failure that names the lock path the
/// tree untouched, the branch unchanged, the banner explaining exactly what to clear. Never a
/// dialog, never a hammer, and never a board wedged by another process's litter.
@ObservationIgnored
public var lockWaitLimit: Duration = .seconds(30)
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
public init(boardRoot: URL) {
self.boardRoot = boardRoot
}
// MARK: - Phrases
/// The in-progress row while the checkout runs (02-architecture.md The banner surface:
/// "Switching to 'main'").
public static func progressLabel(target: String) -> String {
"Switching to '\(target)'…"
}
/// The bracket's completion announcement (10-accessibility.md Live board announcements:
/// "bracketed operations announce once, at completion").
public static func completionAnnouncement(target: String) -> String {
"Switched to branch '\(target)'"
}
/// The waiting state, and the same sentence once the wait is long enough to name what is holding
/// the lock.
public static let waitingLabel = "Waiting for another writer's git lock"
public static func waitingLabel(path: String) -> String {
"\(waitingLabel) (\(path))"
}
// MARK: - Reads
/// Reloads the branch list what the popover's `.task` calls when it appears, and what every
/// completed operation calls for itself.
public func refreshBranches() async {
let root = boardRoot
branches = await Task.detached(priority: .userInitiated) {
GitBranchOperation.localBranches(at: root)
}.value
}
// MARK: - The two operations
/// **Switches to an existing local branch.** Answers whether HEAD actually moved.
@discardableResult
public func switchTo(_ branch: String) async -> Bool {
await perform(target: branch, creating: false)
}
/// **Creates a branch at the current HEAD and switches to it.**
///
/// The full sequence runs settle step included and that is a judgment call, recorded. The card
/// this was built for allows skipping the settle "only if you can prove the tree cannot change",
/// and the proof does not hold: the new branch is created at whatever HEAD is *at that moment*,
/// and this app shares its repository with self-committing agents by design (06 "Two writers,
/// one repository"), so a commit landing between the flush and the create leaves a working tree
/// that the new branch does not describe. Two smaller reasons point the same way the flush puts
/// pending work on the branch it was made on rather than on the branch that did not exist when it
/// was made, and one sequence is one thing to reason about. The step costs nothing when nothing is
/// dirty: the gate never appears unless a session is actually holding unsaved state.
@discardableResult
public func createAndSwitch(to branch: String) async -> Bool {
await perform(target: branch.trimmingCharacters(in: .whitespacesAndNewlines), creating: true)
}
private func perform(target: String, creating: Bool) async -> Bool {
guard !isSwitching, !target.isEmpty else { return false }
// A held repository disables the controls; this is the click that raced the render.
guard isHeld?() != true else { return false }
isSwitching = true
defer { isSwitching = false }
lastFailure = nil
discardedFolders = []
// **a. Settle the editors first explicitly, never silently** (06 Branch switching). Before
// the bracket, because the step is modal and a modal inside a suspended watcher would hold the
// board read-only for as long as the user took to read it.
if let settleSessions {
switch await settleSessions() {
case .cancelled, .failed:
// "Cancel keeps the current branch and the sessions", and a raw buffer that will not
// validate "cancels the whole switch with focus on the offending window, nothing
// half-switched".
discardedFolders = []
return false
case .proceed:
break
}
}
let root = boardRoot
// **a. Discard's second half**: the windows reverted their buffers, and the *uncommitted
// saves* those sessions left on disk go back to HEAD here before the flush, which would
// otherwise commit them the instant the ended session stopped being staged around
// (`GitRestoreOperation.revertToHead`).
let discarded = discardedFolders
discardedFolders = []
if !discarded.isEmpty {
let reverted = await Task.detached(priority: .userInitiated) {
GitRestoreOperation.revertToHead(folderNames: discarded, at: root)
}.value
guard reverted else {
fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "this board's repository could not be read"
))
return false
}
}
// **b. Flush the pending auto-commit** the tree is settled from here on.
await flushPendingCommit?()
// **c. Stamp the intent, before the repository is touched** (06 Rules Abnormal repo
// states). Everything above this line is app-side; everything below can be interrupted.
let head = await Task.detached(priority: .userInitiated) {
GitHistoryWalk.headOID(at: root)
}.value
let current = await Task.detached(priority: .userInitiated) {
GitRepository.branchName(at: root)
}.value
writeStamp?(GitOperationStamp(
fromBranch: current ?? "",
toBranch: target,
headOID: head
))
// **d. The checkout, bracketed** watcher suspended, one full reload at the end, the board
// locked read-only if that reload fails.
let progress = beginProgress?(Self.progressLabel(target: target))
var landed = false
let work: @MainActor () async -> Void = { [weak self] in
guard let self else { return }
self.suspendCommitting?()
defer { self.resumeCommitting?() }
switch await self.runWaitingOutLocks(target: target, creating: creating, progress: progress) {
case .switched:
landed = true
// **e. Reseed undo/redo from the new HEAD**, inside the bracket: the stack must never
// be readable in a state where it describes the branch that is no longer checked out.
await self.reseedUndo?()
case let .failed(failure):
self.fail(failure)
case let .held(pause):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: pause.explanation
))
case let .locked(path):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "another program is still using this repository's index (\(path))"
))
}
}
if let runBracketed {
await runBracketed(Self.completionAnnouncement(target: target), work)
} else {
await work()
}
// The operation is over, whichever way it went: a clean failure left the tree exactly as it
// was, so there is nothing for a later open to abort.
writeStamp?(nil)
if let progress { endProgress?(progress) }
await didSwitch?()
await refreshBranches()
return landed
}
/// The checkout, with 06's lock posture around it: brief silent retries, then a waiting state in
/// the operation's own row, then at `lockWaitLimit` a clean failure naming the lock path.
private func runWaitingOutLocks(
target: String,
creating: Bool,
progress: UUID?
) async -> GitBranchOutcome {
let root = boardRoot
let startedWaiting = ContinuousClock.now
var attempt = 0
var announced = false
var named = false
while true {
let outcome = await Task.detached(priority: .userInitiated) {
creating
? GitBranchOperation.createAndSwitch(target, at: root)
: GitBranchOperation.checkout(target, at: root)
}.value
guard case let .locked(path) = outcome else { return outcome }
attempt += 1
if attempt <= max(0, lockRetryAttempts) {
// Brief and silent: "a held lock is another writer doing its job".
try? await Task.sleep(for: lockRetryDelay)
continue
}
let waited = ContinuousClock.now - startedWaiting
guard waited < lockWaitLimit else {
return .locked(path: path)
}
if !announced, let progress {
updateProgress?(progress, Self.waitingLabel)
announced = true
}
if !named, waited >= lockPathNamingDelay, let progress {
updateProgress?(progress, Self.waitingLabel(path: path))
named = true
}
try? await Task.sleep(for: lockWaitInterval)
}
}
// MARK: - The app's own leftovers
/// **Recovers an interrupted app-run switch, at board open** (06 Rules Abnormal repo states).
///
/// Called once per session, beside the committer's start which is where the pause it is looking
/// for is first knowable, and before any of it reaches a user. Three outcomes, all of
/// `GitOperationRecovery`'s: nothing to do, a stale stamp dropped silently, or the app's own
/// leftover aborted with a banner.
///
/// **A failed abort keeps the stamp**, which is this file's second judgment call. 06 says the app
/// "aborts its own unfinished operation then clears the stamp"; that sentence describes the
/// abort that worked. An abort refused by a conflicting working tree has restored nothing, and
/// clearing the stamp would demote the leftover to somebody else's on the next open the app
/// would then defer forever to an operation only it ever started. So the stamp stands, the failure
/// is surfaced, and the next open tries again.
public func recoverInterruptedOperation() async {
guard let stamp = readStamp?() else { return }
let root = boardRoot
let pause = await Task.detached(priority: .userInitiated) {
GitCommitOperation.reading(at: root).pause
}.value
switch GitOperationRecovery.decide(stamp: stamp, pause: pause) {
case .nothingToDo:
return
case .clearStamp:
writeStamp?(nil)
case let .abort(stamp):
Self.logger.notice("aborting this app's own interrupted branch switch")
var restored = false
let work: @MainActor () async -> Void = { [weak self] in
guard let self else { return }
self.suspendCommitting?()
defer { self.resumeCommitting?() }
let outcome = await Task.detached(priority: .userInitiated) {
GitBranchOperation.abort(stamp, at: root)
}.value
switch outcome {
case .switched:
restored = true
await self.reseedUndo?()
case let .failed(failure):
self.fail(failure)
case let .held(pause):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: pause.explanation
))
case let .locked(path):
self.fail(GitOperationFailure(
operation: GitBranchOperation.operationName,
message: "another program is using this repository's index (\(path))"
))
}
}
if let runBracketed {
await runBracketed(GitOperationStamp.interruptionMessage, work)
} else {
await work()
}
guard restored else { return }
writeStamp?(nil)
reportRecovery?(GitOperationStamp.interruptionMessage)
await didSwitch?()
}
await refreshBranches()
}
// MARK: - Failure
private func fail(_ failure: GitOperationFailure) {
lastFailure = failure
Self.logger.error("branch operation failed: \(failure.description, privacy: .public)")
reportFailure?(failure)
}
}
+49
View File
@@ -629,6 +629,55 @@ enum GitCommitOperation {
)
}
/// **What repo-local config actually says** the two values behind the popover's identity fields,
/// each `nil` when the file does not name it (06 Interaction with external writers).
///
/// Deliberately *not* `userIdentity(at:)`: that answers "who will this commit be by", derived
/// default included, and a field pre-filled with a derived value would turn a placeholder into a
/// value the moment the user typed anywhere else in the popover. The fields show what the file
/// says and nothing more; the derived default is their placeholder.
nonisolated static func repoLocalIdentity(at boardRoot: URL) -> (name: String?, email: String?) {
_ = startUp
guard let repository = open(boardRoot) else { return (nil, nil) }
defer { git_repository_free(repository) }
return GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository))
}
/// **Writes the popover's identity fields into repo-local config** the one write of those keys
/// in the app (`GitConfigFile.writeIdentity`, where the file-format rules live).
///
/// The `.git` directory comes from libgit2 rather than from `boardRoot/.git`, for
/// `userIdentity(at:)`'s reason: a board whose `.git` is a *file* (a linked worktree) has its real
/// config somewhere else, and writing beside the pointer would be writing to nothing.
nonisolated static func writeRepoLocalIdentity(
name: String?,
email: String?,
at boardRoot: URL
) -> Result<Void, GitOperationFailure> {
_ = startUp
let operation = "Saving this board's commit identity"
guard let repository = open(boardRoot) else {
return .failure(GitOperationFailure(
operation: operation,
message: "this board's repository could not be opened"
))
}
defer { git_repository_free(repository) }
do {
try GitConfigFile.writeIdentity(
name: name,
email: email,
inGitDirectory: gitDirectory(of: repository)
)
return .success(())
} catch {
return .failure(GitOperationFailure(
operation: operation,
message: (error as NSError).localizedDescription
))
}
}
private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer<git_signature>? {
var signature: UnsafeMutablePointer<git_signature>?
let now = Date()
+2 -2
View File
@@ -439,8 +439,8 @@ public final class GitHistoryProvider: HistoryProviding {
/// tree rather than against HEAD, so the uncommitted saves the user just chose to lose are
/// reverted by the restore itself rather than by a second pass that could disagree with it
/// (`GitRestoreOperation.plan(at:target:excluding:reconciling:)`).
public func noteDiscarded(cardFolderPath: String) {
discardedFolders.insert(cardFolderPath)
public func noteDiscarded(cardFolderName: String) {
discardedFolders.insert(cardFolderName)
}
// MARK: - The pointer
+136
View File
@@ -175,6 +175,142 @@ enum GitConfigFile {
return (name, email)
}
// MARK: Writing
/// **The popover's identity fields, landing in the file** (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").
///
/// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
/// is the design's own line: the derived default "is passed as an explicit per-commit signature,
/// never written into repo config", because a value the app wrote there would outrank the user's
/// own global `~/.gitconfig` for their terminal commits in that board. What lands here is what the
/// user typed and nothing else.
///
/// **Empty clears the key** rather than writing an empty value the fields show the derived
/// default as a *placeholder*, so an empty field means "no repo-local opinion", which in this file
/// is spelled by the key's absence. A `[user]` section left with nothing in it is removed too, so
/// clearing both fields leaves a config indistinguishable from one the user never edited.
///
/// Everything else in the file survives verbatim: other sections, comments, indentation, and any
/// `[user]` key this app has no opinion about (`signingkey`, say).
static func writeIdentity(
name: String?,
email: String?,
inGitDirectory gitDirectory: URL
) throws {
let configURL = gitDirectory.appendingPathComponent("config")
let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
let updated = applying(name: name, email: email, to: existing)
try Data(updated.utf8).write(to: configURL, options: .atomic)
}
/// The edit, over text the pure half, which is where every rule above is decided and the only
/// half a test needs.
static func applying(name: String?, email: String?, to text: String) -> String {
func cleaned(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else { return nil }
return trimmed
}
// `nil` is "clear this key"; a key absent from the dictionary has already been dealt with.
var pending: [String: String?] = ["name": cleaned(name), "email": cleaned(email)]
// Split on `\n` and rejoin, so the file's own trailing-newline shape survives the round trip
// (`components(separatedBy:)` renders a trailing newline as a final empty element).
var output: [String] = []
/// Whether the lines being read belong to the **plain** `[user]` section. A subsectioned
/// `[user "work"]` is a different scope in git's own model (`user.work.name`, not
/// `user.name`), and editing keys inside one would be this app rewriting a setting the user
/// aimed somewhere else much the worse error, whatever the read side does with it.
///
/// (The read side, `identity(inConfigText:)`, deliberately takes the last matching value it
/// meets whichever section it is in its own recorded call. The two agree in practice for
/// every file this writer has touched, because a plain section it *adds* goes at the end, so
/// its keys are the last ones the reader meets.)
var isPlainUserSection = false
/// Where a key the file does not yet have would be inserted: just after the last line of the
/// plain `[user]` section, or `nil` while there is no such section.
var insertionPoint: Int?
for line in text.isEmpty ? [] : text.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") {
let header = trimmed.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
let section = header
.split(separator: " ", maxSplits: 1)
.first
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
isPlainUserSection = section == "user" && !header.contains("\"")
output.append(line)
if isPlainUserSection { insertionPoint = output.count }
continue
}
let isUserSection = isPlainUserSection
if isUserSection, let separator = trimmed.firstIndex(of: "=") {
let key = trimmed[trimmed.startIndex..<separator]
.trimmingCharacters(in: .whitespaces)
.lowercased()
if let replacement = pending[key] {
pending.removeValue(forKey: key)
if let replacement {
output.append("\t\(key) = \(replacement)")
insertionPoint = output.count
}
// A cleared key simply does not join the output.
continue
}
}
output.append(line)
if isUserSection, insertionPoint != nil, !trimmed.isEmpty { insertionPoint = output.count }
}
// Name before email, always a file this app wrote reads the same whichever field was
// filled first.
let additions = ["name", "email"].compactMap { key -> String? in
guard let value = pending[key] ?? nil else { return nil }
return "\t\(key) = \(value)"
}
if !additions.isEmpty {
if let insertionPoint {
output.insert(contentsOf: additions, at: insertionPoint)
} else {
if let last = output.last, !last.trimmingCharacters(in: .whitespaces).isEmpty {
output.append("")
}
output.append("[user]")
output.append(contentsOf: additions)
output.append("")
}
}
return removingEmptyUserSection(from: output).joined(separator: "\n")
}
/// Drops a `[user]` header with no keys under it what clearing both fields leaves behind, and
/// what a config the user never touched does not have.
private static func removingEmptyUserSection(from lines: [String]) -> [String] {
guard let header = lines.firstIndex(where: {
let trimmed = $0.trimmingCharacters(in: .whitespaces)
return trimmed.lowercased().hasPrefix("[user]")
}) else { return lines }
var end = header + 1
while end < lines.count {
let trimmed = lines[end].trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("[") { break }
if !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix(";") { return lines }
end += 1
}
var kept = lines
kept.removeSubrange(header..<end)
return kept
}
/// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside
/// quotes is content git's own rule, and the one place a naive strip would corrupt a name.
private static func unquoted(_ value: String) -> String {
+110
View File
@@ -0,0 +1,110 @@
import Foundation
// MARK: - GitOperationStamp
/// **The app's declaration that it is about to touch the repository** (06-history-undo.md Rules
/// Abnormal repo states: "every bracketed operation stamps its intent app-side (per-board registry)
/// before touching the repo, so an interrupted app-run rebase or checkout is recognizable as
/// Lanework's").
///
/// ### Why it exists at all
///
/// The app's standing posture toward a repository it finds in a pause state is to **hold and defer**:
/// name the state, disable the controls, and let the tool that created it finish. That posture is
/// correct for every leftover except one the app's own. A checkout interrupted by a crash (or by a
/// volume vanishing mid-flight 02-architecture.md the root-change composition) leaves a repository
/// whose state nobody in a terminal is going to finish, and deferring to a rebase that does not exist
/// would leave the board's git surface paused forever.
///
/// So the app writes down what it is about to do, **before** it does it. Finding a pause state on a
/// later open *with* a matching stamp, it aborts its own unfinished work and says so; finding one
/// without, the pause-and-defer stance is unchanged. The stamp is the only thing that distinguishes
/// the two, which is why it is written before the first byte and cleared after the last.
///
/// ### Where it lives, and why not in the repository
///
/// The **per-board registry** `BoardRecord.gitOperationStamp`, in the app's own Application Support
/// home. Two rules pin it there. Files-first is absolute (02 Per-board app state): "no frontmatter
/// key, no sidecar, no xattr" a marker file in the board folder would be board content, committed by
/// the very operation it describes. And the never-mutate rule forbids the obvious git-shaped home: a
/// file under `.git/` would be app-written repo state, which is exactly what this mechanism exists to
/// keep the app out of.
///
/// A consequence worth stating: the stamp is **per machine**, like every other registry record. A
/// board whose switch was interrupted on one Mac and then opened on another reads as an ordinary
/// unexplained pause hold, name it, defer which is the honest answer, since the second machine
/// genuinely does not know whose leftover it is.
public struct GitOperationStamp: Codable, Sendable, Equatable {
/// Which bracketed operation this stamp is for.
///
/// One case today. It is an enum rather than a bare marker because 06 names the mechanism for
/// "every bracketed operation" and the pull's rebase (07-sync-collab.md) is the next one to stamp;
/// a new case then needs no migration, because an unknown-to-old-builds case never appears in a
/// file an old build wrote.
public enum Kind: String, Codable, Sendable, CaseIterable {
case branchSwitch
}
public let kind: Kind
/// The branch HEAD named **before** the operation where an abort returns to. Empty when the
/// repository had no branch to name (a detached HEAD the switch was starting from, which the
/// paused-surface rule makes unreachable today).
public let fromBranch: String
/// The branch the operation was heading for. Not used by the abort recorded because a recovery
/// that could not say what was interrupted would be a worse diagnostic than one that can.
public let toBranch: String
/// HEAD's commit before the operation, or `nil` on an unborn HEAD. Recorded for the same reason:
/// it is the fact a support question ("what was it doing?") is answered with.
public let headOID: String?
public init(kind: Kind = .branchSwitch, fromBranch: String, toBranch: String, headOID: String?) {
self.kind = kind
self.fromBranch = fromBranch
self.toBranch = toBranch
self.headOID = headOID
}
/// **What the banner says after a successful abort** 06's own sentence, with the app's
/// sentence-shaped capitalization.
public static let interruptionMessage =
"A branch switch was interrupted — the previous state is restored."
}
// MARK: - Recovery
/// **What to do about a stamp found at open** a pure decision, so the mechanism's whole rule is
/// provable without a repository in a broken state.
public enum GitOperationRecovery: Sendable, Equatable {
/// No stamp: the ordinary case, and the one every board is in. The pause-and-defer stance applies
/// unchanged to whatever state the repository happens to be in.
case nothingToDo
/// A stamp, but a repository in a state the app writes in perfectly well. The operation finished
/// and the clear did not land a quit between the two, or a registry write that lost a race so
/// there is nothing to abort and the stamp is stale. Dropping it silently is right: nothing
/// happened that the user needs told about.
case clearStamp
/// A stamp **and** a pause state: the app's own unfinished operation. Abort it, restore the
/// pre-operation state, say so, then clear.
case abort(GitOperationStamp)
/// The whole rule, in one function.
///
/// The conjunction is the point: a pause **without** a stamp is somebody else's operation and the
/// app must not touch it, and a stamp **without** a pause is the app's own finished work. Only
/// both together are "the app's own leftovers".
public static func decide(
stamp: GitOperationStamp?,
pause: GitRepositoryPause?
) -> GitOperationRecovery {
guard let stamp else { return .nothingToDo }
guard pause != nil else { return .clearStamp }
return .abort(stamp)
}
}
+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? {
+82
View File
@@ -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;
+16
View File
@@ -604,6 +604,22 @@ public final class BannerCenter {
return operation.id
}
/// **Relabels a running operation** the same row, still spinning, now saying something else.
///
/// It exists for one sentence in 06-history-undo.md Interaction with external writers:
/// "contention outlasting the brief retry surfaces as a *waiting* state in the operation's
/// in-progress banner row ('waiting for another writer's git lock'), retrying on its cadence".
/// The waiting state is explicitly *the operation's own row*, not a second row and not a
/// replacement the operation has not restarted, it is explaining itself so the id is stable
/// and the view neither churns nor re-animates.
///
/// An unknown id is a no-op: a completion racing a relabel is ordinary, not a bug.
public func updateOperation(_ id: UUID, label: String) {
guard let index = operations.firstIndex(where: { $0.id == id }) else { return }
let existing = operations[index]
operations[index] = InProgressOperation(id: existing.id, label: label, cancel: existing.cancel)
}
/// Ends one the row leaves the strip.
///
/// **Completion and failure both come through here.** There is no `failOperation`: a failure is
+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
+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) }
}
}
+5 -28
View File
@@ -250,7 +250,7 @@ struct BoardInfoView: View {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Git")
if let git {
BoardGitBranchLine(git: git)
BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations)
}
}
.padding(inset)
@@ -368,8 +368,10 @@ enum BoardGitSection: Equatable, CaseIterable {
/// Pro, repo-nested: the honest explanation, no action.
case repoNested
/// Pro, git mode: the read-only branch/source line. Branch switching and creation, the commit
/// identity fields and the remote controls are later cards nothing here is a control.
/// Pro, git mode: the branch/source line with switching and creation, the abnormal-state
/// explanation when the surface is held, and the commit-identity fields (`BoardGitControls`).
/// The remote half tracking, Pull/Push, push-on-commit, authentication is 07-sync-collab.md's
/// own card and joins this same posture.
case branch
static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection {
@@ -437,31 +439,6 @@ private struct BoardGitNestedNote: View {
}
}
/// **The branch/source display** (03-board-ui.md Board popover) read-only, and deliberately the
/// whole of the git-mode section for now: switching, creation, identity and remotes are each their
/// own card, and a control shown before it works is worse than one that isn't there yet.
///
/// The name is read when the popover appears rather than at session composition, so the open path
/// never waits on libgit2 (`HistoryStore.branch`).
private struct BoardGitBranchLine: View {
let git: HistoryStore
var body: some View {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.branch")
.imageScale(.small)
.foregroundStyle(.secondary)
Text(git.branch ?? "")
.font(.callout)
.foregroundStyle(git.branch == nil ? .secondary : .primary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(git.branch.map { "Branch \($0)" } ?? "Reading branch")
.task { await git.refreshBranch() }
}
}
/// The contextual git note **a quiet signpost, not a feature** (12-editions.md The free tier and
/// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git
/// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old