Files
lanework/Kanban/Git/GitBranchOperation.swift
T
rzen 1f7d84bf64 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
2026-07-31 16:41:14 -04:00

343 lines
17 KiB
Swift

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()))
}
}