import Foundation import SwiftGitX import os // MARK: - Failure /// **Why a git operation didn't happen**, named and carrying libgit2's own message. /// /// One type rather than a case per operation because everything that reaches a user goes through /// the same two sentences — what was being attempted, and what the library said — and because the /// operations that will join `initialize` here (commit, checkout, pull) all fail in exactly that /// shape (06-history-undo.md ▸ Interaction with external writers: "An operation that fails /// *cleanly* … surfaces as a one-shot banner failure naming the operation and the error"). public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConvertible { /// What was being attempted, in the user's words rather than libgit2's — "Adding git to this /// board", not `git_repository_init`. public let operation: String /// libgit2's message for the failure, verbatim. Kept rather than mapped: the messages are /// specific ("could not write to '…': Permission denied") in a way no re-phrasing of ours would /// be, and the alternative to showing it is a shrug. public let message: String public init(operation: String, message: String) { self.operation = operation self.message = message } public var description: String { "\(operation) failed: \(message)" } } // MARK: - GitRepository /// **The board's repository, through the bundled libgit2** (06-history-undo.md ▸ Rules ▸ Opt-in /// init: "Bundled libgit2 — no git install required"). /// /// SwiftGitX vendors libgit2 as an in-process library, so every call here runs inside the sandbox /// with no `Process`, no `/usr/bin/git` and no sandbox extension — the shipped Release build behaves /// identically on a machine that has never had the command-line tools installed. /// /// ### Isolation /// /// Every function is `nonisolated` and **opens its own `Repository`, confined to its own /// synchronous scope**. `Repository` is `Sendable` (SwiftGitX marks it so to make handles /// transferable), but the libgit2 handle underneath is not safe for concurrent use from several /// threads at once, so no handle here is ever shared across an `await`, a `Task`, or a stored /// property. `HistoryStore` — which is `@MainActor` — reaches these through `Task.detached`, so the /// main actor never blocks on libgit2 and libgit2 never sees two threads at once. /// /// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's /// *policy* deliberately left behind: nothing here auto-initializes anything, nothing seeds a /// `.gitignore` (06 ▸ Repository hygiene, a later card), and nothing commits on its own schedule. enum GitRepository { /// **The root commit's own subject** (06-history-undo.md ▸ Rules ▸ Abnormal repo states, /// settled): "whenever the app creates a repo's first commit … it commits the whole tree as /// *Initial board state*, never a folded diff-from-empty: there is no last-committed snapshot to /// diff against, and forty Adds would bury the event." static let initialCommitSubject = "Initial board state" /// The branch a board's first commit lands on. /// /// **Forced rather than inherited, deliberately.** libgit2's compiled-in initial-branch name /// comes from `init.defaultBranch` in whatever config layer it can find at /// `git_repository_init` time — which is non-deterministic across machines and simply /// unavailable in the sandbox (redirected, empty HOME). `Repository.create(at:)` has no /// initial-branch parameter, so this is applied by writing `.git/HEAD` directly: on a freshly /// created, unborn, non-bare repository that file is nothing but the plain-text symbolic ref, so /// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches /// the repo. /// /// DESIGN is silent on the name; `main` is git's own modern default and the pathfinder's choice. static let initialBranchName = "main" private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git") // MARK: Opt-in init /// **Add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init): initializes a repository at /// `boardRoot` and immediately commits the whole tree as `Initial board state`. /// /// The commit is not deferred to any debounce — "init doesn't wait for the debounce; the board /// is protected from the moment git exists" — so the two halves are one operation and a failure /// in either is one failure. /// /// **It refuses a board that already has a `.git`.** The app "never mutates repo state it didn't /// create" (06), and `git_repository_init` over an existing repository is a re-initialization — /// harmless in the common case and precisely the kind of thing that rule exists to forbid. The /// caller (`HistoryStore.addGit`) has already established mode `none`; this is the check that /// makes it impossible rather than merely unlikely. /// /// Returns the branch the root commit landed on, which is the popover's display line. nonisolated static func create(at boardRoot: URL) -> Result { let operation = "Adding git to this board" guard !BoardGitMode.hasGitEntry(at: boardRoot) else { return .failure(GitOperationFailure( operation: operation, message: "this board already has a git repository" )) } let gitDirectory: URL do { let created = try Repository.create(at: boardRoot) gitDirectory = created.path // Before anything else touches the repo — see `initialBranchName`. try? "ref: refs/heads/\(initialBranchName)\n".write( to: gitDirectory.appendingPathComponent("HEAD"), atomically: true, encoding: .utf8 ) } catch { return .failure(GitOperationFailure(operation: operation, message: reason(error))) } // A **fresh handle** for the staging and the commit, so nothing reads HEAD through a // repository object that predates the symbolic ref just written: libgit2 caches refs per // repository, and the whole point of writing that file was to decide where the first commit // lands. The creating handle is dropped above. let repository: Repository do { repository = try Repository.open(at: boardRoot) } catch { return .failure(GitOperationFailure(operation: operation, message: reason(error))) } applyIdentity(to: repository, gitDirectory: gitDirectory) do { // An empty pathspec passed to `git_index_add_all` (via `add(paths:)`) matches every path // in the working tree — full `git add -A` semantics in one step, `.gitignore` respected // — which is what "commits the whole tree" means: the board's files, the agent guide, // strays and all (06 ▸ Commit messages: "the committer stages the whole board root"). try repository.add(paths: []) _ = try repository.commit(message: initialCommitSubject) } catch { logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(reason(error), privacy: .public)") return .failure(GitOperationFailure(operation: operation, message: reason(error))) } return .success(branchName(at: boardRoot) ?? initialBranchName) } /// Gives the repository a commit identity **only when it has none** (06-history-undo.md /// ▸ Interaction with external writers ▸ "Where the user's git identity comes from"). /// /// `GitIdentity` resolves what the identity *is*: repo-local config when present, the derived /// default otherwise. What this method adds is the mechanism — writing the resolved identity /// into the repository's own config so libgit2's default signature resolves to it. /// /// **That write is a mechanism, not a design decision, and it is the narrowest one available.** /// SwiftGitX 0.4.0's `commit(message:)` takes no signature (its `CommitOptions` leaves /// `author`/`committer` null, so libgit2 falls back to `git_signature_default`, which fails /// outright in a sandbox with no readable config). Every board this runs on is one the app /// created milliseconds earlier, whose config the app itself wrote, and the keys are only ever /// *added* — a config that already names an identity is left exactly as it was, which is the /// adopted-repo promise. The auto-commit card needs per-commit authorship anyway (foreign /// changes commit as `Lanework External`, `modified-by` windows as the agent), so it must reach /// a signature-capable commit path regardless; when it does, this materialization goes with it. private static func applyIdentity(to repository: Repository, gitDirectory: URL) { let configured = GitConfigFile.identity(inGitDirectory: gitDirectory) guard configured.name == nil || configured.email == nil else { return } let identity = GitIdentity.resolve(repoLocal: configured, derived: .derivedDefault()) if configured.name == nil { try? repository.config.set("user.name", to: identity.name) } if configured.email == nil { try? repository.config.set("user.email", to: identity.email) } } // MARK: Reads /// The current branch's short name, or `nil` when there is no repository at `boardRoot` or /// libgit2 cannot open it — the popover's read-only branch line (03-board-ui.md ▸ Board /// popover), and nothing more: branch switching and creation are a later card. /// /// **An unborn HEAD answers with a name, not with `nil`** (06 ▸ Rules ▸ Abnormal repo states: /// "an unborn HEAD is normal git mode"). Every SwiftGitX HEAD accessor goes through /// `git_repository_head`, which refuses to resolve an unborn HEAD to a name and throws instead, /// so the only way to recover the branch a first commit *would* land on is to read `.git/HEAD`'s /// symbolic-ref target — the same plain text this file writes at init. nonisolated static func branchName(at boardRoot: URL) -> String? { guard BoardGitMode.hasGitEntry(at: boardRoot), let repository = try? Repository.open(at: boardRoot) else { return nil } if repository.isHEADUnborn { return unbornBranchName(gitDirectory: repository.path) } guard let head = try? repository.HEAD else { return nil } if repository.isHEADDetached { // Detached HEAD reports its branch `name` as the literal "HEAD", which labels nothing. // The short hash is what plain git shows in the same state. (The *posture* a detached // HEAD calls for — pausing the whole git surface honestly, 06 ▸ Abnormal repo states — // is the auto-commit card's; this is only the label.) return (head.target as? Commit)?.id.abbreviated ?? "HEAD" } return head.name } /// HEAD's commit, flattened to what a caller (and a test) can assert on: subject, author, and /// how many parents it has — a root commit having none is how "the root commit has its own /// subject" is checkable. nonisolated static func headCommit(at boardRoot: URL) -> CommitSummary? { guard BoardGitMode.hasGitEntry(at: boardRoot), let repository = try? Repository.open(at: boardRoot), !repository.isHEADUnborn, let head = try? repository.HEAD, let commit = head.target as? Commit else { return nil } return CommitSummary( oid: commit.id.hex, subject: commit.summary, authorName: commit.author.name, authorEmail: commit.author.email, parentCount: (try? commit.parents)?.count ?? 0 ) } /// Every file path in HEAD's tree, board-root-relative and sorted — what the repository actually /// tracks right now. nonisolated static func trackedPaths(at boardRoot: URL) -> [String] { guard BoardGitMode.hasGitEntry(at: boardRoot), let repository = try? Repository.open(at: boardRoot), !repository.isHEADUnborn, let head = try? repository.HEAD, let commit = head.target as? Commit else { return [] } return filePaths(of: commit, in: repository).sorted() } /// One commit, as much of it as anything outside this file needs. struct CommitSummary: Sendable, Equatable { let oid: String let subject: String let authorName: String let authorEmail: String let parentCount: Int } // MARK: Path history /// **When each path entered history** — the git half of the loader's earlier-occurrence-wins /// ladder (01-storage-format.md ▸ Fractal layout ▸ Rules: "on git boards, the path history /// already tracks outranks the newcomer"; `BoardLoader.IdentityHistoryRanker`). /// /// The answer is `git log --diff-filter=A`-shaped, walked here rather than shelled out: HEAD's /// **first-parent** ancestry oldest-first, with each commit's rank being its position in that /// walk. Paths present in the oldest commit reached rank 0 (its whole tree, since a root commit /// has no parent to diff against and a capped walk's base is "everything that already existed"); /// every later commit contributes the paths its diff *adds*. Lower is earlier, which is exactly /// the ranker's contract, and a path never seen is absent — the `nil` the rule reads as /// "outranked by anything tracked". /// /// **Ranks are recorded for folders, not only files**, because the loader asks about *items*: /// a card is a folder, and what git tracks is the `index.md` inside it. Every directory prefix /// of an added file therefore takes that file's rank unless it already has an earlier one. /// /// Two honest limits. The walk is **capped** (`limit`), so a board with a longer history than /// that reads everything at its base as equally early — a tie the ladder resolves on birth date, /// exactly as it does without git. And **renames are not followed**: libgit2 reports a rename as /// an add plus a delete unless rename detection is run over the diff, so a card moved between /// lanes ranks at its move rather than at its birth (`--follow`'s job). Both degrade toward the /// no-history answer rather than toward a wrong one. nonisolated static func pathFirstAppearanceRanks(at boardRoot: URL, limit: Int = 512) -> [String: Int] { guard BoardGitMode.hasGitEntry(at: boardRoot), let repository = try? Repository.open(at: boardRoot), !repository.isHEADUnborn, let head = try? repository.HEAD, let tip = head.target as? Commit else { return [:] } var chain: [Commit] = [] var current: Commit? = tip while let commit = current, chain.count < limit { chain.append(commit) current = (try? commit.parents)?.first } var ranks: [String: Int] = [:] for (rank, commit) in chain.reversed().enumerated() { if rank == 0 { for path in filePaths(of: commit, in: repository) { record(path: path, rank: rank, into: &ranks) } continue } guard let diff = try? repository.diff(commit: commit) else { continue } for delta in diff.changes where delta.type == .added || delta.type == .renamed || delta.type == .copied { record(path: delta.newFile.path, rank: rank, into: &ranks) } } return ranks } /// Records `path` and every directory prefix above it at `rank`, keeping the earliest rank any /// of them has already earned. private static func record(path: String, rank: Int, into ranks: inout [String: Int]) { var components = path.split(separator: "/").map(String.init) while !components.isEmpty { let key = components.joined(separator: "/") if let existing = ranks[key] { ranks[key] = min(existing, rank) } else { ranks[key] = rank } components.removeLast() } } // MARK: - Private helpers /// Every blob path under `commit`'s tree, recursively. private static func filePaths(of commit: Commit, in repository: Repository) -> [String] { guard let tree = try? commit.tree else { return [] } var paths: [String] = [] func walk(_ tree: Tree, prefix: String) { for entry in tree.entries { let path = prefix.isEmpty ? entry.name : prefix + "/" + entry.name if entry.type == .tree { guard let subtree: Tree = try? repository.show(id: entry.id) else { continue } walk(subtree, prefix: path) } else { paths.append(path) } } } walk(tree, prefix: "") return paths } /// The unborn HEAD's symbolic target, parsed out of `.git/HEAD`'s plain text /// (`ref: refs/heads/main` → `main`). private static func unbornBranchName(gitDirectory: URL) -> String? { guard let contents = try? String( contentsOf: gitDirectory.appendingPathComponent("HEAD"), encoding: .utf8 ) else { return nil } let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines) let prefix = "ref: refs/heads/" guard trimmed.hasPrefix(prefix) else { return nil } let name = String(trimmed.dropFirst(prefix.count)) return name.isEmpty ? nil : name } /// libgit2's own message for a SwiftGitX error — far more useful than the struct's synthesized /// description — falling back to the description for anything else. private static func reason(_ error: any Error) -> String { if let gitError = error as? SwiftGitXError { return gitError.message } return String(describing: error) } }