Files
lanework/Kanban/Git/GitRepository.swift
T

442 lines
24 KiB
Swift

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<String, GitOperationFailure> {
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)
}
}