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 public init(path: String, isDeletion: Bool, isRename: Bool) { self.path = path self.isDeletion = isDeletion self.isRename = isRename } } // MARK: - A planned commit /// 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 public init(paths: [String], message: String, author: GitIdentity, committer: GitIdentity) { self.paths = paths self.message = message self.author = author self.committer = committer } } /// 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. case committed([String]) /// **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) { 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 ) } for position in 0.. 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: [String] = [] for plan in commits { switch commit(plan, in: repository, index: index) { case let .landed(oid): landed.append(oid) 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() ) } private static func signature(_ identity: GitIdentity) -> UnsafeMutablePointer? { var signature: UnsafeMutablePointer? 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?) -> 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)) } }