Files
lanework/Kanban/Git/GitRepository.swift
T
rzen a7f35a0e5a Implement repository hygiene
Add-git seeds a minimal .gitignore (.DS_Store) before the initial
stage — the seed rides "Initial board state" and .DS_Store never
enters history; an existing .gitignore (or a directory wearing the
name) is left alone forever, adoption and repo-nested seed nothing.

GitHousekeeping is the periodic loose-object repack: filesystem
enumeration of objects/<2hex>/<38hex> (never git_odb_foreach, which
would rewrite the whole database into a fresh pack each pass),
git_packbuilder_insert one oid at a time, additive pack write — and
deletion only after each oid is re-verified against the written
pack opened as a standalone one-pack odb with no loose backend. Any
failure returns before deleting; the worst case is a stray pack.
Nothing prunes, expires, or consolidates — existing packs
accumulate, recorded as the accepted cost of never rewriting
storage the app didn't write. GitHousekeeper schedules it: git's
own 6700 threshold, 8s after session activation (outlasting the
launch catch-up), background priority, skipped under pause states,
held locks, or an in-flight commit, never retried — the next open
tries again. Free tier composes none of it.

20 new tests: full-odb equality, per-oid survival, identical walks,
byte+mtime-identical refs/HEAD/working tree, whole-.git identity on
every declined pass, and deleting-never-forgets. 2394 tests / 412
suites green; InertGitTests untouched. Closes pro-m1-git-undo.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 17:06:57 -04:00

405 lines
22 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. The one seed it does write — a `.gitignore`, at init and never again
/// (06 ▸ Repository hygiene) — is the app's last word on that file rather than the start of a
/// relationship with it.
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"
/// **The whole of the seeded `.gitignore`** (06-history-undo.md ▸ Repository hygiene: "Adding git
/// to a board writes a minimal `.gitignore` (`.DS_Store`) if none exists").
///
/// One line, because one line is what the rule says and because every additional entry would be
/// the app deciding something about a file it is about to stop having opinions on. `.DS_Store` is
/// the entry that earns its place: the Finder writes one into every folder a user looks at, and
/// on a board that means one per lane and one per card, each churning as icons and window
/// positions move — noise that would otherwise be committed by the whole-tree stage, forever,
/// under the user's own name.
static let seededGitignore = ".DS_Store\n"
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 the one seed the app ever writes: a minimal `.gitignore`, if the board has
/// none, in the initial commit rather than after it (`seedGitignoreIfAbsent`).
///
/// **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<String, GitOperationFailure> {
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)))
}
// **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 `.gitignore` seed, written at init and never again** (06-history-undo.md ▸ Repository
/// hygiene: "the app never edits an existing one and never manages the file afterward — it's the
/// user's from then on").
///
/// Three properties, and they are the feature:
///
/// - **Only when absent.** A board that already carries a `.gitignore` — from a template, from a
/// clone, from the user — is left byte for byte alone. `fileExists` rather than a read, so a
/// *directory* wearing the name is left alone too (`IntegrityRules.claimedRootNames` marks
/// `.gitignore` as one of the two claimed names whose squatters are never displaced, precisely
/// because nothing in the app reads this file).
/// - **Only here.** This is the one call site, on the one path that creates a repository. Nothing
/// re-checks it, no heal restores it, no later version of the app appends to it: a user who
/// deletes the seeded line has deleted it.
/// - **Only on the app's own init.** Adoption seeds nothing — an adopted repository is somebody
/// else's init, and 06's rule is about what the app writes when *it* creates one. A repo-nested
/// board seeds nothing either, and structurally cannot: `HistoryStore.addGit` refuses any mode
/// but `none`, so this function is unreachable from there.
///
/// 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 a board with none is an ordinary board — surfacing a
/// banner about a courtesy file would be louder than the thing it reports.
private static func seedGitignoreIfAbsent(at boardRoot: URL) {
let url = boardRoot.appendingPathComponent(".gitignore")
guard !FileManager.default.fileExists(atPath: url.path) else { return }
do {
try Data(seededGitignore.utf8).write(to: url, options: .atomic)
} catch {
logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
}
}
// 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)
}
}