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