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 and nothing commits on /// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on /// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and /// healed in at open), so all that survives here is a last-chance check that the file exists before /// the initial commit freezes the tree — see `seedGitignoreIfAbsent(at:)`. 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. /// /// **The initial branch is `main`** (06 ▸ Rules ▸ Opt-in init, blessed 2026-07-31): "the host's /// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it /// deterministically — git's modern default, 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. /// /// Between them sits a last-chance `.gitignore` check — the file is the board's rather than /// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here /// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`). /// /// **Create re-runs full detection and refuses anything but clean mode none** (06 ▸ Rules ▸ /// Detection, ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and /// refuses unless it reads clean none, so the forbidden nested init is impossible even on a /// raced or stale read." /// /// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it /// detected at board open, which can be minutes old — a `git init` in a terminal at the board root /// *or anywhere above it* between the two would otherwise slip past a root-only check and /// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole /// walk runs again here, at the moment of the write, so the refusal is structural rather than /// probable. **`.unverifiable` refuses too** — a denied ancestor check can never be told apart /// from a repository actually being there, so only a genuinely clean `.none` reading proceeds; a /// stale `.none` that has since become unverifiable is refused exactly like one that has since /// become repo-nested. /// /// 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" switch BoardGitMode.detect(boardRoot: boardRoot) { case .none: break case .git: return .failure(GitOperationFailure( operation: operation, message: "this board already has a git repository" )) case .repoNested: return .failure(GitOperationFailure( operation: operation, message: "this board lives inside a repository; Lanework leaves it to that repository" )) case .unverifiable: return .failure(GitOperationFailure( operation: operation, message: "this board's surroundings could not be fully checked, so Lanework will not add a repository here" )) } 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))) } // **Before the stage below, so the seed is *in* the initial commit** (06 ▸ Repository // hygiene). Ordering is the whole of it: written first, `.gitignore` is one of the paths // `git status` reports and rides into "Initial board state" like any other file — and any // `.DS_Store` the Finder already left under the board is ignored from the repository's very // first commit rather than entering history and needing to be forgotten later, which nothing // in this app will ever do (06 ▸ Deleting never forgets). seedGitignoreIfAbsent(at: boardRoot) // **The root commit goes through the same signature-capable path every later commit does** // (`GitCommitOperation`), which is what retired this method's config materialization. // // Until the auto-commit card there was no way to hand libgit2 a signature through SwiftGitX // — `commit(message:)` leaves `author`/`committer` null and libgit2 falls back to // `git_signature_default`, which reads a merged config ladder the sandbox cannot see — so // add-git wrote `user.name`/`user.email` into the fresh repository's own config to give that // fallback something to find. That was an explicit interim, and it is gone: **nothing in the // app writes those keys any more.** The identity resolves at commit time, in one place // (`GitCommitOperation.userIdentity(at:)`), repo-local config winning over the derived // default exactly as 06 states — and a repository the app created now looks like one `git // init` made, with no opinion of ours baked into its config. The popover's identity fields // (a later card) are what will write that file, because there "the setting *is* the file". // // Every path `git status` reports is staged — full `git add -A` semantics, `.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"). let identity = GitCommitOperation.userIdentity(at: boardRoot) let outcome = GitCommitOperation.perform( at: boardRoot, commits: [PlannedCommit( paths: GitCommitOperation.changedPaths(at: boardRoot).map(\.path), message: initialCommitSubject, author: identity, committer: identity )] ) switch outcome { case .committed: return .success(branchName(at: boardRoot) ?? initialBranchName) case .nothingToCommit: // A board with no files at all — `git init` on an empty folder. The repository exists, // which is what add-git promised; the first settled change takes the root commit through // the ordinary engine (06 ▸ Rules ▸ Abnormal repo states: an unborn HEAD "is normal git // mode"), and the branch line has a name to show either way. return .success(branchName(at: boardRoot) ?? initialBranchName) case .locked: return .failure(GitOperationFailure( operation: operation, message: "another program is using this repository's index" )) case let .held(pause): return .failure(GitOperationFailure(operation: operation, message: pause.explanation)) case let .failed(failure): logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(failure.message, privacy: .public)") return .failure(GitOperationFailure(operation: operation, message: failure.message)) } } /// **The last-chance `.gitignore` seed, immediately before the initial commit.** /// /// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md ▸ Repository hygiene, /// re-ruled: "`.gitignore` seeded on every board, never touched after … git or not"). Every board /// the app creates is born with one, and every board it opens is healed into having one /// (`BoardStore.seedGitignore`) — and add-git can only run on a board that is *open* and writable, /// so by the time this line is reached the file is essentially always already there and this call /// writes nothing. /// /// **It stays anyway, and stays here — before the stage below.** The one case it still answers is /// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient /// failure that armed its memo, a picture that has not changed since), the initial commit would /// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where /// this app has no operation that could ever remove it (06 ▸ Deleting never forgets). One /// `lstat` on the one path that mints a repository is a cheap insurance policy against a /// permanent record. /// /// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s — one seed text, one write-only-when-free /// rule, `lstat` semantics — so this cannot drift from what board creation and the heal write. /// /// A write that fails is not a failure of add-git. The repository exists, the commit that follows /// simply will not carry a `.gitignore`, and the board's own heal will try again at the next /// open — surfacing a banner about a courtesy file would be louder than the thing it reports. private static func seedGitignoreIfAbsent(at boardRoot: URL) { do { try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot) } catch { logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)") } } // MARK: Reads /// **Whether libgit2 can open the repository at the board root at all** — the detection-time /// probe behind 06-history-undo.md ▸ Rules' corrupt-`.git` loud failure (ruled 2026-07-31): /// "a corrupt or unopenable repo never falls to mode none … the failure is **loud**". /// /// It is deliberately the *same* call every read here already makes (`Repository.open`), so /// "unreadable" means exactly what it means to the rest of this file rather than being a second /// opinion about the same repository. `git_repository_open` validates the layout — `HEAD`, /// `objects/`, `refs/` — resolves a `gitdir:` pointer file, and refuses a repository whose /// format version or extensions it does not implement, which is why a SHA-256 repository lands /// here "by construction" (06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot /// open takes the corrupt-repo loud-failure path"). /// /// A board with no `.git` at all answers `false` too — there is no repository to read — but that /// is not a state any caller reaches: the probe runs only in mode `git`, which is exactly the /// mode a root `.git` defines. /// /// Read-only, like everything in this section: opening a repository writes nothing, and a /// repository that fails to open has not been touched at all. nonisolated static func canOpen(at boardRoot: URL) -> Bool { guard BoardGitMode.hasGitEntry(at: boardRoot) else { return false } return (try? Repository.open(at: boardRoot)) != nil } /// 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) } }