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 } } /// **A `.git` file aimed at nothing** — the worktree/submodule pointer shape (`gitdir: …`), which /// detection reads as a repository (presence is presence, 06 ▸ Rules ▸ Detection) and libgit2 cannot /// open, because the directory it names is not there. private func plantDanglingGitPointer(in fixture: WriterFixture) throws { let target = fixture.root.appendingPathComponent("nowhere/.git/worktrees/board").path try fixture.file(".git", Data("gitdir: \(target)\n".utf8)) } /// **A SHA-256 repository, by hand** — the layout libgit2 validates, plus the two config keys /// `git init --object-format=sha256` writes (06 ▸ Repository hygiene: "SHA-256 repositories are /// unsupported, safely … an adopted SHA-256 repo the engine cannot open takes the corrupt-repo /// loud-failure path"). /// /// Built by hand rather than by `git init --object-format=sha256` for the file's standing reason: /// there is no `/usr/bin/git` in this feature's promise, so there is none in its tests. What makes /// the fixture honest is that nothing here is a mock — the bytes are the ones git writes, and the /// refusal is libgit2's own. private func plantSHA256Repository(in fixture: WriterFixture) throws { try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8)) try fixture.file(".git/objects/info/.keep", Data()) try fixture.file(".git/refs/heads/.keep", Data()) try fixture.file(".git/config", Data(""" [core] \trepositoryformatversion = 1 \tbare = false [extensions] \tobjectformat = sha256 """.utf8)) } // 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: - The unreadable repository /// **A `.git` that isn't a valid repository still reads as git mode — and fails loudly** /// (06-history-undo.md ▸ Rules, ruled 2026-07-31). /// /// The probe is `GitRepository.canOpen(at:)` — the same `Repository.open` every read in that file /// makes — run at composition, seeding the committer's pause so the whole git surface is held from /// the first moment rather than from the first debounce. Every fixture here is a real shape from the /// wild: a half-made `.git`, a worktree pointer aimed at nothing, and a SHA-256 repository this /// engine has no support for. @MainActor @Suite("HistoryStore ▸ the unreadable repository") struct HistoryStoreUnreadableRepositoryTests { @Test("A repository that opens reads readable, and holds nothing") func aValidRepositoryIsReadable() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await first.addGit()) // The next open, which is where the probe actually runs. let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(git.mode == .git) #expect(!git.isRepositoryUnreadable) #expect(git.committer?.pause == nil) #expect(GitRepository.canOpen(at: fixture.root)) } @Test("A corrupt `.git` stays git mode, reads unreadable, and holds the surface from the first moment") func aCorruptGitDirectoryIsUnreadable() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } // A `.git` with nothing in it but a plausible HEAD: enough for detection, which asks the // filesystem one question, and not a repository at all to libgit2. try plantGitDirectory(in: fixture) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) // **Never a fall to mode none** — "detection is presence-shaped … a corrupt or unopenable // repo never falls to mode none", which is what keeps add-git from ever being offered // against an existing `.git` ("init into a repairable repo is exactly the never-mutate // hazard"). #expect(git.mode == .git) #expect(git.isRepositoryUnreadable) #expect(!GitRepository.canOpen(at: fixture.root)) // The pause is seeded at *detection*, before anything has been attempted: the surface is // held and the banner is raised at the open rather than a debounce later. #expect(git.committer?.pause == .unreadable) #expect(git.committer?.lastFailure == nil, "a pause is not a failure") // And the one operation that could make it worse is refused, whatever the mode read. #expect(await git.addGit() == false) } @Test("A worktree pointer aimed at nothing reads unreadable — the file shape, not just the directory one") func aDanglingPointerIsUnreadable() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try plantDanglingGitPointer(in: fixture) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence") #expect(git.isRepositoryUnreadable) } @Test("A SHA-256 repository takes the same path, by construction") func aSHA256RepositoryIsUnreadable() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try plantSHA256Repository(in: fixture) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(git.mode == .git) // 06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the // corrupt-repo loud-failure path — never a silent fall to mode-none". #expect(git.isRepositoryUnreadable) } @Test("Probing an unreadable repository touches nothing") func theProbeIsARead() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try plantGitDirectory(in: fixture) let before = try snapshotGitDirectory(fixture.root) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(git.isRepositoryUnreadable) // "Lanework leaves the repository untouched" — the same bytes and the same mtimes, on the // one path where a repair instinct would be most tempting. #expect(try snapshotGitDirectory(fixture.root) == before) } @Test("The identity write is refused against a repository the app cannot open") func identityWritesAreRefused() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } try plantGitDirectory(in: fixture) let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) await git.writeIdentity(name: "Ada", email: "ada@example.com") #expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open") #expect(git.identityFailure == nil, "and nothing was attempted, so there is nothing to report") } /// The seam every git operation consults before it runs (`GitCommitOperation.reading`), asked /// directly: one word is what holds the auto-commit flush, skips housekeeping, disables Undo/Redo /// and the branch controls, and defers the interrupted-operation recovery. @Test("The repository reading reports the pause every operation gates on") func theReadingReportsThePause() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try plantGitDirectory(in: fixture) let reading = GitCommitOperation.reading(at: fixture.root) #expect(reading.pause == .unreadable) #expect(!reading.isUnborn) #expect(!reading.isIndexLocked) // Optional work simply does not happen (06 ▸ Repository hygiene: skipped under a pause). #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held)) // And the app never aborts its own leftover against a repository it cannot open — the stamp // is kept, not cleared, so the leftover stays recognizable as this app's. let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: nil) #expect(GitOperationRecovery.decide(stamp: stamp, pause: .unreadable) == .nothingToDo) #expect(GitOperationRecovery.decide(stamp: stamp, pause: .merge) == .abort(stamp), "every other pause still means the app's own leftover") } } // 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 a `.gitignore` into the initial commit") func addGitSeedsTheIgnoreFile() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) #expect(await git.addGit()) // The one file the app ever writes into a board because of git, and the one moment it writes // it (06 ▸ Repository hygiene). `RepositoryHygieneTests` carries the rest of the rule — the // untouched existing file, the `.DS_Store` that never enters history, adoption seeding // nothing; here it is only the fact that add-git's tree includes it. #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) } @Test("Create re-runs full detection and refuses a board that became repo-nested") func createRefusesAStaleModeNone() async throws { // **The hardening** (06 ▸ Rules ▸ Detection, ruled 2026-07-31): "add-git's create re-runs full // detection and refuses unless it reads clean none, so the forbidden nested init is impossible // even on a raced or stale read." let outer = try WriterFixture() defer { outer.tearDown() } 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")) // Composed while the enclosing folder is still a plain one: the store's mode is `none`, and // that is the reading that goes stale. let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro)) #expect(git.mode == .none) // A terminal `git init` one level up, after the detection the store is holding. try plantGitDirectory(in: outer) #expect(await git.addGit() == false, "a root-only check would have let this through") #expect( !FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path), "no nested repository, ever" ) #expect(git.mode == .none, "a refused add-git changes nothing, mode included") } @Test("Create refuses a board that a fresh detection reads unverifiable — a denied ancestor") func createRefusesUnverifiable() throws { // **The tightened guard** (06 ▸ Rules ▸ Detection): "only a genuinely clean `.none` reading // proceeds" — a stale `.none` that has since become unverifiable is refused exactly like one // that has since become repo-nested (`createRefusesAStaleModeNone` above). let outer = try WriterFixture() defer { outer.tearDown() } let blocked = outer.root.appendingPathComponent("blocked", isDirectory: true) let boardRoot = blocked.appendingPathComponent("board", isDirectory: true) try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true) try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md")) try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) } #expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable, "the fixture is set up correctly") let failure = GitRepository.create(at: boardRoot) guard case .failure(let reason) = failure else { Issue.record("initializing where detection cannot rule out a repository must be refused") return } #expect(reason.operation == "Adding git to this board") #expect(!reason.message.isEmpty) #expect( !FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path), "no repository created on an unverifiable read" ) } @Test("A failure answers at the form when it is up, and at the banner when it is not") @MainActor func aFailureAnswersAtTheFormOrTheBanner() async throws { // **Form-anchored operations answer at the form first** (06 ▸ Interaction with external // writers, ruled 2026-07-31) — "inline is the primary surface, never a silence trap". let fixture = try makeBoard() defer { fixture.tearDown() } // Mode is read once, at composition — so a store composed before a `.git` appeared still says // `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the // question is where the answer lands. let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro)) try plantGitDirectory(in: fixture) var banners: [String] = [] git.reportFailure = { banners.append($0.message) } // The form is up: inline, and nothing on the strip. git.noteFormVisible(true) #expect(await git.addGit() == false) #expect(git.lastFailure != nil) #expect(banners.isEmpty, "the user is looking at the form the answer belongs in") // Dismissing it dismisses the stale error. git.noteFormVisible(false) #expect(git.lastFailure == nil) // Asked again with no form on screen, the answer takes the banner instead of nobody. #expect(await git.addGit() == false) #expect(git.lastFailure == nil) #expect(banners.count == 1) } } // 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 arrived with the undo/redo card: a Pro session on a git board binds // the git substrate over exactly this mode (12-editions.md ▸ The provider seam). #expect(session.history is GitHistoryProvider) } @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") } }