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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user