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,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]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user