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.
///
/// Seven of 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`).
///
/// **The eighth is the app's own reading, and it is a pause by ruling** (06 ▸ Rules, "A `.git` that
/// isn't a valid repository still reads as git mode — and fails loudly", ruled 2026-07-31): a `.git`
/// libgit2 cannot open at all is not a repository *state* — there is no repository to be in one —
/// but the posture it calls for is this one, verbatim: "the whole git surface paused (the
/// abnormal-states posture below)". Putting it in this vocabulary is what makes that true
/// structurally rather than by a rule somebody has to keep: every consumer of a pause already holds
/// the auto-commit debounce (`GitAutoCommitter.execute`), disables Undo/Redo and the branch controls
/// (`GitHistoryProvider.isHeld`, `GitBranchSwitcher.perform`), skips housekeeping
/// (`GitHousekeeper.runNow`), and names the state in the popover — so `.unreadable` inherits all of
/// it by construction, including the standing pause's own 15 s re-read, which is what heals it
/// mid-session (`GitAutoCommitter.holdRecheckInterval`).
public enum GitRepositoryPause: String, Sendable, Equatable, CaseIterable {
    case detachedHead
    case merge
    case revert
    case cherryPick
    case bisect
    case rebase
    case applyMailbox

    /// **The repository could not be opened** — a corrupt `.git`, a worktree pointer aimed at
    /// nothing, or a repository this engine has no support for (a SHA-256 one, 06 ▸ Repository
    /// hygiene: "an adopted SHA-256 repo the engine cannot open takes the corrupt-repo loud-failure
    /// path"). Never a fall to mode none: detection is presence-shaped, so the board stays in git
    /// mode and this is what git mode *reads* like while the repository is unreadable.
    case unreadable

    /// 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"
        // The clause the failure family reads with ("Adding git to this board failed: …",
        // `GitBranchOperation`'s held case), in the same voice as its siblings. The *popover's*
        // sentence for this state is its own and says more (`BoardGitBranchSurface.unreadableNote`):
        // unlike every pause above it, nothing is in progress and no tool is coming to finish it.
        case .unreadable: "this board's git repository can't be read"
        }
    }
}

/// 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 is `.unreadable`** — a pause, not a shrug (06 ▸
    /// Rules, the corrupt-`.git` loud failure, ruled 2026-07-31). This line used to answer "no pause,
    /// not unborn, not locked" and let the commit attempt that followed fail with libgit2's own
    /// message; under the ruling that is exactly backwards — the failure must be loud *before* a
    /// write is attempted, and nothing may be attempted against a repository the app cannot open
    /// ("Lanework leaves the repository untouched").
    ///
    /// Because every caller of this function already branches on `pause`, that one word is the whole
    /// of the pause wiring: the flush holds, housekeeping skips, the interrupted-operation recovery
    /// defers, and the popover's `refreshPause` learns it.
    /// **Presence-shaped, exactly as detection is**: `.unreadable` is what a root `.git` that will
    /// not open reads like, and a board with no `.git` at all is not in git mode in the first place
    /// — it keeps the old no-pause answer, so a caller outside git mode (`GitHousekeeping.run`'s own
    /// `.noRepository` reading, a storeless test) is not told a repository it does not have is
    /// paused.
    nonisolated static func reading(at boardRoot: URL) -> GitRepositoryReading {
        _ = startUp
        guard let repository = open(boardRoot) else {
            return GitRepositoryReading(
                pause: BoardGitMode.hasGitEntry(at: boardRoot) ? .unreadable : 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))
    }
}
