Build HistoryStore — opt-in git and mode detection
The pro-m1 foundation card. SwiftGitX 0.4.0 (bundled libgit2, the pathfinder's pin) joins the one target; new Kanban/Git/ holds BoardGitMode (pure nearest-.git-wins detection, .git-as-file counts, NSString ancestor walk), HistoryStore (@MainActor @Observable; compose() is the tier gate — free tier gets no object, no detection, no stat), GitRepository (scope-confined SwiftGitX handles: create = init + HEAD forced to main + whole-tree "Initial board state" commit; branch reads incl. unborn/detached; path-history ranks), GitIdentity (derived default as a pure function + repo-local config reader — not libgit2's merged ladder), and GitPathHistory (Mutex-guarded lazy ranker). beginSession composes the git state beside the tier and feeds BoardStore.makeIdentityHistoryRanker; git-mode loads pass the git-backed IdentityHistoryRanker to BoardLoader. The popover's git slot resolves a pure five-way matrix: free tier unchanged (absent / BoardGitNote), Pro mode-aware — Add Git on mode none, honest prose on repo-nested, read-only branch line on git. Provider binding unchanged: both tiers still bind native until the undo/redo card. 42 new tests across 8 suites, all repositories built through bundled libgit2; InertGitTests untouched and green. 2194 tests / 375 suites. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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<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)))
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user