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