Files
lanework/Kanban/Git/GitCommitOperation.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

789 lines
39 KiB
Swift

import Foundation
import libgit2
import os
// MARK: - Repository state
/// **A repo state the auto-committer holds for** (06-history-undo.md ▸ Rules ▸ Abnormal repo
/// states): "a detached HEAD, or an in-progress merge/rebase/cherry-pick left by outside-the-app
/// git … pauses the git surface honestly … auto-commit holds".
///
/// **Unborn HEAD is deliberately absent.** It is *normal* git mode — "the first auto-commit creates
/// the root commit on the branch HEAD names, and the undo trail simply starts empty" — so it is a
/// fact about how the next commit is shaped (`GitRepository.initialCommitSubject`), never a reason
/// to stop.
///
/// The cases are libgit2's own `git_repository_state`, which reads exactly the marker files 06
/// names (`MERGE_HEAD`, `rebase-merge/`, `rebase-apply/`, `CHERRY_PICK_HEAD`) plus the two this
/// version has no story for but must not commit over either (`REVERT_HEAD`, `BISECT_LOG`).
public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
case detachedHead
case merge
case revert
case cherryPick
case bisect
case rebase
case applyMailbox
/// What the popover will say — **the branch-switching card's surface, phrased here** so the
/// engine-side hold and the sentence that explains it cannot drift apart (06 ▸ Rules ▸ Abnormal
/// repo states: "the popover's git section names the state plainly … and says resolving it
/// belongs to the tool that created it").
public var explanation: String {
switch self {
case .detachedHead: "HEAD is detached — commits would belong to no branch"
case .merge: "a merge is in progress"
case .revert: "a revert is in progress"
case .cherryPick: "a cherry-pick is in progress"
case .bisect: "a bisect is in progress"
case .rebase: "a rebase is in progress"
case .applyMailbox: "a patch application is in progress"
}
}
}
/// What one look at the repository found, before any staging is attempted.
public struct GitRepositoryReading: Sendable, Equatable {
/// The pause 06 holds for, or `nil` when the repository is in a state the committer may write in.
public let pause: GitRepositoryPause?
/// Whether HEAD names a branch that has no commits yet — normal git mode, and the one thing that
/// makes the next commit a root commit.
public let isUnborn: Bool
/// Whether `index.lock` is held right now. Read as a file rather than inferred from a failure so
/// the committer can back off *before* it has written anything (06 ▸ Interaction with external
/// writers: "`index.lock` contention is never an error").
public let isIndexLocked: Bool
public init(pause: GitRepositoryPause?, isUnborn: Bool, isIndexLocked: Bool) {
self.pause = pause
self.isUnborn = isUnborn
self.isIndexLocked = isIndexLocked
}
}
// MARK: - Changed paths
/// One path `git status` reports as differing between HEAD and the working tree.
///
/// Board-root-relative and file-granular, which is the unit both consumers want: staging adds or
/// removes exactly these, and attribution asks a question per *file* (06 ▸ Interaction with external
/// writers: "classify every observed change, per file").
public struct GitChangedPath: Sendable, Equatable, Hashable {
/// The path, relative to the board root, in git's own spelling (`/` separators, no leading dot).
public let path: String
/// Whether the file is **gone** from the working tree.
///
/// The `modified-by` rule turns on this bit — "any true deletion in the window falls back to
/// `Lanework External` — a deletion leaves no file to stamp" — which is why the rename half
/// below is a separate fact rather than folded in here.
public let isDeletion: Bool
/// Whether this path is one end of a **rename** libgit2 paired up.
///
/// "**A folder move is not a deletion**: items match by id across the whole board … so a moved
/// card attributes by its stamp like any changed file" (06). A paired departure is therefore a
/// deletion on disk that the window must not be demoted by.
public let isRename: Bool
/// Whether the path is **new in this commit** — git's own `GIT_DELTA_ADDED` (and a rename's
/// arriving end), surfaced rather than inferred.
///
/// It exists for the comment verb family (01-storage-format.md § Enhanced schema): comments are
/// window-scoped and the board snapshot never carries them, so "Comment on 'X'" and "Edit comment
/// on 'X'" cannot be told apart by a diff of two snapshots — the only thing that distinguishes a
/// comment folder arriving from one being rewritten is whether HEAD already had it, which is
/// exactly the question this diff already answered.
public let isArrival: Bool
public init(path: String, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
self.path = path
self.isDeletion = isDeletion
self.isRename = isRename
self.isArrival = isArrival
}
}
// MARK: - A planned commit
/// **Which of 06's classes a planned commit belongs to** — carried through the libgit2 work so a
/// landed commit can be recognized by the class that planned it.
///
/// It exists for one consumer: the undo provider's **heal transparency** (06-history-undo.md ▸ Rules
/// ▸ Heal commits are transparent to undo, in-session: "heal-class commits — their paths known by the
/// Writer's heal-marked receipts — never become undo steps"). Receipts live on the main actor and are
/// cleared the moment a window commits, so the only way the stack can ever learn *which commit* was
/// the heal is to be told at the moment it lands.
///
/// A tag rather than a re-derivation, deliberately: a plan whose staging produced HEAD's tree is
/// skipped and lands no commit at all, so the oids that come back are not positionally alignable with
/// the plans that were submitted.
public enum PlannedCommitKind: String, Sendable, Equatable, CaseIterable {
/// A repository's first commit — "Initial board state", never split (06 ▸ Rules ▸ Abnormal repo
/// states).
case root
case foreign
case heal
case user
}
/// One commit that actually landed: its oid, and the class of the plan that made it.
public struct GitLandedCommit: Sendable, Equatable {
public let oid: String
public let kind: PlannedCommitKind
public init(oid: String, kind: PlannedCommitKind) {
self.oid = oid
self.kind = kind
}
}
/// One commit a flush intends to make: which paths it stages, what it says, and who it is by.
///
/// A value rather than a call, because the flush's whole decision — the three-way split, the
/// ordering, the authorship — is made on the main actor from state the committer holds, and the
/// libgit2 work is then a pure function of these (06 ▸ Interaction with external writers: the
/// two-commit split; ruled 2026-07-29: the heal's third class).
public struct PlannedCommit: Sendable, Equatable {
/// Board-root-relative paths, exactly as `GitChangedPath.path` spells them.
public let paths: [String]
public let message: String
/// **Who the change is by** — the user, `Lanework External`, or a `modified-by` agent.
public let author: GitIdentity
/// **Who made the commit** — always this machine's user identity.
///
/// A judgment call, recorded: 06 pins the *author* ("foreign changes are committed under the
/// pinned synthetic author … so any git client can filter, log, and blame by origin" — and both
/// `git log --author` and `git blame` read the author field) and says nothing about the
/// committer. Git's own convention for recording somebody else's change — `git am`, cherry-pick,
/// every forge's merge button — keeps the author as the originator and names the actor who
/// created the commit as committer, which is honestly what happened here: Lanework, running as
/// this user, wrote it. Setting both to the synthetic identity would claim the repository made
/// itself.
public let committer: GitIdentity
/// Which of 06's classes planned this — carried so the landed commit can be recognized by it.
/// See `PlannedCommitKind`; defaulted so a caller with only one class to make (add-git's root
/// commit, the undo provider's restore) says nothing about a split it is not part of.
public let kind: PlannedCommitKind
public init(
paths: [String],
message: String,
author: GitIdentity,
committer: GitIdentity,
kind: PlannedCommitKind = .user
) {
self.paths = paths
self.message = message
self.author = author
self.committer = committer
self.kind = kind
}
}
/// How a flush ended — the four outcomes 06 gives the committer, and no fifth.
public enum GitCommitOutcome: Sendable, Equatable {
/// One commit per planned commit that had anything in it, oldest first — each carrying the class
/// of the plan that made it (`PlannedCommitKind`), which is how heal transparency reaches the
/// undo stack.
case committed([GitLandedCommit])
/// **The happy path, not a malfunction** (06 ▸ Interaction with external writers): the tree had
/// nothing to commit — an agent already committed its own work, or the window held only paths
/// staged around.
case nothingToCommit
/// **Never an error** (06): `index.lock` was held and stayed held through the brief retry. The
/// caller re-debounces; nothing is surfaced.
case locked
/// The repository is in a state the app does not write in (`GitRepositoryPause`). Edits keep
/// landing on disk and commit as one settled batch when it clears.
case held(GitRepositoryPause)
/// A genuine failure — disk full, corruption. Surfaced per 02-architecture.md ▸ Write-failure
/// surfacing and retried on the next debounce.
case failed(GitOperationFailure)
}
// MARK: - GitCommitOperation
/// **The signature-capable commit path** (06-history-undo.md ▸ Interaction with external writers:
/// "Commit attribution is structural, not just a message convention"), written against the vendored
/// libgit2 C API directly.
///
/// ### Why it is not SwiftGitX
///
/// SwiftGitX 0.4.0's `Repository.commit(message:)` takes no signature: its `CommitOptions` leaves
/// `author` and `committer` null, so libgit2 falls back to `git_signature_default`, which resolves
/// through the merged config ladder — unreadable in the sandbox, and the wrong question anyway
/// (06 rules `~/.gitconfig` out of the identity story entirely). Per-commit authorship is this
/// card's whole point: the user's identity on user-driven commits, `Lanework External` on foreign
/// ones, a `modified-by` agent's on stamped ones. None of that is reachable through the wrapper, and
/// `Repository.pointer` is `internal`, so there is no seam to borrow either.
///
/// The module underneath *is* reachable — SwiftGitX vendors `libgit2` as a package product, and
/// `project.yml` names the same pin SwiftGitX pins, so this adds an import rather than a second copy
/// of the library. Everything SwiftGitX does well (`GitRepository`'s reads) still goes through it.
///
/// ### Isolation
///
/// `GitRepository`'s rule, unchanged and for its reason: every function here 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, so libgit2 never sees two threads on one handle.
enum GitCommitOperation {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
/// libgit2's global state, brought up exactly once per process.
///
/// SwiftGitX calls `git_libgit2_init` from `Repository.init`/`open` and pairs it with a shutdown
/// in `deinit`, which is a refcount this file must not ride on: a flush can run when no
/// `Repository` is alive. A `static let` is Swift's own run-once, and the matching shutdown is
/// deliberately never called — the library stays up for the life of the process, which is what
/// every consumer here wants.
private static let startUp: Bool = {
git_libgit2_init() >= 0
}()
// MARK: - Reading
/// The three facts a flush checks **before every attempt** (06 ▸ Rules ▸ Abnormal repo states:
/// "the check runs at open and again before every flush, so finishing the operation in a
/// terminal resumes the pipeline without ceremony").
///
/// A repository that cannot be opened at all reads as no pause, not unborn, not locked — the
/// same shrug every read in `GitRepository` gives an unopenable repo, and the commit attempt
/// that follows will fail honestly with libgit2's own message rather than on a guess made here.
nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
_ = startUp
guard let repository = open(boardRoot) else {
return GitRepositoryReading(pause: nil, isUnborn: false, isIndexLocked: false)
}
defer { git_repository_free(repository) }
let locked = isIndexLocked(gitDirectory: gitDirectory(of: repository))
let unborn = git_repository_head_unborn(repository) == 1
// Detached HEAD is asked first because it is the state an unborn repo cannot be in and the
// one `git_repository_state` does not model: libgit2 keeps "what operation is in progress"
// and "where HEAD points" as separate questions.
if !unborn, git_repository_head_detached(repository) == 1 {
return GitRepositoryReading(pause: .detachedHead, isUnborn: false, isIndexLocked: locked)
}
return GitRepositoryReading(pause: pause(of: repository), isUnborn: unborn, isIndexLocked: locked)
}
/// libgit2's `git_repository_state`, mapped to the pauses 06 names.
///
/// It reads the marker files the design lists (`rebase-merge/`, `rebase-apply/`, `MERGE_HEAD`,
/// `REVERT_HEAD`, `CHERRY_PICK_HEAD`, `BISECT_LOG`) — which is why a test can plant one file and
/// get the real answer rather than a mocked one.
private static func pause(of repository: OpaquePointer) -> GitRepositoryPause? {
switch git_repository_state(repository) {
case Int32(GIT_REPOSITORY_STATE_NONE.rawValue): nil
case Int32(GIT_REPOSITORY_STATE_MERGE.rawValue): .merge
case Int32(GIT_REPOSITORY_STATE_REVERT.rawValue),
Int32(GIT_REPOSITORY_STATE_REVERT_SEQUENCE.rawValue): .revert
case Int32(GIT_REPOSITORY_STATE_CHERRYPICK.rawValue),
Int32(GIT_REPOSITORY_STATE_CHERRYPICK_SEQUENCE.rawValue): .cherryPick
case Int32(GIT_REPOSITORY_STATE_BISECT.rawValue): .bisect
case Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX.rawValue),
Int32(GIT_REPOSITORY_STATE_APPLY_MAILBOX_OR_REBASE.rawValue): .applyMailbox
// Every rebase flavour reads as one pause: the popover says "a rebase is in progress" and the
// engine holds, and no consumer of either is finer-grained than that.
default: .rebase
}
}
/// **Which paths differ between HEAD and the working tree**, `.gitignore` respected.
///
/// This is the commit's *condition* — "its commit condition is the *tree*, not the snapshot
/// diff, so a stray-only window commits rather than leaving the tree dirty" (06 ▸ Commit
/// messages ▸ Non-snapshot files commit too) — and it is also the composer's second input, which
/// is why it comes back as values rather than as a count.
///
/// ### Why it stages into the index rather than reading `git_status`
///
/// Because of one clause: "**A folder move is not a deletion**: items match by id across the
/// whole board … so a moved card attributes by its stamp like any changed file" (06). Rename
/// detection is a *similarity* pass over a diff, and libgit2 only runs it where both ends are in
/// one diff — `git_status`' `RENAMES_INDEX_TO_WORKDIR` finds a rename made **after** staging, and
/// a plain `mv` in a working tree nobody has staged is simply a delete beside an add. Measured,
/// not assumed: the first cut of this function used status with every rename flag set, and a
/// re-stamped agent move still demoted to `Lanework External`.
///
/// So the diff is taken where the pairing can be seen: everything the working tree says is staged
/// into the **in-memory** index (`git_index_add_all` — full `git add -A` semantics, ignores
/// respected, deletions dropped), HEAD's tree is diffed against it, and `git_diff_find_similar`
/// pairs the ends. **Nothing is written**: the index file on disk is untouched, which is what
/// keeps this a read, and the index object is reset to HEAD on the way out so a caller that goes
/// on to stage a *subset* starts from a known base rather than from everything.
/// **`nil` means the survey could not be taken**, which is emphatically not the same answer as
/// "nothing changed" and must never be flattened into it.
///
/// Staging writes blobs into the object store, so a repository whose `.git/objects` has become
/// unwritable fails *here* rather than at the commit — and a version of this that shrugged and
/// returned no paths would report a clean tree, no-op silently, and let history stop advancing
/// with nothing on the banner strip. That is precisely the case 06 separates from contention:
/// "Genuine commit failures — disk full, repo corruption — are different: files stay safe on disk
/// but history stops advancing; surfaced per 02-architecture.md ▸ Write-failure surfacing."
/// (Found by test rather than by reading: the failure suite went green-by-silence when discovery
/// moved from `git_status` to staging.)
nonisolated static func surveyChangedPaths(at boardRoot: URL) -> [GitChangedPath]? {
_ = startUp
guard let repository = open(boardRoot) else { return nil }
defer { git_repository_free(repository) }
var index: OpaquePointer?
guard git_repository_index(&index, repository) == 0, let index else { return nil }
defer { git_index_free(index) }
return changedPaths(in: repository, index: index)
}
/// The survey, with "could not look" folded into "nothing to do" — for the callers that have no
/// failure channel and want the safe answer: `GitRepository.create`'s branch line, and the tests'
/// clean-tree assertions.
nonisolated static func changedPaths(at boardRoot: URL) -> [GitChangedPath] {
surveyChangedPaths(at: boardRoot) ?? []
}
private static func changedPaths(in repository: OpaquePointer, index: OpaquePointer) -> [GitChangedPath]? {
var pathspec = git_strarray()
guard git_index_add_all(index, &pathspec, GIT_INDEX_ADD_DEFAULT.rawValue, nil, nil) == 0 else {
return nil
}
defer { resetIndexToHead(index, in: repository) }
let parent = headCommit(of: repository)
defer { parent.map(git_commit_free) }
var headTree: OpaquePointer?
if let parent { git_commit_tree(&headTree, parent) }
defer { headTree.map(git_tree_free) }
var diff: OpaquePointer?
var options = git_diff_options()
guard git_diff_options_init(&options, UInt32(GIT_DIFF_OPTIONS_VERSION)) == 0,
git_diff_tree_to_index(&diff, repository, headTree, index, &options) == 0,
let diff else { return nil }
defer { git_diff_free(diff) }
var findOptions = git_diff_find_options()
if git_diff_find_options_init(&findOptions, UInt32(GIT_DIFF_FIND_OPTIONS_VERSION)) == 0 {
findOptions.flags = GIT_DIFF_FIND_RENAMES.rawValue
// Best-effort: a diff too large for the similarity pass simply reports the unpaired
// shape, which demotes the window to the generic external author — the safe direction,
// and the one the guide's re-stamping advice already covers.
_ = git_diff_find_similar(diff, &findOptions)
}
var found: [String: GitChangedPath] = [:]
func record(_ path: String?, isDeletion: Bool, isRename: Bool, isArrival: Bool = false) {
guard let path, !path.isEmpty else { return }
let existing = found[path]
found[path] = GitChangedPath(
path: path,
// Present wins where two deltas disagree: staging asks "is it there now", and the
// `modified-by` demotion must not fire for a file the window ends with.
isDeletion: (existing?.isDeletion ?? true) && isDeletion,
isRename: (existing?.isRename ?? false) || isRename,
// New wins, for the mirror of that reason: one delta calling a path an addition is
// enough to know HEAD did not have it, which is the whole content of the bit.
isArrival: (existing?.isArrival ?? false) || isArrival
)
}
for position in 0..<git_diff_num_deltas(diff) {
guard let delta = git_diff_get_delta(diff, position)?.pointee else { continue }
switch delta.status {
case GIT_DELTA_DELETED:
record(string(delta.old_file.path), isDeletion: true, isRename: false)
case GIT_DELTA_RENAMED:
// Both ends, and neither is a deletion the window may be demoted by: the departure
// has to leave the index and the arrival has to enter it. The arriving end is new at
// its path, which is what the comment family reads a post by.
record(string(delta.old_file.path), isDeletion: true, isRename: true)
record(string(delta.new_file.path), isDeletion: false, isRename: true, isArrival: true)
case GIT_DELTA_ADDED, GIT_DELTA_COPIED, GIT_DELTA_UNTRACKED:
record(string(delta.new_file.path), isDeletion: false, isRename: false, isArrival: true)
default:
record(string(delta.new_file.path), isDeletion: false, isRename: false)
}
}
return found.values.sorted { $0.path < $1.path }
}
/// Puts the index object back to exactly HEAD's tree — the base every per-class staging works up
/// from, and what makes the discovery pass above a read.
///
/// On an unborn HEAD that is an empty index, which is the same statement with no tree to say it
/// with. Note this is the *in-memory* index: nothing here calls `git_index_write`, so a caller
/// that abandons the flush leaves the file on disk exactly as it found it, staged changes of
/// another writer's included.
private static func resetIndexToHead(_ index: OpaquePointer, in repository: OpaquePointer) {
guard let parent = headCommit(of: repository) else {
git_index_clear(index)
return
}
defer { git_commit_free(parent) }
var tree: OpaquePointer?
guard git_commit_tree(&tree, parent) == 0, let tree else { return }
defer { git_tree_free(tree) }
git_index_read_tree(index, tree)
}
// MARK: - Committing
/// **Stages and commits each plan in turn, with explicit signatures.**
///
/// The plans are committed **in the order given** and each is a whole commit of its own — which
/// is how the two-commit split ("A debounce window containing both kinds is split into two
/// commits, never mixed") and the heal's third class (ruled 2026-07-29) become one mechanism
/// rather than three code paths.
///
/// A plan whose staging produces the tree HEAD already has is **skipped, not committed**: an
/// empty commit says nothing and would make `git log` a record of the debounce timer rather than
/// of the board. That is also the clean-tree no-op, arrived at without a special case.
///
/// - Parameter allowRootCommit: whether an unborn HEAD may take its root commit here. The caller
/// passes `true`; it exists so the "first settled change on an adopted unborn repo commits the
/// whole tree as *Initial board state*" rule stays a decision the *planner* made and is not
/// re-derived down here.
nonisolated static func perform(
at boardRoot: URL,
commits: [PlannedCommit],
allowRootCommit: Bool = true
) -> GitCommitOutcome {
_ = startUp
guard !commits.isEmpty else { return .nothingToCommit }
guard let repository = open(boardRoot) else {
return .failed(GitOperationFailure(operation: operationName, message: lastErrorMessage()))
}
defer { git_repository_free(repository) }
// Re-checked here, inside the same handle that is about to write, rather than trusted from
// the caller's earlier `reading(at:)`: between the two a terminal can have started a rebase,
// and 06's rule is that the check runs "again before every flush".
if git_repository_head_unborn(repository) == 1 {
guard allowRootCommit else { return .nothingToCommit }
} else if git_repository_head_detached(repository) == 1 {
return .held(.detachedHead)
}
if let pause = pause(of: repository) { return .held(pause) }
if isIndexLocked(gitDirectory: gitDirectory(of: repository)) { return .locked }
var index: OpaquePointer?
guard git_repository_index(&index, repository) == 0, let index else {
return failure(lastErrorMessage())
}
defer { git_index_free(index) }
// **Every split starts from HEAD, not from whatever the index happened to hold.** Each plan
// below writes the *whole* index as a tree, so a change another writer had staged but not
// committed would otherwise ride into whichever commit came first — silently attributing it
// to that class. Resetting makes each commit exactly HEAD plus the paths its own class
// staged, which is what "split into two commits, never mixed" has to mean. The staged change
// is not lost: it is a changed path like any other and is classified and committed on its
// own terms.
resetIndexToHead(index, in: repository)
var landed: [GitLandedCommit] = []
for plan in commits {
switch commit(plan, in: repository, index: index) {
case let .landed(oid):
landed.append(GitLandedCommit(oid: oid, kind: plan.kind))
case .skipped:
continue
case let .stopped(outcome):
// Whatever landed before the failure stays landed — those commits are real, and
// reporting them is what lets the caller clear the suspension for the half that
// worked while retrying the rest on the next debounce.
if case let .failed(reason) = outcome, !landed.isEmpty {
logger.error("commit split failed partway: \(reason.message, privacy: .public)")
}
return outcome
}
}
return landed.isEmpty ? .nothingToCommit : .committed(landed)
}
/// What one plan did.
private enum CommitStep {
case landed(String)
/// Its staging produced the tree HEAD already has — an empty commit, deliberately not made.
case skipped
case stopped(GitCommitOutcome)
}
/// One plan: stage its paths, write the tree, and create the commit if the tree is new.
private static func commit(
_ plan: PlannedCommit,
in repository: OpaquePointer,
index: OpaquePointer
) -> CommitStep {
// **Path by path, never a pathspec.** `git_index_add_all` would take a glob, and a card
// titled with a `[` in its folder name is a real board; exact `add`/`remove` calls also make
// the stage-around exact — an excluded folder is one this loop never mentions, rather than
// one a matcher has to be trusted to miss.
for path in plan.paths {
let exists = FileManager.default.fileExists(
atPath: workdir(of: repository).appendingPathComponent(path).path
)
let status = exists
? git_index_add_bypath(index, path)
: git_index_remove_bypath(index, path)
// `GIT_ENOTFOUND` on a removal is a path the index never had — an untracked file that
// vanished inside the window. Nothing to stage and nothing wrong.
guard status == 0 || (!exists && status == GIT_ENOTFOUND.rawValue) else {
return .stopped(classify(status))
}
}
var treeOID = git_oid()
guard git_index_write_tree(&treeOID, index) == 0 else { return .stopped(classify(lastErrorCode())) }
let parent = headCommit(of: repository)
defer { parent.map(git_commit_free) }
if let parent, let headTree = treeIdentity(of: parent), equal(headTree, treeOID) {
return .skipped
}
// The index is persisted **before** the commit, deliberately: this is the call `index.lock`
// bites on, and failing here leaves an unreferenced tree object (garbage libgit2 collects)
// rather than a commit whose index nobody can see.
guard git_index_write(index) == 0 else { return .stopped(classify(lastErrorCode())) }
var tree: OpaquePointer?
guard git_tree_lookup(&tree, repository, &treeOID) == 0, let tree else {
return .stopped(classify(lastErrorCode()))
}
defer { git_tree_free(tree) }
guard let author = signature(plan.author), let committer = signature(plan.committer) else {
return .stopped(failure(lastErrorMessage()))
}
defer {
git_signature_free(author)
git_signature_free(committer)
}
var commitOID = git_oid()
var parents: [OpaquePointer?] = parent.map { [$0] } ?? []
let status = parents.withUnsafeMutableBufferPointer { buffer in
git_commit_create(
&commitOID,
repository,
// "HEAD" rather than a branch name: on an unborn HEAD this creates the branch the
// symbolic ref names, and on a born one it advances whatever branch is checked out —
// one call for the root commit and every commit after it.
"HEAD",
author,
committer,
nil,
plan.message,
tree,
buffer.count,
buffer.baseAddress
)
}
guard status == 0 else { return .stopped(classify(status)) }
return .landed(hex(commitOID))
}
// MARK: - Identity
/// **Where the user's identity comes from, resolved at commit time** (06 ▸ Interaction with
/// external writers ▸ "Where the user's git identity comes from") — repo-local `.git/config`
/// when present, the derived default otherwise.
///
/// **The one place that order lives.** Until this card, `GitRepository.applyIdentity` also
/// encoded it, by *materializing* the resolved identity into the new repository's config so that
/// libgit2's signature-less commit would find something; that was an explicit interim and it is
/// gone. Nothing writes `user.name`/`user.email` any more: the popover's identity fields (a
/// later card) will, because there "the setting *is* the file", and an app that wrote the file
/// on its own could never tell its own default from the user's choice.
///
/// The `.git` directory is libgit2's answer rather than `boardRoot/.git`, so a board whose
/// `.git` is a *file* (a linked worktree — `BoardGitMode` counts those as git mode) resolves its
/// real config instead of trying to parse a pointer.
nonisolated static func userIdentity(at boardRoot: URL) -> GitIdentity {
_ = startUp
guard let repository = open(boardRoot) else {
return GitIdentity.resolve(repoLocal: (nil, nil), derived: .derivedDefault())
}
defer { git_repository_free(repository) }
return GitIdentity.resolve(
repoLocal: GitConfigFile.identity(inGitDirectory: gitDirectory(of: repository)),
derived: .derivedDefault()
)
}
/// **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()
let status = git_signature_new(
&signature,
identity.name,
identity.email,
git_time_t(now.timeIntervalSince1970),
Int32(TimeZone.current.secondsFromGMT(for: now) / 60)
)
return status == 0 ? signature : nil
}
// MARK: - index.lock
/// Whether `.git/index.lock` is there right now.
///
/// **Never removed, whatever its age** (06 ▸ Interaction with external writers): "a crashed
/// writer's leftover is the user's to clear; the never-mutate rule's one exemption is the app's
/// own leftovers". The pathfinder deleted locks older than ten minutes; that heuristic is
/// deliberately not carried over — it is precisely a mutation of repo state the app did not
/// create.
nonisolated static func isIndexLocked(at boardRoot: URL) -> Bool {
_ = startUp
guard let repository = open(boardRoot) else { return false }
defer { git_repository_free(repository) }
return isIndexLocked(gitDirectory: gitDirectory(of: repository))
}
private static func isIndexLocked(gitDirectory: URL) -> Bool {
FileManager.default.fileExists(atPath: gitDirectory.appendingPathComponent("index.lock").path)
}
// MARK: - Private plumbing
private static let operationName = "Recording this board's history"
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 workdir(of repository: OpaquePointer) -> URL {
URL(fileURLWithPath: string(git_repository_workdir(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 treeIdentity(of commit: OpaquePointer) -> git_oid? {
guard let tree = git_commit_tree_id(commit) else { return nil }
return tree.pointee
}
private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
var left = lhs
var right = rhs
return git_oid_cmp(&left, &right) == 0
}
private static func hex(_ oid: git_oid) -> String {
var value = oid
var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
git_oid_fmt(&buffer, &value)
return String(cString: buffer)
}
private static func string(_ pointer: UnsafePointer<CChar>?) -> String? {
pointer.map { String(cString: $0) }
}
/// libgit2's message for whatever just failed, or a shrug when it set none.
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)
}
private static func lastErrorCode() -> Int32 {
git_error_last() != nil ? GIT_ERROR.rawValue : GIT_ERROR.rawValue
}
/// Turns a libgit2 status into the outcome 06 gives it.
///
/// **`GIT_ELOCKED` is the whole reason this exists**: contention is "never an error", so it must
/// not travel the same road as a disk failure. Everything else is a genuine failure carrying
/// libgit2's own message.
private static func classify(_ status: Int32) -> GitCommitOutcome {
status == GIT_ELOCKED.rawValue ? .locked : failure(lastErrorMessage())
}
private static func failure(_ message: String) -> GitCommitOutcome {
.failed(GitOperationFailure(operation: operationName, message: message))
}
}