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
+27 -1
View File
@@ -535,6 +535,28 @@ public final class BoardStore: HealHost {
@ObservationIgnored
var announce: @MainActor (String?) -> Void = { AccessibilityAnnouncer.post($0) }
/// **Where git path history reaches the loader** (01-storage-format.md Fractal layout
/// Rules, the duplicate-id winner rule; `BoardLoader.IdentityHistoryRanker`) `nil` on every
/// board the app manages no git for, which is every free-tier board and every Pro board without
/// a repo at its root.
///
/// A **provider** rather than a ranker, for two reasons that point the same way. Each load wants
/// its own ranker, so that a load never answers from a history that has moved since the last one
/// (the ranker caches internally, once, per load). And add-git flips a board into git mode
/// mid-session, which a closure asked at load time absorbs by construction while a value handed
/// over at composition never could.
///
/// `@MainActor` because it is called here, on the main actor, at the head of each reload; what
/// it returns is `Sendable` and does its git work off-main, inside the walk that consults it.
///
/// **The board's first load predates this** `init` runs inside `BoardStoreRegistry.acquire`,
/// before a session exists to compose the git state that supplies it so an opening board's
/// duplicate-id ladder falls through to birth date, and every reload after it consults history.
/// Deliberate, and the narrow cost of composing the git state where the design puts it
/// (`AppModel.beginSession`) rather than where the first walk happens to run.
@ObservationIgnored
var makeIdentityHistoryRanker: (@MainActor () -> BoardLoader.IdentityHistoryRanker?)?
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")
// MARK: - Init
@@ -628,6 +650,10 @@ public final class BoardStore: HealHost {
let generation = reloadGeneration
let root = rootURL
let barrier = loadBarrier
// Asked once per load, on the main actor, and answered off it: what comes back is a lazy
// `Sendable` value that touches libgit2 only if this walk finds a duplicate identity to
// break a tie for. `nil` everywhere the app manages no git.
let historyRanker = makeIdentityHistoryRanker?()
reloadInFlight = true
Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")
@@ -636,7 +662,7 @@ public final class BoardStore: HealHost {
// and the loader's typed error is lost on the way into `Result`.
let outcome: Result<LoadResult, BoardLoadError>
do throws(BoardLoadError) {
outcome = .success(try BoardLoader.load(boardRoot: root))
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
} catch {
outcome = .failure(error)
}