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