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
+129
View File
@@ -0,0 +1,129 @@
import Foundation
import Testing
@testable import Kanban
/// **Nearest-`.git`-wins, freshly at every open** (06-history-undo.md Rules Detection) the one
/// question every git surface starts from, pinned against real directories rather than against a
/// mocked filesystem, because what it is *about* is what is on disk.
///
/// The rule has four claims and this file is one test per claim: root wins, an ancestor is nested,
/// neither is `none`, and the answer is re-derived rather than remembered "a board can therefore
/// change mode between opens (e.g. the user ran `git init` in a terminal) the app just reflects
/// what it finds."
// MARK: - Fixtures
/// A `.git` **directory** with a plausible ref inside what `git init` leaves.
private func makeGitDirectory(at parent: URL) throws {
let gitDirectory = parent.appendingPathComponent(".git", isDirectory: true)
try FileManager.default.createDirectory(at: gitDirectory, withIntermediateDirectories: true)
try Data("ref: refs/heads/main\n".utf8).write(to: gitDirectory.appendingPathComponent("HEAD"))
}
/// A `.git` **file** what a linked worktree or a submodule leaves. Still a repository, and the
/// reason detection asks `fileExists` rather than `isDirectory`.
private func makeGitPointerFile(at parent: URL, to target: String) throws {
try Data("gitdir: \(target)\n".utf8).write(to: parent.appendingPathComponent(".git"))
}
/// A board folder inside the fixture, so an *ancestor* can carry the repository.
private func makeSubfolder(_ fixture: WriterFixture, named name: String) throws -> URL {
let url = fixture.root.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
// MARK: - Detection
@Suite("Board git mode ▸ detection")
struct BoardGitModeTests {
@Test("A `.git` at the board root is git mode")
func rootRepositoryIsGitMode() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
}
@Test("A `.git` *file* is a repository too — a worktree is not mode none")
func aWorktreePointerIsGitMode() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitPointerFile(at: fixture.root, to: "/somewhere/else/.git/worktrees/board")
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
}
@Test("No `.git` at the root and none above it is mode none")
func aPlainFolderIsModeNone() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
}
@Test("A `.git` at an ancestor and none at the root is repo-nested")
func anEnclosingRepositoryIsRepoNested() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let board = try makeSubfolder(fixture, named: "project/docs/board")
#expect(BoardGitMode.detect(boardRoot: board) == .repoNested)
#expect(BoardGitMode.enclosingRepositoryRoot(above: board)?.standardizedFileURL
== fixture.root.standardizedFileURL)
}
@Test("Nearest wins: a board with its own `.git` inside a repository is git mode, not nested")
func theNearestRepositoryWins() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeGitDirectory(at: fixture.root)
let board = try makeSubfolder(fixture, named: "board")
try makeGitDirectory(at: board)
#expect(BoardGitMode.detect(boardRoot: board) == .git)
}
@Test("The walk terminates above a board at the filesystem root, finding nothing")
func theAncestorWalkTerminates() {
// The one hazard this walk has ever had: NSURL-bridged URLs whose
// `deletingLastPathComponent` grows "/.." forever instead of settling at "/". The temp
// directory has no repository above it, so the honest answer is `nil` reached, not hung.
#expect(BoardGitMode.enclosingRepositoryRoot(above: URL(fileURLWithPath: "/")) == nil)
}
// MARK: Freshness
@Test("Detection is re-derived at every open: a board that gains a `.git` opens in git mode next time")
func modeChangesBetweenOpens() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none, "the first open")
// What a user does in a terminal under a closed board.
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git, "the next open reflects what it finds")
}
@Test("And a board that loses its `.git` opens back in mode none")
func modeChangesBackBetweenOpens() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try makeGitDirectory(at: fixture.root)
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .git)
try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".git"))
#expect(BoardGitMode.detect(boardRoot: fixture.root) == .none)
}
}