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
}
}