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:
2026-07-31 13:18:07 -04:00
parent 9f8eebe23b
commit 189af238a1
15 changed files with 1943 additions and 17 deletions
+104
View File
@@ -0,0 +1,104 @@
import Foundation
// MARK: - BoardGitMode
/// **Which git mode a board opened in** (07-sync-collab.md the mode state machine;
/// 06-history-undo.md Rules Detection).
///
/// A board has exactly one mode at a time and the mode is not fixed at creation "a board may be
/// created plain, gain git later, and later still gain a remote" (07). What decides it is one
/// question asked of the filesystem, `nearest-.git-wins`, and `detect(boardRoot:)` below is the
/// whole of that question.
///
/// ### Three cases, and the third is not a degraded second
///
/// `repoNested` is not "git mode with the repository somewhere else". A board inside a user's
/// existing repository gets **no app-managed git at all** "no nested repo, no commits into the
/// user's repo, no undo" (06 Rules) which makes it as distinct from `git` as `none` is, and the
/// reason it is a case rather than a flag on `git`.
///
/// ### The remote half is deliberately absent
///
/// 07's state machine has a fourth state, git + remote. It is not here because a remote is a
/// property of a repository the app has already decided it manages remote detection, tracking and
/// the ahead/behind badge are pro-m2's, behind this same seam. What this enum answers is the
/// question every later surface starts from: does the app manage git for this board at all.
public enum BoardGitMode: String, Sendable, Equatable, CaseIterable {
/// No `.git` at the board root and none above it. Plain folders on local disk the only mode
/// the free tier ships (12-editions.md Tier matrix), and the one add-git moves a board out of.
case none
/// A `.git` at the board root: the app manages this board's history. Reached two ways and they
/// are indistinguishable by design the app's own add-git (opt-in init), or **adoption**, "a
/// board whose root already contains `.git` opens in git mode, silently the repo's presence
/// *is* the opt-in" (06 Rules).
case git
/// No `.git` at the board root but one above it: the board lives inside somebody else's
/// repository, which the app "leaves strictly alone" (06 Rules). The popover says so in
/// prose the add-git action is absent because it cannot apply, never hidden or greyed.
case repoNested
}
// MARK: - Detection
public extension BoardGitMode {
/// **Nearest-`.git`-wins, freshly at every board open** (06-history-undo.md Rules): `.git` at
/// the board root `.git`; no `.git` at the root but one at any ancestor `.repoNested`;
/// neither `.none`.
///
/// ### Open-time only, and this function is the whole of "open-time"
///
/// "A `git init` under an open mode-none board takes effect at the next open the running
/// session keeps its mode, and the watcher does not scan for `.git` appearing (no mid-session
/// mode flips from watching; stated here so it isn't rediscovered as a bug)" (06). Nothing
/// calls this on a reload path, and `FolderWatcher`'s `.git` filtering which exists to ignore
/// git churn is what makes that structural rather than a rule somebody has to keep: there is
/// no event a re-detection could hang off even if one wanted it. The one deliberate mid-session
/// transition is the app's own add-git (`HistoryStore.addGit`), a *commanded* flip, which sets
/// the mode directly rather than re-running this.
///
/// A board can therefore be a different mode at its next open than at this one, and that is the
/// designed behaviour, not a cache to invalidate: "the app just reflects what it finds".
///
/// Pure and total a directory it cannot read simply has no `.git` in it, which is `.none`,
/// the same answer an unreadable board would fail to open with anyway.
static func detect(boardRoot: URL) -> BoardGitMode {
if hasGitEntry(at: boardRoot) { return .git }
if enclosingRepositoryRoot(above: boardRoot) != nil { return .repoNested }
return .none
}
/// Whether `url` directly contains a `.git`, **whatever kind of node that is**: a directory in
/// an ordinary repository, a plain file (`gitdir: `) in a linked worktree or a submodule. Both
/// are repositories to git, so both are repositories here a check that insisted on a
/// directory would read a worktree as mode `none` and offer to initialize a second repo on top
/// of one.
static func hasGitEntry(at url: URL) -> Bool {
FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path)
}
/// The nearest ancestor of `boardRoot` that carries a `.git`, or `nil` when there is none the
/// repo-nested half of detection, exposed because the popover's honest explanation is about a
/// repository that exists somewhere specific, and a later card may well want to name it.
///
/// **The walk runs on plain path strings, never on `URL`s** carried over from the pathfinder,
/// where the URL version was a shipped hang. URLs arriving from AppKit surfaces (save panel,
/// bookmark resolution, window restoration) are NSURL-bridged, and for those
/// `deletingLastPathComponent` above `/` grows `/..` forever instead of reaching a fixed point
/// the way native Swift URLs do: the loop never terminated in the app (one core pegged, no repo
/// ever detected) while URL-based unit tests passed. `NSString`'s path math is a pure string
/// operation that terminates at `/` regardless of where the URL came from.
static func enclosingRepositoryRoot(above boardRoot: URL) -> URL? {
var path = (boardRoot.standardizedFileURL.path as NSString).deletingLastPathComponent
while !path.isEmpty {
let candidate = URL(fileURLWithPath: path, isDirectory: true)
if hasGitEntry(at: candidate) { return candidate }
if path == "/" { break }
path = (path as NSString).deletingLastPathComponent
}
return nil
}
}
+190
View File
@@ -0,0 +1,190 @@
import Foundation
// MARK: - GitIdentity
/// **Who the app's commits are authored by** (06-history-undo.md Interaction with external
/// writers "Where the user's git identity comes from").
///
/// Two sources, in the design's own order and the order is git's own, which is the point:
///
/// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox
/// because it lives under the board root, and the natural state of adopted/cloned boards." The
/// popover's name/email fields (a later card) write exactly that file: "the setting *is* the
/// file, portable to any git client, per-board by nature".
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
/// `shortname@hostname` git's own no-config fallback shape, zero ceremony."
///
/// What is deliberately *not* a source is `~/.gitconfig`: the app is sandboxed and cannot read it,
/// which 06 states as an honest limit rather than a bug. Nothing here consults libgit2's own config
/// ladder for the same reason a global layer that is unreachable in the shipped app but readable
/// on a developer's machine would make the app's authorship depend on how it was launched.
///
/// The commits this type does *not* speak for are the synthetic ones: foreign changes commit as
/// `Lanework External <external@lanework.invalid>` and `modified-by`-stamped windows as
/// `<slug>@agents.lanework.invalid` (06). Those are the auto-commit card's, and they are pinned
/// strings rather than derivations nothing about them belongs in a type about *the user's*
/// identity.
public struct GitIdentity: Sendable, Equatable {
public let name: String
public let email: String
public init(name: String, email: String) {
self.name = name
self.email = email
}
}
// MARK: - The derived default
public extension GitIdentity {
/// The derived default, as a **pure function of three strings** so the shape 06 names can be
/// proven without asserting anything about the machine the tests run on.
///
/// `fullName` is the account's display name (`NSFullUserName()`), `accountName` its short name
/// (`NSUserName()`), `hostName` the machine's (`ProcessInfo.hostName`). Every one of them can
/// come back empty or shaped in a way git would reject, so each is defended:
///
/// - An empty full name falls back to the account name git does the same when GECOS is blank,
/// and a commit authored by `"" <me@mac>` is a commit no client renders sensibly.
/// - The email's local part and host are sanitized to what an address may contain: a signature
/// with a space or an angle bracket in it is not merely ugly, libgit2 refuses it outright and
/// the commit fails.
/// - An empty host reads `localhost`, which is what a machine with no name is.
static func derived(fullName: String, accountName: String, hostName: String) -> GitIdentity {
let account = accountName.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedName = fullName.trimmingCharacters(in: .whitespacesAndNewlines)
let name = trimmedName.isEmpty ? (account.isEmpty ? "Lanework" : account) : trimmedName
let localPart = addressComponent(account, fallback: "user")
// A trailing dot is legal in a fully-qualified name and useless in an address; `.local`
// hosts keep theirs, which is exactly what git's own fallback produces on a Mac.
let host = addressComponent(
hostName.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".")
? String(hostName.trimmingCharacters(in: .whitespacesAndNewlines).dropLast())
: hostName,
fallback: "localhost"
)
return GitIdentity(name: name, email: "\(localPart)@\(host)")
}
/// The derived default for *this* machine the one impure call, kept to one line so everything
/// above it stays provable.
static func derivedDefault() -> GitIdentity {
derived(
fullName: NSFullUserName(),
accountName: NSUserName(),
hostName: ProcessInfo.processInfo.hostName
)
}
/// **The resolution 06 states**, per key rather than wholesale: a repo-local config naming only
/// `user.name` contributes exactly that and the email still derives git resolves each key on
/// its own, and a half-configured repo is a real state (it is what a `git config user.email`
/// typo leaves behind).
static func resolve(repoLocal: (name: String?, email: String?), derived: GitIdentity) -> GitIdentity {
func configured(_ value: String?, or fallback: String) -> String {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else { return fallback }
return trimmed
}
return GitIdentity(
name: configured(repoLocal.name, or: derived.name),
email: configured(repoLocal.email, or: derived.email)
)
}
/// Characters an address part may carry, with everything else collapsed to `-`. Deliberately
/// conservative rather than RFC-complete: the input is a Mac account name and a Bonjour host
/// name, and the only job is that libgit2 accepts the signature and a git client renders it.
private static func addressComponent(_ raw: String, fallback: String) -> String {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._"))
let mapped = String(
String.UnicodeScalarView(
raw.unicodeScalars.map { allowed.contains($0) ? $0 : Unicode.Scalar("-") }
)
)
let trimmed = mapped.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
return trimmed.isEmpty ? fallback : trimmed
}
}
// MARK: - Repo-local config
/// **The board's own `.git/config`, read as text** (06-history-undo.md: "repo-local `.git/config`
/// wins when present readable in-sandbox because it lives under the board root").
///
/// Read by hand rather than through libgit2's config ladder, deliberately: `git_repository_config`
/// merges the repository, global and system layers, so a value read through it is not the answer to
/// "what does *this repository* say" it is the answer to "what does this machine say", which is
/// the question the sandbox makes unanswerable and which 06 rules out of the identity story
/// entirely. Reading the file the design names gives the same answer in the shipped sandboxed app,
/// in a test, and on a developer's machine with a `~/.gitconfig` full of opinions.
///
/// The parse is tolerant by design: it is looking for two keys in one section of a format that
/// allows comments, indentation and quoting, and anything it fails to understand simply reads as
/// absent which falls through to the derived default, the same place a missing file lands.
enum GitConfigFile {
/// `user.name` / `user.email` as the config file at `gitDirectory/config` states them; both
/// `nil` when the file does not exist, cannot be read, or names neither key.
static func identity(inGitDirectory gitDirectory: URL) -> (name: String?, email: String?) {
let configURL = gitDirectory.appendingPathComponent("config")
guard let text = try? String(contentsOf: configURL, encoding: .utf8) else { return (nil, nil) }
return identity(inConfigText: text)
}
/// The parse, over text the pure half, and where the format's edges are decided.
static func identity(inConfigText text: String) -> (name: String?, email: String?) {
var section: String?
var name: String?
var email: String?
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue }
if line.hasPrefix("[") {
// `[user]`, and `[user "work"]` a subsection is somebody else's scope, so the
// header's first token is what names the section.
let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
section = header
.split(separator: " ", maxSplits: 1)
.first
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
continue
}
guard section == "user", let separator = line.firstIndex(of: "=") else { continue }
let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
switch key {
case "name": name = value.nonEmpty
case "email": email = value.nonEmpty
default: continue
}
}
return (name, email)
}
/// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside
/// quotes is content git's own rule, and the one place a naive strip would corrupt a name.
private static func unquoted(_ value: String) -> String {
if value.hasPrefix("\"") {
let body = value.dropFirst()
guard let closing = body.firstIndex(of: "\"") else { return String(body) }
return String(body[body.startIndex..<closing])
}
let uncommented = value.prefix { $0 != "#" && $0 != ";" }
return uncommented.trimmingCharacters(in: .whitespaces)
}
}
// MARK: - String conveniences
private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}
+51
View File
@@ -0,0 +1,51 @@
import Foundation
import Synchronization
// MARK: - GitPathHistory
/// **One load's answer to "how early did this path enter history"** the object behind
/// `HistoryStore.identityHistoryRanker`, and the git implementation of the seam
/// `BoardLoader.IdentityHistoryRanker` describes.
///
/// ### Lazy, because the question is usually never asked
///
/// The loader consults the ranker **only when it has already found a duplicate identity**
/// (`BoardLoader.dedupeIdentities` gates on a collision before it builds a single occurrence), which
/// on a healthy board is never. So nothing here walks a repository at construction: the map is built
/// on the first `rank(of:)` call and reused for the rest of that load, which means the ordinary case
/// costs one allocation and no libgit2 at all.
///
/// ### Sendable, because the load runs off the main actor
///
/// `BoardStore.startReload` walks the tree in a detached task, so the ranker crosses into it and the
/// closure `BoardLoader` calls is `@Sendable`. The cache is therefore a `Mutex` rather than a plain
/// `var` one lock, held across the walk itself, which is correct rather than merely safe: two
/// concurrent first-callers would otherwise each walk the whole ancestry to compute the same map.
final class GitPathHistory: Sendable {
private let boardRoot: URL
/// `nil` until the first ask see the type's note. The distinction between "not computed" and
/// "computed, and the repository had nothing to say" is what keeps an empty history from being
/// recomputed on every occurrence in a colliding board.
private let ranks = Mutex<[String: Int]?>(nil)
init(boardRoot: URL) {
self.boardRoot = boardRoot
}
/// The seam value the loader takes: lower is earlier, `nil` is untracked or no history.
var ranker: BoardLoader.IdentityHistoryRanker {
BoardLoader.IdentityHistoryRanker { [self] path in rank(of: path) }
}
/// The rank of one board-root-relative path.
func rank(of path: String) -> Int? {
ranks.withLock { cache in
if cache == nil {
cache = GitRepository.pathFirstAppearanceRanks(at: boardRoot)
}
return cache?[path]
}
}
}
+355
View File
@@ -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)
}
}
+162
View File
@@ -0,0 +1,162 @@
import Foundation
import os
// MARK: - HistoryStore
/// **A board's git state** (02-architecture.md Components HistoryStore): which mode the board
/// opened in, the repository behind it when there is one, and the two operations that can change
/// either the app's own add-git, and nothing else.
///
/// ### One per board session, composed under the tier
///
/// `compose(boardRoot:tier:)` is the whole gate: **the free tier gets no `HistoryStore` at all**, so
/// a free-tier session runs no detection, opens no repository, and does not so much as `stat` a
/// `.git` "any `.git` is inert the app never reads history, never commits, never touches `.git`
/// in any way" (12-editions.md The free tier and `.git`), which `InertGitTests` pins against real
/// bytes. Nothing in this type is conditional on a tier, because the tier decided whether the type
/// exists.
///
/// ### What it does not do yet
///
/// This is the foundation card of pro-m1: mode, a repository, add-git, and the loader's path-history
/// ranker. **The provider binding is not part of it** both tiers still bind
/// `NativeHistoryProvider` (`AppModel.makeHistoryProvider`), and the consumer of `mode` is the
/// undo/redo card two cards later, which builds the git `HistoryProviding` implementation over
/// exactly this object. Auto-commit, commit messages, branch controls, the identity fields, remotes
/// and `.gitignore` seeding are each their own card and deliberately absent here.
@MainActor
@Observable
public final class HistoryStore {
/// The board this is the git state of. The board root *is* the repository's working-tree root
/// in git mode that is what mode `git` means.
public let boardRoot: URL
/// **Detected once, at composition, and changed by exactly one thing afterwards.**
///
/// "Detection is nearest-`.git`-wins, checked at every board open never mid-session"
/// (06-history-undo.md Rules). A `git init` run in a terminal under an open board therefore
/// takes effect at its *next* open the watcher does not scan for `.git` appearing, and nothing
/// re-runs `BoardGitMode.detect` for the life of this object.
///
/// The one deliberate mid-session transition is `addGit()` below: "the rule forbids *discovered*
/// flips, never commanded ones."
public private(set) var mode: BoardGitMode
/// The current branch's short name in git mode, `nil` until it has been read (or when there is
/// nothing to read).
///
/// Filled by `refreshBranch()` rather than at composition, deliberately: composition happens on
/// the board-open path, where 02-architecture.md's hang-avoidance doctrine says nothing may
/// block, and opening a repository is libgit2 work small, but work. Detection is a `stat`;
/// this is a read, and it waits until the popover actually asks.
public private(set) var branch: String?
/// Whether add-git is in flight the button's disabled state, and the guard that keeps a double
/// click from running `git_repository_init` twice.
public private(set) var isAddingGit = false
/// The last add-git failure, or `nil` if the last attempt succeeded (or there hasn't been one).
///
/// Surfaced inline in the popover rather than as a banner: the popover is where the operation
/// was asked for and is still open when it answers, and 02-architecture.md's one-shot banner
/// vocabulary is for failures of writes the user made *elsewhere*. DESIGN does not settle
/// add-git's failure surface either way.
public private(set) var lastFailure: GitOperationFailure?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")
init(boardRoot: URL, mode: BoardGitMode) {
self.boardRoot = boardRoot
self.mode = mode
}
/// **The tier gate and the open-time detection, in one line** (12-editions.md The provider
/// seam; 06-history-undo.md Rules Detection) called by `AppModel.beginSession` beside the
/// entitlement read that supplies `tier`.
///
/// `nil` under `.free` means exactly what it says: no git state exists for that session, so no
/// caller can accidentally consult one. Under `.pro` the mode is whatever the filesystem says
/// right now, and a board that has changed mode since its last open simply opens in the new one
/// "the app just reflects what it finds".
///
/// **Adoption needs no step of its own**: a board whose root already carries `.git` lands in
/// `.git` here, silently, with no dialog and nothing to confirm "the repo's presence *is* the
/// opt-in" (06 Rules Adoption).
public static func compose(boardRoot: URL, tier: Tier) -> HistoryStore? {
guard tier == .pro else { return nil }
let mode = BoardGitMode.detect(boardRoot: boardRoot)
logger.debug("board opened in git mode \(mode.rawValue, privacy: .public)")
return HistoryStore(boardRoot: boardRoot, mode: mode)
}
// MARK: - Add git
/// **Opt-in init** (06-history-undo.md Rules): initializes a repository at the board root and
/// immediately commits the whole tree as "Initial board state".
///
/// Reachable from one place the board popover's git section under Pro and from nowhere else:
/// "No silent auto-init, ever", a deliberate pivot from the pathfinder, which initialized a repo
/// under every board it opened.
///
/// **It flips the open board's mode immediately**, which is the design's one sanctioned
/// mid-session transition: "clicking it flips the open board into git mode immediately the
/// popover flows straight into the git controls". The flip is commanded, not discovered, which
/// is what distinguishes it from the `git init` a user runs in a terminal under an open board.
///
/// Only mode `none` can be added to. Mode `git` has nothing to add, and a repo-nested board is
/// one the app "leaves strictly alone" no nested repo, ever.
@discardableResult
public func addGit() async -> Bool {
guard mode == .none, !isAddingGit else { return false }
isAddingGit = true
lastFailure = nil
defer { isAddingGit = false }
let root = boardRoot
// Off the main actor: `git_repository_init` plus a whole-tree stage and commit is real
// filesystem work, and the popover it was clicked in stays live while it runs.
let outcome = await Task.detached(priority: .userInitiated) {
GitRepository.create(at: root)
}.value
switch outcome {
case .success(let branchName):
mode = .git
branch = branchName
Self.logger.notice("add-git initialized a repository at \(root.path, privacy: .public)")
return true
case .failure(let failure):
lastFailure = failure
Self.logger.error("add-git failed: \(failure.description, privacy: .public)")
return false
}
}
/// Reads the current branch name into `branch` the popover's read-only display line, refreshed
/// when the popover opens. A no-op outside git mode.
public func refreshBranch() async {
guard mode == .git else { return }
let root = boardRoot
branch = await Task.detached(priority: .userInitiated) {
GitRepository.branchName(at: root)
}.value
}
// MARK: - The loader's history seam
/// **The git-backed `IdentityHistoryRanker`** (01-storage-format.md Fractal layout Rules;
/// `BoardLoader.IdentityHistoryRanker`), or `nil` on any board the app manages no git for the
/// free tier and modes `none`/`repoNested` alike, all of which fall through to the ladder's
/// remaining rungs (birth date, then traversal order).
///
/// **A fresh ranker per ask, deliberately.** Each one computes its map at most once, lazily, and
/// only if something actually asks which is only when a duplicate identity was found, since
/// that is the only thing `BoardLoader.dedupeIdentities` consults it for. A ranker cached across
/// loads would answer from a history that has since moved; one built per load never can.
public var identityHistoryRanker: BoardLoader.IdentityHistoryRanker? {
guard mode == .git else { return nil }
return GitPathHistory(boardRoot: boardRoot).ranker
}
}