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)
}
}
+46
View File
@@ -31,3 +31,49 @@ struct BoardInfoPopoverTests {
#expect(!BoardGitNote.hasGitDirectory(at: fixture.root))
}
}
/// **The popover's git slot, posture by posture** (03-board-ui.md Board popover; 06-history-undo.md
/// Rules; 12-editions.md The free tier and `.git`).
///
/// `BoardGitSection.resolve` is the whole decision, pulled out as a pure function of the tier and the
/// board's mode precisely so the matrix is assertable the views it selects are SwiftUI and stay
/// untested, exactly as the note's wording and placement do above.
@Suite("Board popover ▸ the git section's posture")
struct BoardGitSectionTests {
@Test("The free tier: absent on an ordinary board, a Pro pointer on a board carrying an inert .git")
func theFreeTierIsContextual() {
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: false) == .absent)
#expect(BoardGitSection.resolve(tier: .free, mode: .none, hasGitDirectory: true) == .proPointer)
}
@Test("Pro: mode none offers add-git, git mode shows the branch")
func proFollowsTheMode() {
#expect(BoardGitSection.resolve(tier: .pro, mode: .none, hasGitDirectory: false) == .addGit)
#expect(BoardGitSection.resolve(tier: .pro, mode: .git, hasGitDirectory: true) == .branch)
}
@Test("A repo-nested board explains itself — the add-git action is absent, not disabled")
func repoNestedExplainsRatherThanDisables() {
let section = BoardGitSection.resolve(tier: .pro, mode: .repoNested, hasGitDirectory: false)
// The design is insistent here: "not a hidden 'add git' but a short explanation the option
// is absent because it *can't* apply, and the UI should teach that rather than look broken"
// (06 Rules). A `.addGit` that rendered disabled would satisfy neither half.
#expect(section == .repoNested)
#expect(section != .addGit)
}
@Test("Every posture is reachable, and none of them is two postures")
func theMatrixIsTotal() {
let resolved = Set(
Tier.allCases.flatMap { tier in
BoardGitMode.allCases.flatMap { mode in
[true, false].map { BoardGitSection.resolve(tier: tier, mode: mode, hasGitDirectory: $0) }
}
}
)
#expect(resolved == Set(BoardGitSection.allCases))
}
}
+152
View File
@@ -0,0 +1,152 @@
import Foundation
import Testing
@testable import Kanban
/// **Where the app's commits get their author from** (06-history-undo.md Interaction with external
/// writers "Where the user's git identity comes from"): repo-local `.git/config` when it names
/// one, the derived `Full Name <shortname@hostname>` default when it doesn't.
///
/// Both halves are pure functions here on purpose. The derivation takes its three strings as
/// arguments rather than reading the machine, so the *shape* is provable on any machine including
/// one whose account has no full name, which is the case the fallbacks exist for. And the config
/// read is a parse over text, so the format's edges (comments, quoting, subsections, a `[user]`
/// section that never appears) are pinned without a repository.
@Suite("Git identity ▸ the derived default")
struct GitIdentityDerivationTests {
@Test("The shape is the account's full name plus shortname@hostname")
func theDerivedShape() {
let identity = GitIdentity.derived(fullName: "Ada Lovelace", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("An account with no full name falls back to its short name rather than committing as \"\"")
func anEmptyFullNameFallsBack() {
let identity = GitIdentity.derived(fullName: " ", accountName: "ada", hostName: "analytical.local")
#expect(identity.name == "ada")
#expect(identity.email == "[email protected]")
}
@Test("Characters an address may not carry are collapsed, not passed to libgit2")
func addressComponentsAreSanitized() {
// libgit2 refuses a signature carrying a space or an angle bracket outright the commit
// fails rather than looking odd so this is a correctness fallback, not cosmetics.
let identity = GitIdentity.derived(
fullName: "Ada Lovelace",
accountName: "ada lovelace",
hostName: "Ada's <Mac>.local"
)
#expect(!identity.email.contains(" "))
#expect(!identity.email.contains("<"))
#expect(!identity.email.contains(">"))
#expect(identity.email == "[email protected]")
}
@Test("A machine with no name reads localhost, and an account with none reads user")
func emptyComponentsHaveHonestFallbacks() {
let identity = GitIdentity.derived(fullName: "", accountName: "", hostName: "")
#expect(identity.name == "Lanework")
#expect(identity.email == "user@localhost")
}
@Test("A trailing dot on a fully-qualified host name is dropped")
func aTrailingDotIsDropped() {
let identity = GitIdentity.derived(fullName: "Ada", accountName: "ada", hostName: "host.example.com.")
#expect(identity.email == "[email protected]")
}
@Test("This machine's derived default is well-formed, whatever this machine is called")
func theMachineDefaultIsWellFormed() {
let identity = GitIdentity.derivedDefault()
#expect(!identity.name.isEmpty)
#expect(identity.email.contains("@"))
#expect(!identity.email.contains(" "))
}
}
@Suite("Git identity ▸ repo-local config wins")
struct GitConfigFileTests {
@Test("A `[user]` section supplies both halves")
func bothKeysAreRead() {
let text = """
[core]
\trepositoryformatversion = 0
[user]
\tname = Ada Lovelace
\temail = [email protected]
"""
let identity = GitConfigFile.identity(inConfigText: text)
#expect(identity.name == "Ada Lovelace")
#expect(identity.email == "[email protected]")
}
@Test("Config wins over the derived default, key by key")
func resolutionPrefersConfigPerKey() {
let derived = GitIdentity(name: "Machine Owner", email: "[email protected]")
let both = GitIdentity.resolve(repoLocal: (name: "Ada", email: "[email protected]"), derived: derived)
#expect(both == GitIdentity(name: "Ada", email: "[email protected]"))
// Half-configured is a real state it is what a `git config user.email` typo leaves and
// git resolves each key on its own.
let nameOnly = GitIdentity.resolve(repoLocal: (name: "Ada", email: nil), derived: derived)
#expect(nameOnly == GitIdentity(name: "Ada", email: "[email protected]"))
let neither = GitIdentity.resolve(repoLocal: (name: nil, email: " "), derived: derived)
#expect(neither == derived, "a blank value is not a value")
}
@Test("Comments, quoting and subsections are read the way git reads them")
func theParseHandlesTheFormatsEdges() {
let text = """
# a comment
; another
[user "work"]
\tname = Wrong Section
[user]
\tname = "Ada # Lovelace"
\temail = [email protected] # trailing comment
"""
let identity = GitConfigFile.identity(inConfigText: text)
// `[user "work"]` is a subsection but still the `user` section git reads its keys as
// `user.name` under a subsection name, and this parse deliberately takes the last value it
// meets rather than inventing subsection scoping for a file that has none in practice.
#expect(identity.name == "Ada # Lovelace", "a `#` inside quotes is content")
#expect(identity.email == "[email protected]", "an unquoted trailing comment is not")
}
@Test("A config with no `[user]` section, or no config at all, names nobody")
func absentConfigNamesNobody() throws {
let empty = GitConfigFile.identity(inConfigText: "[core]\n\tbare = false\n")
#expect(empty.name == nil)
#expect(empty.email == nil)
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let missing = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(missing.name == nil)
#expect(missing.email == nil)
}
@Test("The file on disk is what is read — the board root's own `.git/config`")
func theFileIsRead() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.file(".git/config", Data("[user]\n\tname = Ada\n\temail = [email protected]\n".utf8))
let identity = GitConfigFile.identity(inGitDirectory: fixture.root.appendingPathComponent(".git"))
#expect(identity.name == "Ada")
#expect(identity.email == "[email protected]")
}
}
+449
View File
@@ -0,0 +1,449 @@
import Foundation
import SwiftGitX
import Testing
@testable import Kanban
/// **The board's git state** (06-history-undo.md Rules; 02-architecture.md Components
/// HistoryStore) composed under the tier, detected at open, and changed afterwards by exactly
/// one thing.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with
/// bundled libgit2 and no external git dependency", and a fixture faked out of hand-written files
/// could not tell whether that happened. Nothing in this file shells out to `git` there is no
/// `/usr/bin/git` in the promise this feature makes, so there is none in its tests either.
// MARK: - Fixtures
/// A board with one lane and one card small, and enough for a tree with three `index.md`s in it.
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
return fixture
}
/// A `.git` that is not a repository a directory with a plausible `HEAD` in it. Enough for
/// *detection*, which asks the filesystem one question, and deliberately not enough for libgit2,
/// which is how a test can tell the two apart.
private func plantGitDirectory(in fixture: WriterFixture, under parent: String = "") throws {
let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
}
/// A second (third, fourth) commit, made the way an external writer makes one SwiftGitX directly,
/// not through the app, which has no commit surface until the auto-commit card.
private func commitEverything(at boardRoot: URL, message: String) throws {
let repository = try Repository.open(at: boardRoot)
try repository.add(paths: [])
_ = try repository.commit(message: message)
}
/// Bytes and mtimes of everything under a subtree `InertGitTests`' instrument, in the shape this
/// file needs it: what proves that *reading* a board's mode touched nothing.
private struct SubtreeEntry: Equatable {
let path: String
let data: Data?
let modified: Date
}
private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
let base = root.appendingPathComponent(".git", isDirectory: true)
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: base.path) else { return [] }
var entries: [SubtreeEntry] = []
for case let relative as String in walker {
let url = base.appendingPathComponent(relative)
let attributes = try manager.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else { continue }
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
entries.append(SubtreeEntry(
path: relative,
data: isDirectory ? nil : try Data(contentsOf: url),
modified: modified
))
}
return entries.sorted { $0.path < $1.path }
}
// MARK: - Composition
@MainActor
@Suite("HistoryStore ▸ composition and the tier gate")
struct HistoryStoreCompositionTests {
@Test("The free tier composes no git state at all, on any board")
func theFreeTierComposesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
// Not "mode none on a git board" *nothing*. With no object there is no path by which a
// free-tier session could read history, commit, or touch `.git` (12-editions.md The free
// tier and `.git`, whose byte-level half is `InertGitTests`).
#expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
}
@Test("Pro on a plain board is mode none — and opening one never creates a repository")
func proOnAPlainBoardIsModeNone() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(git.mode == .none)
// "No silent auto-init, ever" (06 Rules) the deliberate pivot from the pathfinder, which
// initialized a repository under every board it opened. Composing twice is the whole test:
// two opens, no repository.
_ = HistoryStore.compose(boardRoot: fixture.root, tier: .pro)
#expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in")
}
@Test("A board whose root already has a repository opens in git mode, silently")
func adoptionNeedsNoStep() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own
// add-git in a previous session), and the repository is simply there.
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await first.addGit())
let afterInit = try #require(GitRepository.headCommit(at: fixture.root))
let before = try snapshotGitDirectory(fixture.root)
// The next open. Adoption is not init: no dialog, no confirmation, no second step the mode
// is simply what the filesystem says, and it says git.
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(second.mode == .git)
// And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes.
// Composition is a `stat`, not an operation.
#expect(GitRepository.headCommit(at: fixture.root)?.oid == afterInit.oid)
#expect(try snapshotGitDirectory(fixture.root) == before)
}
@Test("A board nested inside a repository opens repo-nested")
func nestedBoardsAreDetectedAsNested() throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try plantGitDirectory(in: outer)
let boardRoot = outer.root.appendingPathComponent("docs/board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
#expect(git.mode == .repoNested)
}
@Test("Mode is an open-time fact: a `.git` appearing mid-session does not flip the open board")
func noMidSessionDiscoveredFlip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(git.mode == .none)
// What a `git init` in a terminal under an open board does which is nothing, until the
// next open (06 Rules: "the running session keeps its mode, and the watcher does not scan
// for `.git` appearing").
try plantGitDirectory(in: fixture)
#expect(git.mode == .none, "the open session keeps the mode it composed with")
let nextOpen = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(nextOpen.mode == .git, "and the next open reflects what it finds")
}
}
// MARK: - Add git
@MainActor
@Suite("HistoryStore ▸ add-git")
struct HistoryStoreAddGitTests {
@Test("Add-git initializes a repository at the board root and commits the whole tree")
func addGitInitializesAndCommits() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
#expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install")
#expect(git.mode == .git, "the one commanded mid-session flip")
let head = try #require(GitRepository.headCommit(at: fixture.root))
// "The root commit has its own subject" (06 Rules Abnormal repo states) never a folded
// diff-from-empty, because there is nothing to diff against and forty Adds would bury it.
#expect(head.subject == "Initial board state")
#expect(head.parentCount == 0, "it is the root commit")
// The whole tree, not a hand-picked set: the board, the lane, the card.
let tracked = GitRepository.trackedPaths(at: fixture.root)
#expect(tracked.contains("index.md"))
#expect(tracked.contains("\(Ident.lane1)/index.md"))
#expect(tracked.contains("\(Ident.lane1)/\(Ident.card1)/index.md"))
}
@Test("Add-git seeds no `.gitignore` — repository hygiene is a later card")
func addGitSeedsNoGitignore() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
#expect(!fixture.exists(".gitignore"))
#expect(!GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
}
@Test("The root commit is authored by the derived default when the repo names nobody")
func theRootCommitCarriesTheDerivedIdentity() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
let derived = GitIdentity.derivedDefault()
let head = try #require(GitRepository.headCommit(at: fixture.root))
#expect(head.authorName == derived.name)
#expect(head.authorEmail == derived.email)
}
@Test("The branch is deterministic, and the popover's display line reads it")
func theBranchIsMainAndReadable() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
// Deterministic rather than inherited: libgit2's initial branch comes from an
// `init.defaultBranch` this app cannot read in the sandbox (`GitRepository.initialBranchName`).
#expect(git.branch == "main")
#expect(GitRepository.branchName(at: fixture.root) == "main")
await git.refreshBranch()
#expect(git.branch == "main")
}
@Test("An unborn HEAD still has a branch name — a repository with no commits is normal git mode")
func anUnbornHeadIsNormal() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
// `git init` and nothing else: the shape an adopted repository can genuinely be in
// (06 Rules Abnormal repo states: "an unborn HEAD is normal git mode").
_ = try Repository.create(at: fixture.root)
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(git.mode == .git)
#expect(GitRepository.branchName(at: fixture.root) != nil)
#expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine")
}
@Test("Add-git refuses a board that already has a repository, and changes nothing")
func addGitRefusesAnAdoptedBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await first.addGit())
let head = try #require(GitRepository.headCommit(at: fixture.root))
let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await second.addGit() == false, "there is nothing to add")
#expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized")
}
@Test("Add-git refuses a repo-nested board — no nested repository, ever")
func addGitRefusesANestedBoard() async throws {
let outer = try WriterFixture()
defer { outer.tearDown() }
try plantGitDirectory(in: outer)
let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))
let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
#expect(await git.addGit() == false)
#expect(git.mode == .repoNested)
#expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
}
@Test("A refused add-git says why, in libgit2's words where it has any")
func aRefusalIsExplained() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
// Mode `git` is refused by `HistoryStore` before libgit2 is reached, so the failure the
// popover would show comes from the layer that *would* have run it.
let failure = GitRepository.create(at: fixture.root)
guard case .failure(let reason) = failure else {
Issue.record("initializing over an existing repository must be refused")
return
}
#expect(reason.operation == "Adding git to this board")
#expect(!reason.message.isEmpty)
}
}
// MARK: - The loader's history ranker
@MainActor
@Suite("HistoryStore ▸ the git-backed identity ranker")
struct GitPathHistoryTests {
@Test("A path that entered history earlier ranks lower; an untracked one has no rank")
func ranksFollowHistory() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
// A second card, arriving in a later commit the whole point of the rung.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try commitEverything(at: fixture.root, message: "Add card 'Second'")
// A third, on disk but never committed.
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
let ranker = try #require(git.identityHistoryRanker)
let first = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card1)"))
let second = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card2)"))
#expect(first < second, "lower is earlier")
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card3)") == nil, "untracked is no rank at all")
#expect(ranker.rank(Ident.lane1) == first, "a folder ranks with the first file that landed in it")
}
@Test("No ranker where the app manages no git")
func noRankerWithoutARepository() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(plain.identityHistoryRanker == nil, "mode none injects nothing")
// And the free tier has no `HistoryStore` to ask in the first place pinned in the
// composition suite above.
}
@Test("Git history decides a real duplicate-id collision through the loader")
func historyDecidesTheDuplicateWinner() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await git.addGit())
// The same identity, arriving later in a second lane the copy the duplicate rule is about.
try fixture.item("\(Ident.lane2)/\(Ident.card1)", Item.rich(order: "1024", title: "First (copy)"))
try commitEverything(at: fixture.root, message: "Copy the card")
let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: git.identityHistoryRanker)
let duplicates: [DuplicateIdentity] = result.defects.compactMap {
if case .duplicateIdentity(let duplicate) = $0 { return duplicate }
return nil
}
let duplicate = try #require(duplicates.first)
#expect(duplicate.winner == "\(Ident.lane1)/\(Ident.card1)", "the path that entered history first")
#expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
}
}
// MARK: - Session composition
@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("HistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let model = AppModel(
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
)
return (model, { try? FileManager.default.removeItem(at: folder) })
}
@MainActor
@discardableResult
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
let ref = BoardWindowRef(url: url)
let recordID = model.boardRegistry.recordOpen(of: url)
let store = try model.storeRegistry.acquire(url)
model.boardRegistry.setOpenNow(id: recordID)
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
return ref
}
@MainActor
@Suite("Board sessions ▸ the git state they compose")
struct BoardSessionGitTests {
@Test("A free-tier session carries no git state, even on a board that has a repository")
func freeSessionsCarryNoGitState() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try plantGitDirectory(in: fixture)
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .free }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.git == nil)
#expect(session.gitMode == .none, "the free tier ships exactly one mode")
#expect(session.store.makeIdentityHistoryRanker == nil, "and injects nothing into the loader")
}
@Test("A Pro session on a git board composes git mode and wires the loader's ranker")
func proSessionsCarryTheDetectedMode() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
// A real repository, so the ranker has something to read.
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
#expect(await seed.addGit())
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.gitMode == .git)
let provider = try #require(session.store.makeIdentityHistoryRanker)
let ranker = try #require(provider())
#expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)
// The provider binding is deliberately *not* part of this card: both tiers still bind the
// native stack until the undo/redo card builds the git provider over this mode.
#expect(session.history is NativeHistoryProvider)
}
@Test("A Pro session on a plain board is mode none and injects nothing")
func proSessionsOnPlainBoardsInjectNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let (model, tearDown) = try makeModel()
defer { tearDown() }
model.currentTier = { .pro }
let ref = try openBoard(model, at: fixture.root)
let session = try #require(model.session(for: ref))
#expect(session.gitMode == .none)
let provider = try #require(session.store.makeIdentityHistoryRanker, "the wiring is there")
#expect(provider() == nil, "and it answers nothing on a board with no repository")
}
}